From 1e893fbe37b1ec3f39846e198255c432f2317ef3 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 16:18:07 +0500 Subject: [PATCH 01/14] Start v3: PHP 8.4, Laravel 12/13, CI matrix, Pint, and the user type core Replaces the v2 controllers (laravel/ui traits) and the abstract User with user types, Route::authRoutes(), actions, controllers, middleware and Blade pages. Drops javaabu/helpers, javaabu/activitylog, javaabu/passport, javaabu/schema, laravel/ui and medialibrary. Closes #1 Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 5 + .github/coverage.svg | 20 - .github/workflows/run-tests.yml | 66 -- .github/workflows/tests.yml | 87 +++ .gitignore | 4 +- .php_cs.dist.php | 35 - .scrutinizer.yml | 19 - .styleci.yml | 4 - LICENSE | 21 - README.md | 24 +- composer.json | 77 +- config/config.php | 151 ---- config/javaabu-auth.php | 209 ++++++ ...026_11_04_000000_create_passkeys_table.php | 30 + lang/dv/validation.php | 5 - lang/en/validation.php | 5 - phpunit.xml.dist | 2 +- pint.json | 20 + resources/js/auth.ts | 21 + resources/js/lib/webauthn.ts | 185 +++++ resources/js/passkeys.ts | 94 +++ resources/views/account-inactive.blade.php | 19 + resources/views/confirm-password.blade.php | 30 + resources/views/forgot-password.blade.php | 21 + resources/views/layouts/account.blade.php | 26 + resources/views/layouts/auth.blade.php | 22 + resources/views/login.blade.php | 53 ++ .../views/partials/account-nav.blade.php | 15 + .../views/partials/passkey-login.blade.php | 12 + resources/views/partials/status.blade.php | 5 + resources/views/register.blade.php | 22 + resources/views/reset-password.blade.php | 17 + .../settings/partials/api-tokens.blade.php | 35 + .../settings/partials/passkeys.blade.php | 43 ++ .../settings/partials/sessions.blade.php | 27 + .../settings/partials/two-factor.blade.php | 62 ++ resources/views/settings/profile.blade.php | 51 ++ resources/views/settings/security.blade.php | 41 ++ .../views/two-factor-challenge.blade.php | 32 + .../views/update-required-password.blade.php | 19 + resources/views/verify-email.blade.php | 21 + src/Actions/AttemptLogin.php | 125 ++++ src/Actions/CreateNewUser.php | 51 ++ src/Actions/DeleteUser.php | 22 + src/Actions/LoginUser.php | 28 + src/Actions/PasswordValidationRules.php | 14 + src/Actions/ResetUserPassword.php | 37 + src/Actions/StartAuthenticatedSession.php | 65 ++ .../ConfirmTwoFactorAuthentication.php | 32 + .../DisableTwoFactorAuthentication.php | 24 + .../EnableTwoFactorAuthentication.php | 33 + .../TwoFactor/GenerateNewRecoveryCodes.php | 27 + src/Actions/UpdateUserPassword.php | 36 + src/Actions/UpdateUserProfileInformation.php | 55 ++ src/AuthServiceProvider.php | 250 +++++-- .../DetectsGmailAliases.php | 22 +- src/Concerns/HasAccountStatus.php | 88 +++ src/Concerns/HasPasskeys.php | 34 + src/Concerns/HasSessions.php | 31 + src/Concerns/HasUserType.php | 31 + src/Concerns/LocksAfterFailedLogins.php | 79 +++ src/Concerns/RequiresPasswordUpdate.php | 29 + src/Concerns/TwoFactorAuthenticatable.php | 105 +++ src/Contracts/ConfirmPasswordContract.php | 12 - src/Contracts/CreatesNewUsers.php | 16 + src/Contracts/DeletesUsers.php | 14 + src/Contracts/ForgotPasswordContract.php | 15 - src/Contracts/HasAccountStatus.php | 15 + src/Contracts/HasGuardContract.php | 15 - src/Contracts/HasUserTypeContract.php | 10 - src/Contracts/HasUserTypeRedirectContract.php | 10 - src/Contracts/LocksAfterFailedLogins.php | 15 + src/Contracts/LoginContract.php | 12 - src/Contracts/LoginMethod.php | 28 + src/Contracts/RegisterContract.php | 12 - src/Contracts/RequiresPasswordUpdate.php | 15 + src/Contracts/ResetPasswordContract.php | 10 - src/Contracts/ResetsUserPasswords.php | 16 + src/Contracts/TwoFactorAuthenticatable.php | 22 + src/Contracts/UpdatePasswordContract.php | 15 - src/Contracts/UpdatesUserPasswords.php | 16 + .../UpdatesUserProfileInformation.php | 16 + src/Contracts/VerificationContract.php | 12 - src/Enums/TwoFactorModes.php | 27 + src/Enums/UserStatuses.php | 56 +- src/Events/AccountDeleted.php | 17 + src/Events/AccountLocked.php | 17 + src/Events/EmailUpdated.php | 28 - src/Events/PasswordChanged.php | 38 - src/Events/PasswordUpdated.php | 17 + src/Events/ProfileUpdated.php | 17 + src/Events/RecoveryCodeReplaced.php | 18 + src/Events/RecoveryCodesGenerated.php | 17 + .../TwoFactorAuthenticationChallenged.php | 17 + .../TwoFactorAuthenticationConfirmed.php | 17 + .../TwoFactorAuthenticationDisabled.php | 17 + src/Events/TwoFactorAuthenticationEnabled.php | 17 + src/Events/TwoFactorAuthenticationFailed.php | 17 + src/Factories/UserFactory.php | 77 -- .../Controllers/AccountStatusController.php | 30 + .../Auth/ConfirmPasswordController.php | 80 --- .../Auth/ForgotPasswordController.php | 43 -- src/Http/Controllers/Auth/LoginController.php | 69 -- .../Controllers/Auth/RegisterController.php | 83 --- .../Auth/ResetPasswordController.php | 89 --- .../Auth/UpdatePasswordController.php | 69 -- .../Auth/VerificationController.php | 91 --- src/Http/Controllers/AuthBaseController.php | 15 - .../AuthenticatedSessionController.php | 65 ++ .../Controllers/BrowserSessionsController.php | 30 + .../ConfirmablePasswordController.php | 54 ++ src/Http/Controllers/Controller.php | 47 ++ ...mailVerificationNotificationController.php | 32 + .../EmailVerificationPromptController.php | 25 + .../Controllers/NewPasswordController.php | 70 ++ .../PasskeyConfirmationController.php | 41 ++ .../Controllers/PasskeyLoginController.php | 52 ++ src/Http/Controllers/PasskeysController.php | 63 ++ src/Http/Controllers/PasswordController.php | 24 + .../PasswordResetLinkController.php | 48 ++ src/Http/Controllers/ProfileController.php | 72 ++ .../Controllers/RegisteredUserController.php | 33 + .../RequiredPasswordController.php | 39 ++ src/Http/Controllers/SecurityController.php | 98 +++ ...woFactorAuthenticatedSessionController.php | 134 ++++ .../TwoFactorAuthenticationController.php | 111 +++ .../Controllers/VerifyEmailController.php | 37 + src/Http/Middleware/Authenticate.php | 64 ++ src/Http/Middleware/EnsureAccountIsActive.php | 32 + src/Http/Middleware/EnsureEmailIsVerified.php | 37 + .../Middleware/EnsurePasswordIsUpdated.php | 31 + .../Middleware/EnsureTwoFactorIsEnabled.php | 56 ++ .../Middleware/RedirectIfAuthenticated.php | 27 + src/Http/Middleware/RequirePassword.php | 50 ++ src/Http/Middleware/SetUserType.php | 24 + src/Http/Middlewares/RedirectIfActivated.php | 39 -- .../RedirectIfActivatedAndEmailVerified.php | 39 -- .../RedirectIfEmailNotVerified.php | 33 - .../RedirectIfEmailVerificationNotNeeded.php | 39 -- .../Middlewares/RedirectIfNotActivated.php | 42 -- src/Http/Requests/LoginRequest.php | 26 + src/Http/Requests/UsersRequest.php | 100 --- src/Http/Responses/LoginResponse.php | 26 + src/Http/Responses/LogoutResponse.php | 22 + src/Http/Responses/RegisterResponse.php | 22 + .../Responses/TwoFactorChallengeResponse.php | 25 + src/Listeners/RecordFailedLogin.php | 26 - src/Listeners/RecordLockout.php | 33 - src/Listeners/RecordLogin.php | 45 -- src/Listeners/RecordLogout.php | 35 - src/Listeners/RecordPasswordChanged.php | 41 -- src/Listeners/RecordPasswordReset.php | 35 - src/Listeners/RecordPasswordResetLinkSent.php | 35 - src/Mail/EmailUpdated.php | 50 -- src/Mail/NewEmailVerification.php | 72 -- src/Models/User.php | 66 ++ src/Notifications/EmailUpdateRequest.php | 45 -- src/Notifications/NewPasswordNotification.php | 69 -- src/Notifications/ResetPassword.php | 38 +- src/Notifications/VerifyEmail.php | 31 +- src/Passkeys/Passkey.php | 45 ++ src/Passkeys/VerifyPasskey.php | 28 + src/PasswordResetsSchema.php | 18 - .../RedirectIfPasswordUpdateNotRequired.php | 38 - .../RedirectIfPasswordUpdateRequired.php | 38 - src/PasswordUpdate/PasswordUpdatable.php | 61 -- .../PasswordUpdatableContract.php | 38 - src/PasswordUpdate/UpdatesPassword.php | 112 --- src/Providers/EventServiceProvider.php | 62 -- src/Routing/AuthRoutes.php | 244 +++++++ src/Rules/UniqueGmail.php | 21 +- src/Schema/UserSchema.php | 77 ++ .../MultiAuthDatabaseSessionHandler.php | 36 - src/Session/Session.php | 57 -- src/Session/SessionSchema.php | 21 - src/{ => Sessions}/Agent.php | 7 +- .../MultiAuthDatabaseSessionHandler.php | 46 ++ src/Sessions/Session.php | 75 ++ src/Support/AccountState.php | 30 + src/Support/ActivityLogger.php | 78 +++ src/Support/PageData.php | 89 +++ src/Support/Strings.php | 11 + src/Traits/DeterminesRedirectPaths.php | 23 - src/Traits/InteractsWithGuards.php | 21 - src/Traits/InteractsWithUserTypes.php | 11 - src/TwoFactor/RecoveryCode.php | 13 + src/TwoFactor/TwoFactorProvider.php | 57 ++ src/User.php | 656 ------------------ src/UserContract.php | 56 -- src/UserSchema.php | 43 -- src/UserType.php | 435 ++++++++++++ src/UserTypeRegistry.php | 153 ++++ src/UserTypes.php | 30 + src/VerifiesEmails.php | 158 ----- src/Views/ViewRenderer.php | 70 ++ tests/Factories/AdminFactory.php | 50 ++ tests/Factories/UserFactory.php | 50 ++ .../Feature/ConfirmPasswordControllerTest.php | 88 --- .../Feature/ForgotPasswordControllerTest.php | 109 --- .../Controllers/ConfirmPasswordController.php | 23 - .../Controllers/ForgotPasswordController.php | 37 - .../Http/Controllers/HomeController.php | 14 - .../Http/Controllers/LoginController.php | 20 - .../Http/Controllers/RegisterController.php | 23 - .../Controllers/ResetPasswordController.php | 27 - .../Controllers/UpdatePasswordController.php | 27 - .../Http/Controllers/UserController.php | 63 -- .../Controllers/VerificationController.php | 25 - tests/Feature/Http/Requests/UsersRequest.php | 30 - tests/Feature/LoginControllerTest.php | 351 ---------- tests/Feature/LoginTest.php | 200 ++++++ tests/Feature/Models/User.php | 77 -- .../MultiAuthDatabaseSessionHandlerTest.php | 212 ------ tests/Feature/RegisterControllerTest.php | 99 --- tests/Feature/ResetPasswordControllerTest.php | 160 ----- tests/Feature/Rules/UniqueGmailRuleTest.php | 149 ---- .../Feature/UpdatePasswordControllerTest.php | 212 ------ tests/Feature/UserControllerTest.php | 66 -- tests/Feature/UserFactory.php | 8 - tests/Feature/VerificationControllerTest.php | 192 ----- tests/Feature/views/login.blade.php | 1 - .../Feature/views/passwords/confirm.blade.php | 1 - tests/Feature/views/passwords/email.blade.php | 1 - tests/Feature/views/passwords/reset.blade.php | 1 - .../Feature/views/passwords/update.blade.php | 1 - tests/Feature/views/register.blade.php | 1 - .../views/verification/result.blade.php | 17 - .../views/verification/verify.blade.php | 4 - tests/InteractsWithDatabase.php | 18 - tests/Models/Admin.php | 21 + tests/Models/User.php | 21 + tests/TestCase.php | 285 ++------ tests/Unit/DetectsGmailAliasesTest.php | 292 -------- tests/Unit/UserTest.php | 43 -- .../2024_01_01_000000_create_test_tables.php | 43 ++ ...01_01_000001_create_activity_log_table.php | 31 + .../database/create_password_resets_table.php | 26 - tests/database/create_sessions_table.php | 30 - tests/database/create_users_table.php | 30 - tests/views/app.blade.php | 1 + tests/views/layouts/app.blade.php | 7 + 241 files changed, 6540 insertions(+), 6189 deletions(-) delete mode 100644 .github/coverage.svg delete mode 100644 .github/workflows/run-tests.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .php_cs.dist.php delete mode 100644 .scrutinizer.yml delete mode 100644 .styleci.yml delete mode 100644 LICENSE delete mode 100644 config/config.php create mode 100644 config/javaabu-auth.php create mode 100644 database/migrations/2026_11_04_000000_create_passkeys_table.php delete mode 100644 lang/dv/validation.php delete mode 100644 lang/en/validation.php create mode 100644 pint.json create mode 100644 resources/js/auth.ts create mode 100644 resources/js/lib/webauthn.ts create mode 100644 resources/js/passkeys.ts create mode 100644 resources/views/account-inactive.blade.php create mode 100644 resources/views/confirm-password.blade.php create mode 100644 resources/views/forgot-password.blade.php create mode 100644 resources/views/layouts/account.blade.php create mode 100644 resources/views/layouts/auth.blade.php create mode 100644 resources/views/login.blade.php create mode 100644 resources/views/partials/account-nav.blade.php create mode 100644 resources/views/partials/passkey-login.blade.php create mode 100644 resources/views/partials/status.blade.php create mode 100644 resources/views/register.blade.php create mode 100644 resources/views/reset-password.blade.php create mode 100644 resources/views/settings/partials/api-tokens.blade.php create mode 100644 resources/views/settings/partials/passkeys.blade.php create mode 100644 resources/views/settings/partials/sessions.blade.php create mode 100644 resources/views/settings/partials/two-factor.blade.php create mode 100644 resources/views/settings/profile.blade.php create mode 100644 resources/views/settings/security.blade.php create mode 100644 resources/views/two-factor-challenge.blade.php create mode 100644 resources/views/update-required-password.blade.php create mode 100644 resources/views/verify-email.blade.php create mode 100644 src/Actions/AttemptLogin.php create mode 100644 src/Actions/CreateNewUser.php create mode 100644 src/Actions/DeleteUser.php create mode 100644 src/Actions/LoginUser.php create mode 100644 src/Actions/PasswordValidationRules.php create mode 100644 src/Actions/ResetUserPassword.php create mode 100644 src/Actions/StartAuthenticatedSession.php create mode 100644 src/Actions/TwoFactor/ConfirmTwoFactorAuthentication.php create mode 100644 src/Actions/TwoFactor/DisableTwoFactorAuthentication.php create mode 100644 src/Actions/TwoFactor/EnableTwoFactorAuthentication.php create mode 100644 src/Actions/TwoFactor/GenerateNewRecoveryCodes.php create mode 100644 src/Actions/UpdateUserPassword.php create mode 100644 src/Actions/UpdateUserProfileInformation.php rename src/{Traits => Concerns}/DetectsGmailAliases.php (65%) create mode 100644 src/Concerns/HasAccountStatus.php create mode 100644 src/Concerns/HasPasskeys.php create mode 100644 src/Concerns/HasSessions.php create mode 100644 src/Concerns/HasUserType.php create mode 100644 src/Concerns/LocksAfterFailedLogins.php create mode 100644 src/Concerns/RequiresPasswordUpdate.php create mode 100644 src/Concerns/TwoFactorAuthenticatable.php delete mode 100644 src/Contracts/ConfirmPasswordContract.php create mode 100644 src/Contracts/CreatesNewUsers.php create mode 100644 src/Contracts/DeletesUsers.php delete mode 100644 src/Contracts/ForgotPasswordContract.php create mode 100644 src/Contracts/HasAccountStatus.php delete mode 100644 src/Contracts/HasGuardContract.php delete mode 100644 src/Contracts/HasUserTypeContract.php delete mode 100644 src/Contracts/HasUserTypeRedirectContract.php create mode 100644 src/Contracts/LocksAfterFailedLogins.php delete mode 100644 src/Contracts/LoginContract.php create mode 100644 src/Contracts/LoginMethod.php delete mode 100644 src/Contracts/RegisterContract.php create mode 100644 src/Contracts/RequiresPasswordUpdate.php delete mode 100644 src/Contracts/ResetPasswordContract.php create mode 100644 src/Contracts/ResetsUserPasswords.php create mode 100644 src/Contracts/TwoFactorAuthenticatable.php delete mode 100644 src/Contracts/UpdatePasswordContract.php create mode 100644 src/Contracts/UpdatesUserPasswords.php create mode 100644 src/Contracts/UpdatesUserProfileInformation.php delete mode 100644 src/Contracts/VerificationContract.php create mode 100644 src/Enums/TwoFactorModes.php create mode 100644 src/Events/AccountDeleted.php create mode 100644 src/Events/AccountLocked.php delete mode 100644 src/Events/EmailUpdated.php delete mode 100644 src/Events/PasswordChanged.php create mode 100644 src/Events/PasswordUpdated.php create mode 100644 src/Events/ProfileUpdated.php create mode 100644 src/Events/RecoveryCodeReplaced.php create mode 100644 src/Events/RecoveryCodesGenerated.php create mode 100644 src/Events/TwoFactorAuthenticationChallenged.php create mode 100644 src/Events/TwoFactorAuthenticationConfirmed.php create mode 100644 src/Events/TwoFactorAuthenticationDisabled.php create mode 100644 src/Events/TwoFactorAuthenticationEnabled.php create mode 100644 src/Events/TwoFactorAuthenticationFailed.php delete mode 100644 src/Factories/UserFactory.php create mode 100644 src/Http/Controllers/AccountStatusController.php delete mode 100644 src/Http/Controllers/Auth/ConfirmPasswordController.php delete mode 100644 src/Http/Controllers/Auth/ForgotPasswordController.php delete mode 100644 src/Http/Controllers/Auth/LoginController.php delete mode 100644 src/Http/Controllers/Auth/RegisterController.php delete mode 100644 src/Http/Controllers/Auth/ResetPasswordController.php delete mode 100644 src/Http/Controllers/Auth/UpdatePasswordController.php delete mode 100644 src/Http/Controllers/Auth/VerificationController.php delete mode 100644 src/Http/Controllers/AuthBaseController.php create mode 100644 src/Http/Controllers/AuthenticatedSessionController.php create mode 100644 src/Http/Controllers/BrowserSessionsController.php create mode 100644 src/Http/Controllers/ConfirmablePasswordController.php create mode 100644 src/Http/Controllers/Controller.php create mode 100644 src/Http/Controllers/EmailVerificationNotificationController.php create mode 100644 src/Http/Controllers/EmailVerificationPromptController.php create mode 100644 src/Http/Controllers/NewPasswordController.php create mode 100644 src/Http/Controllers/PasskeyConfirmationController.php create mode 100644 src/Http/Controllers/PasskeyLoginController.php create mode 100644 src/Http/Controllers/PasskeysController.php create mode 100644 src/Http/Controllers/PasswordController.php create mode 100644 src/Http/Controllers/PasswordResetLinkController.php create mode 100644 src/Http/Controllers/ProfileController.php create mode 100644 src/Http/Controllers/RegisteredUserController.php create mode 100644 src/Http/Controllers/RequiredPasswordController.php create mode 100644 src/Http/Controllers/SecurityController.php create mode 100644 src/Http/Controllers/TwoFactorAuthenticatedSessionController.php create mode 100644 src/Http/Controllers/TwoFactorAuthenticationController.php create mode 100644 src/Http/Controllers/VerifyEmailController.php create mode 100644 src/Http/Middleware/Authenticate.php create mode 100644 src/Http/Middleware/EnsureAccountIsActive.php create mode 100644 src/Http/Middleware/EnsureEmailIsVerified.php create mode 100644 src/Http/Middleware/EnsurePasswordIsUpdated.php create mode 100644 src/Http/Middleware/EnsureTwoFactorIsEnabled.php create mode 100644 src/Http/Middleware/RedirectIfAuthenticated.php create mode 100644 src/Http/Middleware/RequirePassword.php create mode 100644 src/Http/Middleware/SetUserType.php delete mode 100644 src/Http/Middlewares/RedirectIfActivated.php delete mode 100644 src/Http/Middlewares/RedirectIfActivatedAndEmailVerified.php delete mode 100644 src/Http/Middlewares/RedirectIfEmailNotVerified.php delete mode 100644 src/Http/Middlewares/RedirectIfEmailVerificationNotNeeded.php delete mode 100644 src/Http/Middlewares/RedirectIfNotActivated.php create mode 100644 src/Http/Requests/LoginRequest.php delete mode 100644 src/Http/Requests/UsersRequest.php create mode 100644 src/Http/Responses/LoginResponse.php create mode 100644 src/Http/Responses/LogoutResponse.php create mode 100644 src/Http/Responses/RegisterResponse.php create mode 100644 src/Http/Responses/TwoFactorChallengeResponse.php delete mode 100644 src/Listeners/RecordFailedLogin.php delete mode 100644 src/Listeners/RecordLockout.php delete mode 100644 src/Listeners/RecordLogin.php delete mode 100644 src/Listeners/RecordLogout.php delete mode 100644 src/Listeners/RecordPasswordChanged.php delete mode 100644 src/Listeners/RecordPasswordReset.php delete mode 100644 src/Listeners/RecordPasswordResetLinkSent.php delete mode 100644 src/Mail/EmailUpdated.php delete mode 100644 src/Mail/NewEmailVerification.php create mode 100644 src/Models/User.php delete mode 100644 src/Notifications/EmailUpdateRequest.php delete mode 100644 src/Notifications/NewPasswordNotification.php create mode 100644 src/Passkeys/Passkey.php create mode 100644 src/Passkeys/VerifyPasskey.php delete mode 100644 src/PasswordResetsSchema.php delete mode 100644 src/PasswordUpdate/Middleware/RedirectIfPasswordUpdateNotRequired.php delete mode 100644 src/PasswordUpdate/Middleware/RedirectIfPasswordUpdateRequired.php delete mode 100644 src/PasswordUpdate/PasswordUpdatable.php delete mode 100644 src/PasswordUpdate/PasswordUpdatableContract.php delete mode 100644 src/PasswordUpdate/UpdatesPassword.php delete mode 100644 src/Providers/EventServiceProvider.php create mode 100644 src/Routing/AuthRoutes.php create mode 100644 src/Schema/UserSchema.php delete mode 100644 src/Session/MultiAuthDatabaseSessionHandler.php delete mode 100644 src/Session/Session.php delete mode 100644 src/Session/SessionSchema.php rename src/{ => Sessions}/Agent.php (96%) create mode 100644 src/Sessions/MultiAuthDatabaseSessionHandler.php create mode 100644 src/Sessions/Session.php create mode 100644 src/Support/AccountState.php create mode 100644 src/Support/ActivityLogger.php create mode 100644 src/Support/PageData.php create mode 100644 src/Support/Strings.php delete mode 100644 src/Traits/DeterminesRedirectPaths.php delete mode 100644 src/Traits/InteractsWithGuards.php delete mode 100644 src/Traits/InteractsWithUserTypes.php create mode 100644 src/TwoFactor/RecoveryCode.php create mode 100644 src/TwoFactor/TwoFactorProvider.php delete mode 100644 src/User.php delete mode 100644 src/UserContract.php delete mode 100644 src/UserSchema.php create mode 100644 src/UserType.php create mode 100644 src/UserTypeRegistry.php create mode 100644 src/UserTypes.php delete mode 100644 src/VerifiesEmails.php create mode 100644 src/Views/ViewRenderer.php create mode 100644 tests/Factories/AdminFactory.php create mode 100644 tests/Factories/UserFactory.php delete mode 100644 tests/Feature/ConfirmPasswordControllerTest.php delete mode 100644 tests/Feature/ForgotPasswordControllerTest.php delete mode 100644 tests/Feature/Http/Controllers/ConfirmPasswordController.php delete mode 100644 tests/Feature/Http/Controllers/ForgotPasswordController.php delete mode 100644 tests/Feature/Http/Controllers/HomeController.php delete mode 100644 tests/Feature/Http/Controllers/LoginController.php delete mode 100644 tests/Feature/Http/Controllers/RegisterController.php delete mode 100644 tests/Feature/Http/Controllers/ResetPasswordController.php delete mode 100644 tests/Feature/Http/Controllers/UpdatePasswordController.php delete mode 100644 tests/Feature/Http/Controllers/UserController.php delete mode 100644 tests/Feature/Http/Controllers/VerificationController.php delete mode 100644 tests/Feature/Http/Requests/UsersRequest.php delete mode 100644 tests/Feature/LoginControllerTest.php create mode 100644 tests/Feature/LoginTest.php delete mode 100644 tests/Feature/Models/User.php delete mode 100644 tests/Feature/MultiAuthDatabaseSessionHandlerTest.php delete mode 100644 tests/Feature/RegisterControllerTest.php delete mode 100644 tests/Feature/ResetPasswordControllerTest.php delete mode 100644 tests/Feature/Rules/UniqueGmailRuleTest.php delete mode 100644 tests/Feature/UpdatePasswordControllerTest.php delete mode 100644 tests/Feature/UserControllerTest.php delete mode 100644 tests/Feature/UserFactory.php delete mode 100644 tests/Feature/VerificationControllerTest.php delete mode 100644 tests/Feature/views/login.blade.php delete mode 100644 tests/Feature/views/passwords/confirm.blade.php delete mode 100644 tests/Feature/views/passwords/email.blade.php delete mode 100644 tests/Feature/views/passwords/reset.blade.php delete mode 100644 tests/Feature/views/passwords/update.blade.php delete mode 100644 tests/Feature/views/register.blade.php delete mode 100644 tests/Feature/views/verification/result.blade.php delete mode 100644 tests/Feature/views/verification/verify.blade.php delete mode 100644 tests/InteractsWithDatabase.php create mode 100644 tests/Models/Admin.php create mode 100644 tests/Models/User.php delete mode 100644 tests/Unit/DetectsGmailAliasesTest.php delete mode 100644 tests/Unit/UserTest.php create mode 100644 tests/database/2024_01_01_000000_create_test_tables.php create mode 100644 tests/database/2024_01_01_000001_create_activity_log_table.php delete mode 100644 tests/database/create_password_resets_table.php delete mode 100644 tests/database/create_sessions_table.php delete mode 100644 tests/database/create_users_table.php create mode 100644 tests/views/app.blade.php create mode 100644 tests/views/layouts/app.blade.php diff --git a/.gitattributes b/.gitattributes index bb6265e..4ba65c5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,8 @@ /.scrutinizer.yml export-ignore /tests export-ignore /.editorconfig export-ignore +/.github export-ignore +/docs export-ignore +/pint.json export-ignore +/AGENTS.md export-ignore +/CLAUDE.md export-ignore diff --git a/.github/coverage.svg b/.github/coverage.svg deleted file mode 100644 index e4c1404..0000000 --- a/.github/coverage.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - coverage - - 61 % - - \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 70ecf0b..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: tests - -on: - pull_request: - branches: - - main - push: - branches: - - main - -jobs: - tests: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: true - matrix: - os: [ ubuntu-latest ] - php: [ 8.4, 8.3, 8.2 ] - laravel: [ 13.*, 12.*, ^11.7 ] - stability: [ prefer-lowest, prefer-stable ] - include: - - laravel: 13.* - testbench: 11.* - - laravel: 12.* - testbench: 10.* - - laravel: ^11.7 - testbench: ^9.16 - exclude: - - laravel: 13.* - php: 8.2 - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install SQLite 3 - run: | - sudo apt-get update - sudo apt-get install sqlite3 -y - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv - coverage: ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && 'xdebug' || 'none' }} - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: Execute tests - run: | - vendor/bin/phpunit ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && '--coverage-clover=clover.xml' || '' }} - - - name: Make code coverage badge - if: startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' - uses: timkrase/phpunit-coverage-badge@v1.2.1 - with: - coverage_badge_path: .github/coverage.svg - push_badge: true - repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c94701e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,87 @@ +name: tests + +on: + pull_request: + push: + branches: + - main + - next-gen + +# The unreleased javaabu/* dependencies come from VCS repositories; the job's own +# token keeps Composer within GitHub's API limits +env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"}}' + +jobs: + pint: + runs-on: ubuntu-latest + + name: Pint + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Check code style + run: vendor/bin/pint --test + + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [ 8.4, 8.5 ] + laravel: [ 12.*, 13.* ] + stability: [ prefer-lowest, prefer-stable ] + include: + - laravel: 12.* + testbench: 10.* + - laravel: 13.* + testbench: 11.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} + + env: + COVERAGE: ${{ matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv + coverage: ${{ env.COVERAGE == 'true' && 'pcov' || 'none' }} + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Execute tests + if: env.COVERAGE != 'true' + run: vendor/bin/phpunit + + - name: Execute tests with coverage + if: env.COVERAGE == 'true' + run: vendor/bin/phpunit --coverage-clover build/coverage.xml + + - name: Upload coverage to Codecov + if: env.COVERAGE == 'true' + uses: codecov/codecov-action@v5 + with: + files: build/coverage.xml + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 8683765..d37c91e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,6 @@ composer.lock vendor coverage .phpunit.result.cache -.php-cs-fixer.cache .idea -clover.xml +node_modules +.phpunit.cache diff --git a/.php_cs.dist.php b/.php_cs.dist.php deleted file mode 100644 index 8648fdc..0000000 --- a/.php_cs.dist.php +++ /dev/null @@ -1,35 +0,0 @@ -in([ - __DIR__ . '/src', - __DIR__ . '/tests', - ]) - ->name('*.php') - ->notName('*.blade.php') - ->ignoreDotFiles(true) - ->ignoreVCS(true); - -return (new PhpCsFixer\Config()) - ->setRules([ - '@PSR12' => true, - 'array_syntax' => ['syntax' => 'short'], - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'trailing_comma_in_multiline' => true, - 'phpdoc_scalar' => true, - 'unary_operator_spaces' => true, - 'binary_operator_spaces' => true, - 'blank_line_before_statement' => [ - 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], - ], - 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_var_without_name' => true, - 'method_argument_space' => [ - 'on_multiline' => 'ensure_fully_multiline', - 'keep_multiple_spaces_after_comma' => true, - ], - 'single_trait_insert_per_statement' => true, - ]) - ->setFinder($finder); diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index df16b68..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,19 +0,0 @@ -filter: - excluded_paths: [tests/*] - -checks: - php: - remove_extra_empty_lines: true - remove_php_closing_tag: true - remove_trailing_whitespace: true - fix_use_statements: - remove_unused: true - preserve_multiple: false - preserve_blanklines: true - order_alphabetically: true - fix_php_opening_tag: true - fix_linefeed: true - fix_line_ending: true - fix_identation_4spaces: true - fix_doc_comments: true - diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index f4d3cbc..0000000 --- a/.styleci.yml +++ /dev/null @@ -1,4 +0,0 @@ -preset: laravel - -disabled: - - single_class_element_per_statement diff --git a/LICENSE b/LICENSE deleted file mode 100644 index e3e4a82..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Javaabu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index 1500260..f1d77c9 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,23 @@ # Auth -[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/auth.svg?style=flat-square)](https://packagist.org/packages/javaabu/auth) -[![Test Status](../../actions/workflows/run-tests.yml/badge.svg)](../../actions/workflows/run-tests.yml) -![Code Coverage Badge](./.github/coverage.svg) -[![Total Downloads](https://img.shields.io/packagist/dt/javaabu/auth.svg?style=flat-square)](https://packagist.org/packages/javaabu/auth) +[![Tests](https://github.com/Javaabu/auth/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/Javaabu/auth/actions/workflows/tests.yml?query=branch%3Amain) +[![codecov](https://codecov.io/gh/Javaabu/auth/branch/main/graph/badge.svg)](https://codecov.io/gh/Javaabu/auth/tree/main) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/auth.svg)](https://packagist.org/packages/javaabu/auth) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) -Laravel Authentication Helpers +Multi-auth for Laravel. Any number of user types (admins, public users, …), each with its own guard, password broker, routes, pages and settings: login, registration, password reset, email verification, password confirmation, two-factor authentication, passkeys, profile and security pages, browser sessions and API tokens. Pages ship in Blade (javaabu/forms) and Inertia React (@javaabu/forms). ## Documentation -You'll find the documentation on [https://docs.javaabu.com/auth](https://docs.javaabu.com/auth). - -Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving this package? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible. - -If you've found a bug regarding security please mail [info@javaabu.com](mailto:info@javaabu.com) instead of using the issue tracker. +You'll find the documentation on [https://docs.javaabu.com/auth](https://docs.javaabu.com/auth), and in the [`docs`](docs) folder. +AI coding assistants can start from [`llms.txt`](llms.txt). The package ships a [Laravel Boost](https://github.com/laravel/boost) skill and guideline, which Boost finds by itself. ## Testing -You can run the tests with - -``` bash -./vendor/bin/phpunit +```bash +composer test +composer lint ``` ## Changelog diff --git a/composer.json b/composer.json index 2d01894..72eba68 100644 --- a/composer.json +++ b/composer.json @@ -1,28 +1,72 @@ { "name": "javaabu/auth", - "description": "Laravel Authentication Helpers", + "description": "Multi-auth for Laravel: login, registration, password reset, email verification, two-factor authentication, passkeys and API tokens for any number of user types, with Blade and Inertia React pages", "homepage": "https://github.com/Javaabu/auth", "license": "MIT", + "keywords": [ + "javaabu", + "laravel", + "auth", + "multi-auth", + "two-factor", + "passkeys", + "webauthn" + ], "authors": [ { "name": "Athfan Khaleel", "email": "athfan@javaabu.com" + }, + { + "name": "Arushad Ahmed", + "email": "arushad@javaabu.com" + } + ], + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/Javaabu/forms" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/manifest" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/permissions" } ], "require": { - "php": "^8.2", - "illuminate/support": "^11.7 || ^12.0 || ^13.0", - "javaabu/helpers": "^1.71", - "javaabu/activitylog": "^1.6", - "laravel/ui": "^4.6", - "javaabu/passport": "^2.0", - "spatie/laravel-medialibrary": "^11.21", + "php": "^8.4", + "bacon/bacon-qr-code": "^3.0", + "illuminate/auth": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/http": "^12.0 || ^13.0", + "illuminate/notifications": "^12.0 || ^13.0", + "illuminate/routing": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "javaabu/forms": "^2.0@dev", + "javaabu/manifest": "^1.0@dev", + "javaabu/permissions": "^3.0@dev", + "laravel/passkeys": "^0.2.1", "mobiledetect/mobiledetectlib": "^4.8", - "javaabu/schema": "^1.2" + "pragmarx/google2fa": "^9.0" }, "require-dev": { - "orchestra/testbench": "^9.16 || ^10.0 || ^11.0", - "phpunit/phpunit": "^10.5 || ^11.5.3 || ^12.5.8 || ^13.0.3" + "inertiajs/inertia-laravel": "^2.0 || ^3.0", + "javaabu/boost-javaabu-guidelines": "^1.0", + "laravel/passport": "^13.0", + "laravel/pint": "^1.24", + "laravel/sanctum": "^4.0", + "orchestra/testbench": "^10.0 || ^11.0", + "phpunit/phpunit": "^11.5.3 || ^12.5.8", + "spatie/laravel-activitylog": "^4.10" + }, + "suggest": { + "inertiajs/inertia-laravel": "Renders the auth pages with Inertia React when javaabu-auth.views is inertia (^2.0 || ^3.0)", + "laravel/sanctum": "API tokens and SPA authentication when javaabu-auth.api includes sanctum (^4.0)", + "laravel/passport": "OAuth2 API authentication when javaabu-auth.api includes passport (^13.0)", + "spatie/laravel-activitylog": "Logs logins, logouts, failed logins, lockouts and password changes (^4.10)" }, "autoload": { "psr-4": { @@ -34,9 +78,15 @@ "Javaabu\\Auth\\Tests\\": "tests/" } }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-clover build/coverage.xml", + "lint": "vendor/bin/pint --test", + "format": "vendor/bin/pint" + }, "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-next-gen": "3.x-dev" }, "laravel": { "providers": [ @@ -44,6 +94,9 @@ ] } }, + "config": { + "sort-packages": true + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/config/config.php b/config/config.php deleted file mode 100644 index 2e69630..0000000 --- a/config/config.php +++ /dev/null @@ -1,151 +0,0 @@ - [ - 'guard' => env('AUTH_GUARD', 'web_admin'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication drivers have a user provider. This defines how the - | users are actually retrieved out of your database or other storage - | mechanisms used by this application to persist your user's data. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web_admin' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - - 'api_admin' => [ - 'driver' => 'passport', - 'provider' => 'users', - ], - - 'api' => [ - 'driver' => 'passport', - 'provider' => 'users', -// 'hash' => false, - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication drivers have a user provider. This defines how the - | users are actually retrieved out of your database or other storage - | mechanisms used by this application to persist your user's data. - | - | If you have multiple user tables or models you may configure multiple - | sources which represent each model / table. These sources may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | You may specify multiple password reset configurations if you have more - | than one user table or model in the application and you want to have - | separate password reset settings based on the specific user types. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the amount of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - - /* - |-------------------------------------------------------------------------- - | Max login attempts - |-------------------------------------------------------------------------- - | - | The maximum login attempts allowed before a user gets locked out. - | This is a custom configuration. - | - */ - - 'max_login_attempts' => env('MAX_LOGIN_ATTEMPTS', 5), - - /* - |-------------------------------------------------------------------------- - | Passport API Guards - |-------------------------------------------------------------------------- - | - | Guards used by Passport for OAuthClientAuth. Put the guards in priority - | order of least authorized first, then most authorized - | - */ - - 'passport_guards' => [ - 'api_admin', - 'api', - ], -]; diff --git a/config/javaabu-auth.php b/config/javaabu-auth.php new file mode 100644 index 0000000..f02b258 --- /dev/null +++ b/config/javaabu-auth.php @@ -0,0 +1,209 @@ + …, 'account' => …] Blade layouts; default to javaabu-auth.layouts + | username the column users log in with + | features turn features on or off, see Javaabu\Auth\UserType::DEFAULT_FEATURES + | two_factor `off`, `optional` or `required` + | max_login_attempts lock the account after this many failed logins in a row + | login_methods classes implementing Javaabu\Auth\Contracts\LoginMethod + | + */ + + 'user_types' => [ + 'user' => [ + 'guard' => 'web', + 'passwords' => 'users', + 'route_name' => '', + 'home' => '/', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Views + |-------------------------------------------------------------------------- + | + | Which stack the pages render with: `blade` (javaabu/forms) or `inertia` + | (@javaabu/forms React pages shipped through javaabu/manifest). A user + | type can choose its own. Requests that expect JSON always get JSON. + | + */ + + 'views' => env('AUTH_VIEWS', 'blade'), + + /* + | The Blade layouts the pages extend, and the section they fill. `auth` is + | for the guest pages (login, register, …), `account` for the signed-in + | pages (profile, security). The package's own layouts are minimal pages + | that load the javaabu/forms assets; point these at your app's layouts. + */ + + 'layouts' => [ + 'auth' => 'auth::layouts.auth', + 'account' => 'auth::layouts.account', + ], + + 'layout_section' => 'content', + + /* + |-------------------------------------------------------------------------- + | Account lockout + |-------------------------------------------------------------------------- + | + | Lock accounts after this many failed logins in a row, until the user + | resets their password. Needs the LocksAfterFailedLogins trait on the + | model. Null never locks. A user type can set its own. + | + */ + + 'max_login_attempts' => env('AUTH_MAX_LOGIN_ATTEMPTS', 5), + + /* + |-------------------------------------------------------------------------- + | Rate limits + |-------------------------------------------------------------------------- + | + | Attempts per minute. Each is a named rate limiter (`auth.login`, …) you + | can redefine with RateLimiter::for() in your app. + | + */ + + 'rate_limits' => [ + 'login' => 5, + 'two_factor' => 5, + 'passkeys' => 10, + 'password_reset' => 6, + 'verification' => 6, + 'api_tokens' => 6, + ], + + /* + |-------------------------------------------------------------------------- + | Password confirmation + |-------------------------------------------------------------------------- + | + | Seconds before users are asked for their password again on pages behind + | the `auth.password.confirm` middleware. Each user type keeps its own + | confirmation. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + + /* + |-------------------------------------------------------------------------- + | Two-factor authentication + |-------------------------------------------------------------------------- + | + | TOTP settings. `issuer` is shown in authenticator apps (default: app.name). + | `window` is how many 30 second periods either side of now are accepted. + | + */ + + 'two_factor' => [ + 'issuer' => null, + 'window' => 1, + 'secret_length' => 16, + 'recovery_codes' => 8, + ], + + /* + |-------------------------------------------------------------------------- + | API authentication + |-------------------------------------------------------------------------- + | + | `none`, `sanctum`, `passport` or `both`. Run `php artisan auth:install` + | to set it up. Turn on the `api_tokens` feature for the user types that + | may create personal access tokens. + | + */ + + 'api' => env('AUTH_API', 'none'), + + 'api_tokens' => [ + 'abilities' => ['*'], + 'expiration' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Permissions + |-------------------------------------------------------------------------- + | + | Register this package's permissions (for admins managing other users' + | accounts) through javaabu/permissions, for these user types' models. + | Leave empty for every user type. + | + */ + + 'register_permissions' => true, + + 'managed_user_types' => [], + + /* + |-------------------------------------------------------------------------- + | Activity log + |-------------------------------------------------------------------------- + | + | Log logins, logouts, failed logins, lockouts and password changes when + | spatie/laravel-activitylog is installed. + | + */ + + 'log_activity' => true, + + /* + |-------------------------------------------------------------------------- + | Controllers + |-------------------------------------------------------------------------- + | + | The controllers Route::authRoutes() uses. Extend the package's controller + | to change its behaviour. + | + */ + + 'controllers' => [ + 'login' => Controllers\AuthenticatedSessionController::class, + 'register' => Controllers\RegisteredUserController::class, + 'password_reset_link' => Controllers\PasswordResetLinkController::class, + 'new_password' => Controllers\NewPasswordController::class, + 'email_verification_prompt' => Controllers\EmailVerificationPromptController::class, + 'verify_email' => Controllers\VerifyEmailController::class, + 'email_verification_notification' => Controllers\EmailVerificationNotificationController::class, + 'confirm_password' => Controllers\ConfirmablePasswordController::class, + 'account_status' => Controllers\AccountStatusController::class, + 'required_password' => Controllers\RequiredPasswordController::class, + 'two_factor_challenge' => Controllers\TwoFactorAuthenticatedSessionController::class, + 'two_factor' => Controllers\TwoFactorAuthenticationController::class, + 'passkey_login' => Controllers\PasskeyLoginController::class, + 'passkey_confirmation' => Controllers\PasskeyConfirmationController::class, + 'passkeys' => Controllers\PasskeysController::class, + 'profile' => Controllers\ProfileController::class, + 'security' => Controllers\SecurityController::class, + 'password' => Controllers\PasswordController::class, + 'sessions' => Controllers\BrowserSessionsController::class, + 'api_tokens' => Controllers\ApiTokensController::class, + 'user_accounts' => Controllers\UserAccountsController::class, + ], + +]; diff --git a/database/migrations/2026_11_04_000000_create_passkeys_table.php b/database/migrations/2026_11_04_000000_create_passkeys_table.php new file mode 100644 index 0000000..b7ccea4 --- /dev/null +++ b/database/migrations/2026_11_04_000000_create_passkeys_table.php @@ -0,0 +1,30 @@ +id(); + $table->morphs('user'); + $table->string('name'); + $table->string('credential_id')->unique(); + $table->json('credential'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('passkeys'); + } +}; diff --git a/lang/dv/validation.php b/lang/dv/validation.php deleted file mode 100644 index 1225552..0000000 --- a/lang/dv/validation.php +++ /dev/null @@ -1,5 +0,0 @@ - 'މި :attribute ގެ ޖީމެއިލް އެޑްރެސްގެ އެކައުންޓެއް މިހާރުވެސް އެބައިން.', -]; diff --git a/lang/en/validation.php b/lang/en/validation.php deleted file mode 100644 index ce16e84..0000000 --- a/lang/en/validation.php +++ /dev/null @@ -1,5 +0,0 @@ - 'The same gmail :attribute is already taken.', -]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d5a979d..8716dda 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,7 +6,7 @@ processIsolation="false" stopOnFailure="false"> - + tests diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..44df8bf --- /dev/null +++ b/pint.json @@ -0,0 +1,20 @@ +{ + "preset": "laravel", + "rules": { + "concat_space": { + "spacing": "one" + }, + "global_namespace_import": { + "import_classes": true, + "import_constants": false, + "import_functions": false + }, + "no_unused_imports": true, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "php_unit_method_casing": { + "case": "snake_case" + } + } +} diff --git a/resources/js/auth.ts b/resources/js/auth.ts new file mode 100644 index 0000000..d930385 --- /dev/null +++ b/resources/js/auth.ts @@ -0,0 +1,21 @@ +/** + * For the Blade pages: forms marked data-confirm ask before they submit. + */ +function onSubmit(event: Event): void { + const form = event.target as HTMLFormElement | null; + const message = form?.dataset?.confirm; + + if (message && !window.confirm(message)) { + event.preventDefault(); + event.stopImmediatePropagation(); + } +} + +export function init(root: Document = document): void { + root.addEventListener("submit", onSubmit, true); +} + +if (typeof document !== "undefined" && !(window as { __javaabuAuth?: boolean }).__javaabuAuth) { + (window as { __javaabuAuth?: boolean }).__javaabuAuth = true; + init(); +} diff --git a/resources/js/lib/webauthn.ts b/resources/js/lib/webauthn.ts new file mode 100644 index 0000000..ea7f693 --- /dev/null +++ b/resources/js/lib/webauthn.ts @@ -0,0 +1,185 @@ +/** + * The browser side of passkeys: fetches options from the server, asks the browser for a + * credential and posts it back. Works with the JSON endpoints of laravel/passkeys. + */ + +export interface PasskeyRequestOptions { + /** GET: returns `{ options }` */ + optionsUrl: string; + /** POST: receives `{ credential, … }` */ + url: string; + /** Extra fields for the POST, e.g. `{ name }` or `{ remember: true }` */ + data?: Record; +} + +export interface PasskeyResult { + ok: boolean; + status: number; + body: T; +} + +type Json = Record; + +export function passkeysSupported(): boolean { + return typeof window !== "undefined" && typeof window.PublicKeyCredential === "function" && !!navigator.credentials; +} + +export function base64UrlToBuffer(value: string): ArrayBuffer { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes.buffer; +} + +export function bufferToBase64Url(buffer: ArrayBuffer | ArrayBufferView): string { + const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + let binary = ""; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function csrfHeaders(): Record { + const headers: Record = {}; + const meta = typeof document !== "undefined" ? document.querySelector('meta[name="csrf-token"]') : null; + + if (meta?.content) { + headers["X-CSRF-TOKEN"] = meta.content; + + return headers; + } + + const cookie = typeof document !== "undefined" ? document.cookie.split("; ").find((row) => row.startsWith("XSRF-TOKEN=")) : undefined; + + if (cookie) { + headers["X-XSRF-TOKEN"] = decodeURIComponent(cookie.split("=")[1] ?? ""); + } + + return headers; +} + +async function request(url: string, init: RequestInit = {}): Promise> { + const response = await fetch(url, { + credentials: "same-origin", + ...init, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + ...csrfHeaders(), + ...(init.headers ?? {}), + }, + }); + + const text = await response.text(); + let body: unknown = null; + + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + + return { ok: response.ok, status: response.status, body: body as T }; +} + +function creationOptions(options: Json): CredentialCreationOptions { + const publicKey = { ...options } as Json; + + publicKey.challenge = base64UrlToBuffer(String(options.challenge)); + publicKey.user = { ...(options.user as Json), id: base64UrlToBuffer(String((options.user as Json).id)) }; + publicKey.excludeCredentials = ((options.excludeCredentials as Json[] | undefined) ?? []).map((credential) => ({ + ...credential, + id: base64UrlToBuffer(String(credential.id)), + })); + + return { publicKey: publicKey as unknown as PublicKeyCredentialCreationOptions }; +} + +function requestOptions(options: Json): CredentialRequestOptions { + const publicKey = { ...options } as Json; + + publicKey.challenge = base64UrlToBuffer(String(options.challenge)); + publicKey.allowCredentials = ((options.allowCredentials as Json[] | undefined) ?? []).map((credential) => ({ + ...credential, + id: base64UrlToBuffer(String(credential.id)), + })); + + return { publicKey: publicKey as unknown as PublicKeyCredentialRequestOptions }; +} + +/** The JSON form of a credential, as the server expects it */ +export function credentialToJson(credential: PublicKeyCredential): Json { + const response = credential.response as AuthenticatorAttestationResponse & AuthenticatorAssertionResponse; + const json: Json = { + clientDataJSON: bufferToBase64Url(response.clientDataJSON), + }; + + if ("attestationObject" in response && response.attestationObject) { + json.attestationObject = bufferToBase64Url(response.attestationObject); + json.transports = typeof response.getTransports === "function" ? response.getTransports() : []; + } + + if ("authenticatorData" in response && response.authenticatorData) { + json.authenticatorData = bufferToBase64Url(response.authenticatorData); + json.signature = bufferToBase64Url(response.signature); + json.userHandle = response.userHandle ? bufferToBase64Url(response.userHandle) : null; + } + + return { + id: credential.id, + rawId: bufferToBase64Url(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment ?? null, + clientExtensionResults: credential.getClientExtensionResults(), + response: json, + }; +} + +async function ceremony(kind: "create" | "get", { optionsUrl, url, data = {} }: PasskeyRequestOptions): Promise> { + const options = await request<{ options: Json }>(optionsUrl); + + if (!options.ok || !options.body?.options) { + return options as unknown as PasskeyResult; + } + + const credential = + kind === "create" + ? await navigator.credentials.create(creationOptions(options.body.options)) + : await navigator.credentials.get(requestOptions(options.body.options)); + + if (!credential) { + throw new Error("No passkey was chosen."); + } + + return request(url, { + method: "POST", + body: JSON.stringify({ ...data, credential: credentialToJson(credential as PublicKeyCredential) }), + }); +} + +/** Add a passkey to the signed-in user's account */ +export function registerPasskey(options: PasskeyRequestOptions): Promise> { + return ceremony("create", options); +} + +/** Log in, or confirm the password, with a passkey */ +export function verifyPasskey(options: PasskeyRequestOptions): Promise> { + return ceremony("get", options); +} + +/** The first validation message in a Laravel error response */ +export function errorMessage(result: PasskeyResult, fallback: string): string { + const body = result.body as { message?: string; errors?: Record } | null; + const first = body?.errors ? Object.values(body.errors)[0]?.[0] : undefined; + + return first ?? body?.message ?? fallback; +} diff --git a/resources/js/passkeys.ts b/resources/js/passkeys.ts new file mode 100644 index 0000000..3f23a7f --- /dev/null +++ b/resources/js/passkeys.ts @@ -0,0 +1,94 @@ +/** + * For the Blade pages: wires up [data-passkey-login], [data-passkey-confirm] and [data-passkeys] + * (adding a passkey on the security page) to the passkey endpoints in their data attributes. + */ +import { errorMessage, passkeysSupported, registerPasskey, verifyPasskey } from "./lib/webauthn"; + +function showError(root: HTMLElement, message: string): void { + const element = root.querySelector("[data-passkey-error]"); + + if (!element) { + return; + } + + element.textContent = message; + element.hidden = false; +} + +function redirectFrom(body: unknown, fallback: string): string { + const redirect = (body as { redirect?: string } | null)?.redirect; + + return redirect ?? fallback; +} + +async function run(root: HTMLElement, action: () => Promise<{ ok: boolean; body: unknown; status: number }>, onSuccess: (body: unknown) => void): Promise { + const failed = root.dataset.failed ?? "Something went wrong."; + + if (!passkeysSupported()) { + showError(root, root.dataset.unsupported ?? failed); + + return; + } + + try { + const result = await action(); + + if (!result.ok) { + showError(root, errorMessage(result, failed)); + + return; + } + + onSuccess(result.body); + } catch { + showError(root, failed); + } +} + +function onClick(event: Event): void { + const target = event.target as Element | null; + const login = target?.closest?.("[data-passkey-login-button]")?.closest("[data-passkey-login]"); + const confirm = target?.closest?.("[data-passkey-confirm-button]")?.closest("[data-passkey-confirm]"); + const add = target?.closest?.("[data-passkey-add-button]")?.closest("[data-passkeys]"); + + if (login) { + event.preventDefault(); + const remember = document.querySelector('[data-login-form] input[name="remember"]')?.checked ?? false; + + void run( + login, + () => verifyPasskey({ optionsUrl: login.dataset.optionsUrl!, url: login.dataset.url!, data: { remember } }), + (body) => window.location.assign(redirectFrom(body, window.location.href)), + ); + } + + if (confirm) { + event.preventDefault(); + + void run( + confirm, + () => verifyPasskey({ optionsUrl: confirm.dataset.optionsUrl!, url: confirm.dataset.url! }), + (body) => window.location.assign(redirectFrom(body, window.location.href)), + ); + } + + if (add) { + event.preventDefault(); + const name = add.querySelector("[data-passkey-name]")?.value.trim() || navigator.platform || "Passkey"; + + void run( + add, + () => registerPasskey({ optionsUrl: add.dataset.optionsUrl!, url: add.dataset.url!, data: { name } }), + () => window.location.reload(), + ); + } +} + +export function init(root: Document = document): void { + root.addEventListener("click", onClick); +} + +if (typeof document !== "undefined" && !(window as { __javaabuAuthPasskeys?: boolean }).__javaabuAuthPasskeys) { + (window as { __javaabuAuthPasskeys?: boolean }).__javaabuAuthPasskeys = true; + init(); +} diff --git a/resources/views/account-inactive.blade.php b/resources/views/account-inactive.blade.php new file mode 100644 index 0000000..5f03a12 --- /dev/null +++ b/resources/views/account-inactive.blade.php @@ -0,0 +1,19 @@ +@extends($layout) + +@section($layout_section) + +
+ {{ $message }} + +
+ @isset($urls['password.request']) + {{ __('Reset your password') }} + @endisset + + + {{ __('Log out') }} + +
+
+
+@endsection diff --git a/resources/views/confirm-password.blade.php b/resources/views/confirm-password.blade.php new file mode 100644 index 0000000..f9ceb00 --- /dev/null +++ b/resources/views/confirm-password.blade.php @@ -0,0 +1,30 @@ +@extends($layout) + +@section($layout_section) + +
+

{{ __('This is a secure area. Please confirm your password before continuing.') }}

+ + + + + {{ __('Confirm') }} + + + @if($can_use_passkeys) + @manifestRequire('auth::passkeys') + +
+ + {{ __('Confirm with a passkey') }} + + +
+ @endif +
+
+@endsection diff --git a/resources/views/forgot-password.blade.php b/resources/views/forgot-password.blade.php new file mode 100644 index 0000000..2a69017 --- /dev/null +++ b/resources/views/forgot-password.blade.php @@ -0,0 +1,21 @@ +@extends($layout) + +@section($layout_section) + +
+

{{ __('Enter your email address and we will email you a link to choose a new password.') }}

+ + @include('auth::partials.status') + + + + + {{ __('Email password reset link') }} + + +

+ {{ __('Back to log in') }} +

+
+
+@endsection diff --git a/resources/views/layouts/account.blade.php b/resources/views/layouts/account.blade.php new file mode 100644 index 0000000..dd68d1b --- /dev/null +++ b/resources/views/layouts/account.blade.php @@ -0,0 +1,26 @@ +{{-- The package's minimal layout for the profile and security pages. Point javaabu-auth.layouts.account at your own. --}} + + + + + + + {{ isset($title) ? $title . ' · ' : '' }}{{ config('app.name') }} + @manifestStyles + + +
+
+ {{ config('app.name') }} + + @include('auth::partials.account-nav') +
+ +
+ @yield('content') +
+
+ + @manifestScripts + + diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php new file mode 100644 index 0000000..e7ab0e0 --- /dev/null +++ b/resources/views/layouts/auth.blade.php @@ -0,0 +1,22 @@ +{{-- The package's minimal layout for the guest pages. Point javaabu-auth.layouts.auth at your own. --}} + + + + + + + {{ isset($title) ? $title . ' · ' : '' }}{{ config('app.name') }} + @manifestStyles + + +
+

+ {{ config('app.name') }} +

+ + @yield('content') +
+ + @manifestScripts + + diff --git a/resources/views/login.blade.php b/resources/views/login.blade.php new file mode 100644 index 0000000..96e9357 --- /dev/null +++ b/resources/views/login.blade.php @@ -0,0 +1,53 @@ +@extends($layout) + +@section($layout_section) + +
+ @include('auth::partials.status') + + + + + + +
+ + + @if($can_reset_password) + {{ __('Forgot your password?') }} + @endif +
+ + {{ __('Log in') }} +
+ + @if($can_use_passkeys) + @include('auth::partials.passkey-login') + @endif + + @foreach($auth_type['login_methods'] as $method) + + {{ $method['label'] }} + + @endforeach + + @if($can_register) +

+ {{ __("Don't have an account?") }} + {{ __('Sign up') }} +

+ @endif +
+
+@endsection diff --git a/resources/views/partials/account-nav.blade.php b/resources/views/partials/account-nav.blade.php new file mode 100644 index 0000000..4b7b8af --- /dev/null +++ b/resources/views/partials/account-nav.blade.php @@ -0,0 +1,15 @@ + diff --git a/resources/views/partials/passkey-login.blade.php b/resources/views/partials/passkey-login.blade.php new file mode 100644 index 0000000..7562900 --- /dev/null +++ b/resources/views/partials/passkey-login.blade.php @@ -0,0 +1,12 @@ +@manifestRequire('auth::passkeys') + +
+ + {{ __('Log in with a passkey') }} + + +
diff --git a/resources/views/partials/status.blade.php b/resources/views/partials/status.blade.php new file mode 100644 index 0000000..0612280 --- /dev/null +++ b/resources/views/partials/status.blade.php @@ -0,0 +1,5 @@ +@if($status ?? session('status')) + + {{ $status ?? session('status') }} + +@endif diff --git a/resources/views/register.blade.php b/resources/views/register.blade.php new file mode 100644 index 0000000..6ab03b0 --- /dev/null +++ b/resources/views/register.blade.php @@ -0,0 +1,22 @@ +@extends($layout) + +@section($layout_section) + + + + + + + + + + + {{ __('Create account') }} + +

+ {{ __('Already have an account?') }} + {{ __('Log in') }} +

+
+
+@endsection diff --git a/resources/views/reset-password.blade.php b/resources/views/reset-password.blade.php new file mode 100644 index 0000000..9e0f0f5 --- /dev/null +++ b/resources/views/reset-password.blade.php @@ -0,0 +1,17 @@ +@extends($layout) + +@section($layout_section) + + + + + + + + + + + {{ __('Reset password') }} + + +@endsection diff --git a/resources/views/settings/partials/api-tokens.blade.php b/resources/views/settings/partials/api-tokens.blade.php new file mode 100644 index 0000000..3ce150a --- /dev/null +++ b/resources/views/settings/partials/api-tokens.blade.php @@ -0,0 +1,35 @@ + +
+ @if(session('plain_text_token')) + + {{ __('Copy your new API token now. It will not be shown again.') }} + {{ session('plain_text_token') }} + + @endif + + + + {{ __('Create token') }} + + + @if($api_tokens) +
    + @foreach($api_tokens as $token) +
  • +
    + {{ $token['name'] }} + + {{ $token['last_used_at'] ? __('Last used :time', ['time' => $token['last_used_at']]) : __('Never used') }} + +
    + + + {{ __('Delete') }} + +
  • + @endforeach +
+ @endif +
+
diff --git a/resources/views/settings/partials/passkeys.blade.php b/resources/views/settings/partials/passkeys.blade.php new file mode 100644 index 0000000..4c3cea4 --- /dev/null +++ b/resources/views/settings/partials/passkeys.blade.php @@ -0,0 +1,43 @@ +@manifestRequire('auth::passkeys') + + +
+

{{ __('Log in with your fingerprint, face or screen lock instead of a password.') }}

+ + @if($passkeys) +
    + @foreach($passkeys as $passkey) +
  • +
    + {{ $passkey['name'] }} + + {{ $passkey['authenticator'] ?? __('Passkey') }} · + {{ __('Added :time', ['time' => $passkey['created_at']]) }} + @if($passkey['last_used_at']) + · {{ __('Last used :time', ['time' => $passkey['last_used_at']]) }} + @endif + +
    + + + {{ __('Remove') }} + +
  • + @endforeach +
+ @else +

{{ __('You have no passkeys yet.') }}

+ @endif + +
+ + {{ __('Add a passkey') }} +
+ +
+
diff --git a/resources/views/settings/partials/sessions.blade.php b/resources/views/settings/partials/sessions.blade.php new file mode 100644 index 0000000..0399cd8 --- /dev/null +++ b/resources/views/settings/partials/sessions.blade.php @@ -0,0 +1,27 @@ + +
+

{{ __('The browsers and devices where you are logged in.') }}

+ +
    + @foreach($sessions as $session) +
  • + + {{ $session['platform'] ?? __('Unknown') }} · {{ $session['browser'] ?? __('Unknown') }} + + + {{ $session['ip_address'] }} · + @if($session['is_current_device']) + {{ __('This device') }} + @else + {{ __('Last active :time', ['time' => $session['last_active']]) }} + @endif + +
  • + @endforeach +
+ + + {{ __('Log out other browser sessions') }} + +
+
diff --git a/resources/views/settings/partials/two-factor.blade.php b/resources/views/settings/partials/two-factor.blade.php new file mode 100644 index 0000000..d3349cb --- /dev/null +++ b/resources/views/settings/partials/two-factor.blade.php @@ -0,0 +1,62 @@ + +
+ @if($two_factor['enabled']) + {{ __('Two-factor authentication is on.') }} + @elseif($two_factor['pending']) +

{{ __('Scan the QR code with your authenticator app, then enter the code it shows.') }}

+ +
+
{!! $two_factor['qr_code'] !!}
+

+ {{ __('Setup key') }}: + {{ $two_factor['secret_key'] }} +

+
+ + + + +
+ {{ __('Confirm') }} +
+
+ @else +

+ @if($two_factor['required']) + {{ __('Your account needs two-factor authentication. Set it up to continue.') }} + @else + {{ __('Add a second step to your login with a code from an authenticator app.') }} + @endif +

+ + + {{ __('Turn on two-factor authentication') }} + + @endif + + @if($two_factor['recovery_codes']) +
+

{{ __('Store these recovery codes somewhere safe. Each can be used once if you lose your authenticator app.') }}

+
    + @foreach($two_factor['recovery_codes'] as $code) +
  • {{ $code }}
  • + @endforeach +
+
+ @endif + + @if($two_factor['enabled']) +
+ + {{ __('Regenerate recovery codes') }} + + + @unless($two_factor['required']) + + {{ __('Turn off two-factor authentication') }} + + @endunless +
+ @endif +
+
diff --git a/resources/views/settings/profile.blade.php b/resources/views/settings/profile.blade.php new file mode 100644 index 0000000..36287aa --- /dev/null +++ b/resources/views/settings/profile.blade.php @@ -0,0 +1,51 @@ +@extends($layout) + +@section($layout_section) + @manifestRequire('auth::auth') + +
+

{{ __('Profile') }}

+ + @include('auth::partials.status') + + + + + + + + @if($must_verify_email) + + {{ __('Your email address is not verified.') }} + @isset($urls['verification.send']) + {{ __('Resend verification email') }} + @endisset + + @endif + +
+ {{ __('Save') }} +
+
+ + @if($must_verify_email && isset($urls['verification.send'])) + + @endif +
+ + @if($can_delete_account) + + +

{{ __('Once your account is deleted, all of its data is permanently deleted. Enter your password to confirm.') }}

+ + + +
+ {{ __('Delete account') }} +
+
+
+ @endif +
+@endsection diff --git a/resources/views/settings/security.blade.php b/resources/views/settings/security.blade.php new file mode 100644 index 0000000..ce621d2 --- /dev/null +++ b/resources/views/settings/security.blade.php @@ -0,0 +1,41 @@ +@extends($layout) + +@section($layout_section) + @manifestRequire('auth::auth') + +
+

{{ __('Security') }}

+ + @include('auth::partials.status') + + + + + + + + + +
+ {{ __('Save password') }} +
+
+
+ + @if($two_factor) + @include('auth::settings.partials.two-factor') + @endif + + @if(! is_null($passkeys)) + @include('auth::settings.partials.passkeys') + @endif + + @if(! is_null($sessions)) + @include('auth::settings.partials.sessions') + @endif + + @if(! is_null($api_tokens)) + @include('auth::settings.partials.api-tokens') + @endif +
+@endsection diff --git a/resources/views/two-factor-challenge.blade.php b/resources/views/two-factor-challenge.blade.php new file mode 100644 index 0000000..66fc429 --- /dev/null +++ b/resources/views/two-factor-challenge.blade.php @@ -0,0 +1,32 @@ +@extends($layout) + +@section($layout_section) + +
+ +

{{ __('Enter the code from your authenticator app.') }}

+ + + + {{ __('Continue') }} +
+ +
has('recovery_code')) open @endif> + {{ __('Use a recovery code instead') }} + + + + + {{ __('Continue') }} + +
+
+
+@endsection diff --git a/resources/views/update-required-password.blade.php b/resources/views/update-required-password.blade.php new file mode 100644 index 0000000..4ebaf56 --- /dev/null +++ b/resources/views/update-required-password.blade.php @@ -0,0 +1,19 @@ +@extends($layout) + +@section($layout_section) + +
+

{{ __('You need to choose a new password before you continue.') }}

+ + + + + + + + + {{ __('Save password') }} + +
+
+@endsection diff --git a/resources/views/verify-email.blade.php b/resources/views/verify-email.blade.php new file mode 100644 index 0000000..69e508b --- /dev/null +++ b/resources/views/verify-email.blade.php @@ -0,0 +1,21 @@ +@extends($layout) + +@section($layout_section) + +
+

{{ __('We have emailed a verification link to :email. Click the link to verify your email address.', ['email' => $email]) }}

+ + @include('auth::partials.status') + +
+ + {{ __('Resend verification email') }} + + + + {{ __('Log out') }} + +
+
+
+@endsection diff --git a/src/Actions/AttemptLogin.php b/src/Actions/AttemptLogin.php new file mode 100644 index 0000000..b4be343 --- /dev/null +++ b/src/Actions/AttemptLogin.php @@ -0,0 +1,125 @@ +ensureIsNotRateLimited($request, $type); + + $user = $this->timebox->call(function (Timebox $timebox) use ($request, $type) { + $user = $this->validateCredentials($request, $type); + + if ($user) { + $timebox->returnEarly(); + } + + return $user; + }, 200 * 1000); + + if (! $user) { + $this->failed($request, $type); + } + + RateLimiter::clear($this->throttleKey($request, $type)); + + return ($this->start_session)($request, $type, $user, $request->boolean('remember')); + } + + protected function validateCredentials(Request $request, UserType $type): ?Authenticatable + { + $provider = $type->auth()->getProvider(); + $credentials = $this->credentials($request, $type); + + $user = $provider->retrieveByCredentials([$type->usernameColumn() => $credentials[$type->usernameColumn()]]); + + if ($user instanceof LocksAfterFailedLogins && $user->isLockedOut()) { + throw ValidationException::withMessages([ + $type->usernameColumn() => __('Your account has been locked due to too many failed logins. Reset your password to unlock it.'), + ]); + } + + if ($user && $provider->validateCredentials($user, $credentials)) { + return $user; + } + + event(new Failed($type->guardName(), $user, $credentials)); + + if ($user instanceof LocksAfterFailedLogins) { + $user->incrementLoginAttempts(); + + if ($user->isLockedOut()) { + AccountLocked::dispatch($user); + } + } + + return null; + } + + /** @return array */ + protected function credentials(Request $request, UserType $type): array + { + return [ + $type->usernameColumn() => mb_strtolower(trim((string) $request->input($type->usernameColumn()))), + 'password' => (string) $request->input('password'), + ]; + } + + protected function failed(Request $request, UserType $type): never + { + RateLimiter::hit($this->throttleKey($request, $type)); + + throw ValidationException::withMessages([ + $type->usernameColumn() => __('These credentials do not match our records.'), + ]); + } + + protected function ensureIsNotRateLimited(Request $request, UserType $type): void + { + $key = $this->throttleKey($request, $type); + + if (! RateLimiter::tooManyAttempts($key, (int) config('javaabu-auth.rate_limits.login', 5))) { + return; + } + + event(new Lockout($request)); + + $seconds = RateLimiter::availableIn($key); + + throw ValidationException::withMessages([ + $type->usernameColumn() => __('Too many login attempts. Please try again in :seconds seconds.', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ])->status(429); + } + + protected function throttleKey(Request $request, UserType $type): string + { + $username = Str::transliterate(Str::lower((string) $request->input($type->usernameColumn()))); + + return "auth.login|{$type->name()}|{$username}|{$request->ip()}"; + } +} diff --git a/src/Actions/CreateNewUser.php b/src/Actions/CreateNewUser.php new file mode 100644 index 0000000..c128a69 --- /dev/null +++ b/src/Actions/CreateNewUser.php @@ -0,0 +1,51 @@ +newModel(); + + Validator::make($input, $this->rules($type, $user))->validate(); + + $user->forceFill([ + 'name' => $input['name'], + $type->usernameColumn() => $this->normalizeUsername($input[$type->usernameColumn()]), + 'password' => Hash::make($input['password']), + ])->save(); + + return $user; + } + + /** @return array> */ + protected function rules(UserType $type, Model $user): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + $type->usernameColumn() => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique($user::class, $type->usernameColumn()), + ], + 'password' => $this->passwordRules(), + ]; + } + + protected function normalizeUsername(string $username): string + { + return mb_strtolower(trim($username)); + } +} diff --git a/src/Actions/DeleteUser.php b/src/Actions/DeleteUser.php new file mode 100644 index 0000000..c0933e0 --- /dev/null +++ b/src/Actions/DeleteUser.php @@ -0,0 +1,22 @@ +tokens()->delete(); + } + + $user->delete(); + + AccountDeleted::dispatch($user); + } +} diff --git a/src/Actions/LoginUser.php b/src/Actions/LoginUser.php new file mode 100644 index 0000000..ec16872 --- /dev/null +++ b/src/Actions/LoginUser.php @@ -0,0 +1,28 @@ +auth()->login($user, $remember); + + if ($request->hasSession()) { + $request->session()->regenerate(); + $request->session()->forget(StartAuthenticatedSession::CHALLENGE_SESSION_KEY); + } + + if ($user instanceof LocksAfterFailedLogins) { + $user->resetLoginAttempts(); + } + } +} diff --git a/src/Actions/PasswordValidationRules.php b/src/Actions/PasswordValidationRules.php new file mode 100644 index 0000000..fdd7fc0 --- /dev/null +++ b/src/Actions/PasswordValidationRules.php @@ -0,0 +1,14 @@ + */ + protected function passwordRules(): array + { + return ['required', 'string', Password::default(), 'confirmed']; + } +} diff --git a/src/Actions/ResetUserPassword.php b/src/Actions/ResetUserPassword.php new file mode 100644 index 0000000..32411f6 --- /dev/null +++ b/src/Actions/ResetUserPassword.php @@ -0,0 +1,37 @@ + $this->passwordRules(), + ])->validate(); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ]); + + if ($user instanceof RequiresPasswordUpdate) { + $user->clearRequirePasswordUpdate(); + } + + $user->save(); + + if ($user instanceof LocksAfterFailedLogins) { + $user->resetLoginAttempts(); + } + } +} diff --git a/src/Actions/StartAuthenticatedSession.php b/src/Actions/StartAuthenticatedSession.php new file mode 100644 index 0000000..8bf8928 --- /dev/null +++ b/src/Actions/StartAuthenticatedSession.php @@ -0,0 +1,65 @@ +attributes->set(UserTypeRegistry::ROUTE_ACTION, $type->name()); + + if (! $skip_two_factor && $this->needsTwoFactorChallenge($type, $user)) { + return $this->challenge($request, $type, $user, $remember); + } + + ($this->login)($request, $type, $user, $remember); + + return app(LoginResponse::class)->toResponse($request); + } + + public function needsTwoFactorChallenge(UserType $type, Authenticatable $user): bool + { + return $type->hasTwoFactor() + && $user instanceof TwoFactorAuthenticatable + && $user->hasEnabledTwoFactorAuthentication(); + } + + protected function challenge(Request $request, UserType $type, Authenticatable $user, bool $remember): Response + { + $request->session()->put(self::CHALLENGE_SESSION_KEY, [ + 'user_type' => $type->name(), + 'id' => $user->getAuthIdentifier(), + 'remember' => $remember, + ]); + + TwoFactorAuthenticationChallenged::dispatch($user); + + return app(TwoFactorChallengeResponse::class)->toResponse($request); + } +} diff --git a/src/Actions/TwoFactor/ConfirmTwoFactorAuthentication.php b/src/Actions/TwoFactor/ConfirmTwoFactorAuthentication.php new file mode 100644 index 0000000..3754be5 --- /dev/null +++ b/src/Actions/TwoFactor/ConfirmTwoFactorAuthentication.php @@ -0,0 +1,32 @@ +twoFactorSecret(); + + if (! $secret || ! $code || ! $this->provider->verify($secret, $code)) { + throw ValidationException::withMessages([ + 'code' => __('The provided two-factor authentication code was invalid.'), + ]); + } + + $user->forceFill([ + 'two_factor_confirmed_at' => now(), + ])->save(); + + TwoFactorAuthenticationConfirmed::dispatch($user); + } +} diff --git a/src/Actions/TwoFactor/DisableTwoFactorAuthentication.php b/src/Actions/TwoFactor/DisableTwoFactorAuthentication.php new file mode 100644 index 0000000..7e2fcad --- /dev/null +++ b/src/Actions/TwoFactor/DisableTwoFactorAuthentication.php @@ -0,0 +1,24 @@ +two_factor_secret) && is_null($user->two_factor_recovery_codes) && is_null($user->two_factor_confirmed_at)) { + return; + } + + $user->forceFill([ + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + ])->save(); + + TwoFactorAuthenticationDisabled::dispatch($user); + } +} diff --git a/src/Actions/TwoFactor/EnableTwoFactorAuthentication.php b/src/Actions/TwoFactor/EnableTwoFactorAuthentication.php new file mode 100644 index 0000000..63f9b5a --- /dev/null +++ b/src/Actions/TwoFactor/EnableTwoFactorAuthentication.php @@ -0,0 +1,33 @@ +two_factor_secret) && ! $force) { + return; + } + + $user->forceFill([ + 'two_factor_secret' => encrypt($this->provider->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(GenerateNewRecoveryCodes::codes())), + 'two_factor_confirmed_at' => null, + ])->save(); + + TwoFactorAuthenticationEnabled::dispatch($user); + } +} diff --git a/src/Actions/TwoFactor/GenerateNewRecoveryCodes.php b/src/Actions/TwoFactor/GenerateNewRecoveryCodes.php new file mode 100644 index 0000000..6b39e5a --- /dev/null +++ b/src/Actions/TwoFactor/GenerateNewRecoveryCodes.php @@ -0,0 +1,27 @@ +forceFill([ + 'two_factor_recovery_codes' => encrypt(json_encode(static::codes())), + ])->save(); + + RecoveryCodesGenerated::dispatch($user); + } + + /** @return array */ + public static function codes(): array + { + $count = (int) config('javaabu-auth.two_factor.recovery_codes', 8); + + return array_map(fn () => RecoveryCode::generate(), range(1, max($count, 1))); + } +} diff --git a/src/Actions/UpdateUserPassword.php b/src/Actions/UpdateUserPassword.php new file mode 100644 index 0000000..ddd7779 --- /dev/null +++ b/src/Actions/UpdateUserPassword.php @@ -0,0 +1,36 @@ + ['required', 'string', "current_password:{$type->guardName()}"], + 'password' => [...$this->passwordRules(), 'different:current_password'], + ])->validate(); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ]); + + if ($user instanceof RequiresPasswordUpdate) { + $user->clearRequirePasswordUpdate(); + } + + $user->save(); + + PasswordUpdated::dispatch($user); + } +} diff --git a/src/Actions/UpdateUserProfileInformation.php b/src/Actions/UpdateUserProfileInformation.php new file mode 100644 index 0000000..0924a92 --- /dev/null +++ b/src/Actions/UpdateUserProfileInformation.php @@ -0,0 +1,55 @@ +usernameColumn(); + + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + $username => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique($user::class, $username)->ignore($user->getKey(), $user->getKeyName()), + ], + ])->validate(); + + $new_username = mb_strtolower(trim($input[$username])); + $changed_email = $new_username !== $user->getAttribute($username); + + $user->forceFill([ + 'name' => $input['name'], + $username => $new_username, + ]); + + $must_verify = $changed_email + && $username === 'email' + && $user instanceof MustVerifyEmail + && $type->hasFeature(UserType::EMAIL_VERIFICATION); + + if ($must_verify) { + $user->forceFill(['email_verified_at' => null]); + } + + $user->save(); + + if ($must_verify) { + $user->sendEmailVerificationNotification(); + } + + ProfileUpdated::dispatch($user); + } +} diff --git a/src/AuthServiceProvider.php b/src/AuthServiceProvider.php index 58106b7..6a97ac9 100644 --- a/src/AuthServiceProvider.php +++ b/src/AuthServiceProvider.php @@ -2,73 +2,245 @@ namespace Javaabu\Auth; +use Illuminate\Auth\AuthenticationException; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Foundation\Exceptions\Handler; +use Illuminate\Http\Request; +use Illuminate\Routing\Router; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Session; use Illuminate\Support\ServiceProvider; -use Javaabu\Auth\Http\Middlewares\RedirectIfActivated; -use Javaabu\Auth\Http\Middlewares\RedirectIfActivatedAndEmailVerified; -use Javaabu\Auth\Http\Middlewares\RedirectIfEmailNotVerified; -use Javaabu\Auth\Http\Middlewares\RedirectIfEmailVerificationNotNeeded; -use Javaabu\Auth\Http\Middlewares\RedirectIfNotActivated; -use Javaabu\Auth\PasswordUpdate\Middleware\RedirectIfPasswordUpdateNotRequired; -use Javaabu\Auth\PasswordUpdate\Middleware\RedirectIfPasswordUpdateRequired; -use Javaabu\Auth\Providers\EventServiceProvider; -use Javaabu\Auth\Session\MultiAuthDatabaseSessionHandler; +use Javaabu\Auth\Actions\CreateNewUser; +use Javaabu\Auth\Actions\DeleteUser; +use Javaabu\Auth\Actions\ResetUserPassword; +use Javaabu\Auth\Actions\UpdateUserPassword; +use Javaabu\Auth\Actions\UpdateUserProfileInformation; +use Javaabu\Auth\Contracts\CreatesNewUsers; +use Javaabu\Auth\Contracts\DeletesUsers; +use Javaabu\Auth\Contracts\ResetsUserPasswords; +use Javaabu\Auth\Contracts\UpdatesUserPasswords; +use Javaabu\Auth\Contracts\UpdatesUserProfileInformation; +use Javaabu\Auth\Http\Middleware\Authenticate; +use Javaabu\Auth\Http\Middleware\EnsureAccountIsActive; +use Javaabu\Auth\Http\Middleware\EnsureEmailIsVerified; +use Javaabu\Auth\Http\Middleware\EnsurePasswordIsUpdated; +use Javaabu\Auth\Http\Middleware\EnsureTwoFactorIsEnabled; +use Javaabu\Auth\Http\Middleware\RedirectIfAuthenticated; +use Javaabu\Auth\Http\Middleware\RequirePassword; +use Javaabu\Auth\Http\Middleware\SetUserType; +use Javaabu\Auth\Passkeys\Passkey; +use Javaabu\Auth\Passkeys\VerifyPasskey; +use Javaabu\Auth\Routing\AuthRoutes; +use Javaabu\Auth\Sessions\MultiAuthDatabaseSessionHandler; +use Javaabu\Auth\Support\ActivityLogger; +use Javaabu\Auth\TwoFactor\TwoFactorProvider; +use Javaabu\Manifest\Concerns\RegistersAssets; +use Javaabu\Manifest\Package; +use Laravel\Passkeys\Actions\VerifyPasskey as BaseVerifyPasskey; +use Laravel\Passkeys\Passkeys; +use PragmaRX\Google2FA\Google2FA; class AuthServiceProvider extends ServiceProvider { + use RegistersAssets; + + /** Route middleware the package registers */ + public const array MIDDLEWARE = [ + 'auth.type' => Authenticate::class, + 'auth.guest' => RedirectIfAuthenticated::class, + 'auth.user-type' => SetUserType::class, + 'auth.active' => EnsureAccountIsActive::class, + 'auth.verified' => EnsureEmailIsVerified::class, + 'auth.password-updated' => EnsurePasswordIsUpdated::class, + 'auth.two-factor' => EnsureTwoFactorIsEnabled::class, + 'auth.password.confirm' => RequirePassword::class, + ]; + + /** Default implementations of the actions apps can replace */ + public const array ACTIONS = [ + CreatesNewUsers::class => CreateNewUser::class, + ResetsUserPasswords::class => ResetUserPassword::class, + UpdatesUserPasswords::class => UpdateUserPassword::class, + UpdatesUserProfileInformation::class => UpdateUserProfileInformation::class, + DeletesUsers::class => DeleteUser::class, + ]; + + public static bool $runs_migrations = true; + /** - * Bootstrap the application services. + * Stop the package from loading its migrations, e.g. after publishing them */ + public static function ignoreMigrations(): void + { + static::$runs_migrations = false; + } + + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../config/javaabu-auth.php', 'javaabu-auth'); + + $this->app->singleton(UserTypeRegistry::class); + + foreach (self::ACTIONS as $contract => $action) { + $this->app->bindIf($contract, $action); + } + + $this->app->singleton(TwoFactorProvider::class, function (Application $app) { + return new TwoFactorProvider($app->make(Google2FA::class), $app->make('cache')->store()); + }); + + $this->app->bind(BaseVerifyPasskey::class, VerifyPasskey::class); + + // The package registers passkey routes per user type + Passkeys::ignoreRoutes(); + Passkeys::usePasskeyModel(Passkey::class); + } + public function boot(): void { - // declare publishes if ($this->app->runningInConsole()) { - $this->publishes([ - __DIR__.'/../config/config.php' => config_path('auth.php'), - ], 'auth-config'); + $this->registerPublishing(); + } - $this->publishes([ - __DIR__ . '/../lang' => lang_path('vendor/auth'), - ], 'auth-translations'); + $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); + $this->loadViewsFrom(__DIR__ . '/../resources/views', 'auth'); + + if (static::$runs_migrations) { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); } - $this->loadTranslationsFrom(__DIR__.'/../lang', 'auth'); + $this->registerFrontendAssets(); + $this->registerMiddleware(); + $this->registerRouteMacros(); + $this->registerRateLimiters(); + $this->registerSessionDriver(); + $this->registerGuestRedirects(); - Session::extend('multi_auth_database', function (Application $app) { - $table = config('session.table'); + $this->app->make(ActivityLogger::class)->register(); + } - $lifetime = config('session.lifetime'); + protected function registerPublishing(): void + { + $this->publishes([ + __DIR__ . '/../config/javaabu-auth.php' => config_path('javaabu-auth.php'), + ], 'auth-config'); - $connection = config('session.connection'); + $this->publishes([ + __DIR__ . '/../resources/views' => resource_path('views/vendor/auth'), + ], 'auth-views'); - $db_connection = $app->make('db')->connection($connection); + $this->publishes([ + __DIR__ . '/../lang' => lang_path('vendor/auth'), + ], 'auth-translations'); - return new MultiAuthDatabaseSessionHandler($db_connection, $table, $lifetime, $app); - }); + $this->publishesMigrations([ + __DIR__ . '/../database/migrations' => database_path('migrations'), + ], 'auth-migrations'); } /** - * Register the application services. + * The Inertia pages (auth::Login, …), the code apps can import as `@javaabu/auth`, + * and the scripts the Blade pages load */ - public function register(): void + protected function registerFrontendAssets(): void { - // merge package config with user defined config - $this->mergeConfigFrom(__DIR__.'/../config/config.php', 'auth'); + $this->registerAssets('auth', __DIR__ . '/../resources', function (Package $package) { + $package + ->script('auth', 'js/auth.ts') + ->script('passkeys', 'js/passkeys.ts') + ->js() + ->pages() + ->npm([ + '@javaabu/forms' => 'github:Javaabu/react-forms#next-gen', + '@javaabu/onu' => '^0.2.1', + ], peer_dependencies: [ + 'react' => '^18.3 || ^19.0', + 'react-dom' => '^18.3 || ^19.0', + '@inertiajs/react' => '^2.0 || ^3.0', + ]); + }); + } - $this->app->register(EventServiceProvider::class); + protected function registerMiddleware(): void + { + $router = $this->app->make(Router::class); - $this->registerMiddlewareAliases(); + foreach (self::MIDDLEWARE as $alias => $middleware) { + $router->aliasMiddleware($alias, $middleware); + } } - public function registerMiddlewareAliases(): void + protected function registerRouteMacros(): void { - app('router')->aliasMiddleware('active', RedirectIfNotActivated::class); - app('router')->aliasMiddleware('inactive', RedirectIfActivated::class); - app('router')->aliasMiddleware('password-update-not-required', RedirectIfPasswordUpdateRequired::class); - app('router')->aliasMiddleware('password-update-required', RedirectIfPasswordUpdateNotRequired::class); - app('router')->aliasMiddleware('needs-verification', RedirectIfEmailVerificationNotNeeded::class); - app('router')->aliasMiddleware('email.verified', RedirectIfEmailNotVerified::class); - app('router')->aliasMiddleware('inactive-email.unverified', RedirectIfActivatedAndEmailVerified::class); + Route::macro('authRoutes', function (?string $type = null, array $options = []) { + AuthRoutes::register($this, $type, $options); + }); + } + + protected function registerRateLimiters(): void + { + $limit = fn (string $key, int $default) => (int) config("javaabu-auth.rate_limits.{$key}", $default); + + RateLimiter::for('auth.passkeys', function (Request $request) use ($limit) { + return Limit::perMinute($limit('passkeys', 10)) + ->by(($request->input('credential.id') ?: $request->session()->getId()) . '|' . $request->ip()); + }); + + RateLimiter::for('auth.password-reset', function (Request $request) use ($limit) { + return Limit::perMinute($limit('password_reset', 6))->by($request->ip()); + }); + + RateLimiter::for('auth.verification', function (Request $request) use ($limit) { + return Limit::perMinute($limit('verification', 6))->by($request->ip()); + }); + + RateLimiter::for('auth.api-tokens', function (Request $request) use ($limit) { + return Limit::perMinute($limit('api_tokens', 6))->by($request->ip()); + }); + } + + protected function registerSessionDriver(): void + { + Session::extend('multi_auth_database', function (Application $app) { + $connection = $app->make('db')->connection(config('session.connection')); + + return new MultiAuthDatabaseSessionHandler( + $connection, + config('session.table', 'sessions'), + config('session.lifetime'), + $app, + ); + }); + } + + /** + * Guests on a user type's guard go to that type's login page, not `route('login')` + */ + protected function registerGuestRedirects(): void + { + $this->callAfterResolving(ExceptionHandler::class, function (ExceptionHandler $handler) { + if (! $handler instanceof Handler) { + return; + } + + $handler->renderable(function (AuthenticationException $exception, Request $request) { + if ($request->expectsJson() || $exception->redirectTo($request)) { + return null; + } + + foreach ($exception->guards() as $guard) { + $type = UserTypes::forGuard($guard); + + if ($type?->hasRoute('login')) { + return redirect()->guest($type->route('login')); + } + } + + return null; + }); + }); } } diff --git a/src/Traits/DetectsGmailAliases.php b/src/Concerns/DetectsGmailAliases.php similarity index 65% rename from src/Traits/DetectsGmailAliases.php rename to src/Concerns/DetectsGmailAliases.php index 939a0d5..59021a9 100644 --- a/src/Traits/DetectsGmailAliases.php +++ b/src/Concerns/DetectsGmailAliases.php @@ -1,6 +1,6 @@ afterLast('@') - ->lower() - ->toString(); + ->afterLast('@') + ->lower() + ->toString(); if ($domain != 'gmail.com') { $query->where($field, $gmail); + return; } // get the username $username = Str::of($gmail) - ->beforeLast('@') - ->lower() // normalize to lowercase - ->replace('.', '') // remove dots - ->beforeLast('+') // remove + - ->toString(); - + ->beforeLast('@') + ->lower() // normalize to lowercase + ->replace('.', '') // remove dots + ->beforeLast('+') // remove + + ->toString(); $query->whereRaw(" LOWER( @@ -40,6 +40,6 @@ public function scopeHasGmailAlias(Builder $query, string $gmail, string $field '.', '' ) ) = ?", [$username]) - ->where($field, 'LIKE', '%@gmail.com'); + ->where($field, 'LIKE', '%@gmail.com'); } } diff --git a/src/Concerns/HasAccountStatus.php b/src/Concerns/HasAccountStatus.php new file mode 100644 index 0000000..adadd9c --- /dev/null +++ b/src/Concerns/HasAccountStatus.php @@ -0,0 +1,88 @@ + UserStatuses::class` to the model's casts. + * + * @property UserStatuses $status + */ +trait HasAccountStatus +{ + public function initializeHasAccountStatus(): void + { + $this->mergeCasts(['status' => UserStatuses::class]); + + if (! array_key_exists('status', $this->attributes)) { + $this->attributes['status'] = UserStatuses::PENDING->value; + } + } + + public function hasActiveAccount(): bool + { + return $this->status === UserStatuses::APPROVED; + } + + public function accountStatusMessage(): string + { + return $this->status->message(); + } + + public function approve(bool $save = false): void + { + $this->updateAccountStatus(UserStatuses::APPROVED, $save); + } + + public function markAsPending(bool $save = false): void + { + $this->updateAccountStatus(UserStatuses::PENDING, $save); + } + + public function ban(bool $save = false): void + { + $this->updateAccountStatus(UserStatuses::BANNED, $save); + } + + public function isApproved(): bool + { + return $this->status === UserStatuses::APPROVED; + } + + public function isPending(): bool + { + return $this->status === UserStatuses::PENDING; + } + + public function isBanned(): bool + { + return $this->status === UserStatuses::BANNED; + } + + public function scopeApproved(Builder $query): void + { + $query->where($this->qualifyColumn('status'), UserStatuses::APPROVED->value); + } + + public function scopePending(Builder $query): void + { + $query->where($this->qualifyColumn('status'), UserStatuses::PENDING->value); + } + + public function scopeBanned(Builder $query): void + { + $query->where($this->qualifyColumn('status'), UserStatuses::BANNED->value); + } + + protected function updateAccountStatus(UserStatuses $status, bool $save): void + { + $this->status = $status; + + if ($save) { + $this->save(); + } + } +} diff --git a/src/Concerns/HasPasskeys.php b/src/Concerns/HasPasskeys.php new file mode 100644 index 0000000..da37f66 --- /dev/null +++ b/src/Concerns/HasPasskeys.php @@ -0,0 +1,34 @@ +hasMany(Passkeys::passkeyModel(), 'user_id') + ->withAttributes(['user_type' => $this->getMorphClass()]); + } + + public static function bootHasPasskeys(): void + { + static::deleted(function ($user) { + if (method_exists($user, 'isForceDeleting') && ! $user->isForceDeleting()) { + return; + } + + $user->passkeys()->delete(); + }); + } +} diff --git a/src/Concerns/HasSessions.php b/src/Concerns/HasSessions.php new file mode 100644 index 0000000..30f47ad --- /dev/null +++ b/src/Concerns/HasSessions.php @@ -0,0 +1,31 @@ +morphMany(Session::class, 'user'); + } + + /** + * Log the user out of their other browsers + */ + public function deleteOtherSessionRecords(): void + { + if (config('session.driver') !== 'multi_auth_database') { + return; + } + + $this->sessions() + ->when(request()->hasSession(), fn ($query) => $query->where('id', '!=', request()->session()->getId())) + ->delete(); + } +} diff --git a/src/Concerns/HasUserType.php b/src/Concerns/HasUserType.php new file mode 100644 index 0000000..07f8667 --- /dev/null +++ b/src/Concerns/HasUserType.php @@ -0,0 +1,31 @@ +notify(new ResetPassword($token, $this->userType()->name())); + } + + public function sendEmailVerificationNotification(): void + { + $this->notify(new VerifyEmail($this->userType()->name())); + } +} diff --git a/src/Concerns/LocksAfterFailedLogins.php b/src/Concerns/LocksAfterFailedLogins.php new file mode 100644 index 0000000..9548a03 --- /dev/null +++ b/src/Concerns/LocksAfterFailedLogins.php @@ -0,0 +1,79 @@ +mergeCasts(['login_attempts' => 'integer']); + } + + public function maxLoginAttempts(): ?int + { + $type = UserTypes::for($this); + + return $type + ? $type->maxLoginAttemptsAllowed() + : config('javaabu-auth.max_login_attempts'); + } + + public function isLockedOut(): bool + { + $max = $this->maxLoginAttempts(); + + if (! $max) { + return false; + } + + return ($this->login_attempts ?? 0) >= $max; + } + + public function incrementLoginAttempts(): void + { + $this->forceFill(['login_attempts' => ($this->login_attempts ?? 0) + 1])->saveQuietly(); + } + + public function resetLoginAttempts(): void + { + if (! $this->login_attempts) { + return; + } + + $this->forceFill(['login_attempts' => null])->saveQuietly(); + } + + public function scopeLockedOut(Builder $query): void + { + $max = $this->maxLoginAttempts(); + + if (! $max) { + $query->whereRaw('1 = 0'); + + return; + } + + $query->where($this->qualifyColumn('login_attempts'), '>=', $max); + } + + public function scopeNotLockedOut(Builder $query): void + { + $max = $this->maxLoginAttempts(); + + if (! $max) { + return; + } + + $query->where(function (Builder $query) use ($max) { + $query->whereNull($this->qualifyColumn('login_attempts')) + ->orWhere($this->qualifyColumn('login_attempts'), '<', $max); + }); + } +} diff --git a/src/Concerns/RequiresPasswordUpdate.php b/src/Concerns/RequiresPasswordUpdate.php new file mode 100644 index 0000000..7363430 --- /dev/null +++ b/src/Concerns/RequiresPasswordUpdate.php @@ -0,0 +1,29 @@ +mergeCasts(['require_password_update' => 'boolean']); + } + + public function requiresPasswordUpdate(): bool + { + return (bool) $this->require_password_update; + } + + public function requirePasswordUpdate(): void + { + $this->forceFill(['require_password_update' => true])->save(); + } + + public function clearRequirePasswordUpdate(): void + { + $this->forceFill(['require_password_update' => false]); + } +} diff --git a/src/Concerns/TwoFactorAuthenticatable.php b/src/Concerns/TwoFactorAuthenticatable.php new file mode 100644 index 0000000..295f037 --- /dev/null +++ b/src/Concerns/TwoFactorAuthenticatable.php @@ -0,0 +1,105 @@ +mergeCasts(['two_factor_confirmed_at' => 'datetime']); + + $this->makeHidden(['two_factor_secret', 'two_factor_recovery_codes']); + } + + /** + * Turned on and confirmed with a code from the app + */ + public function hasEnabledTwoFactorAuthentication(): bool + { + return ! is_null($this->two_factor_secret) + && ! is_null($this->two_factor_confirmed_at); + } + + /** + * Turned on but not confirmed yet + */ + public function hasPendingTwoFactorAuthentication(): bool + { + return ! is_null($this->two_factor_secret) + && is_null($this->two_factor_confirmed_at); + } + + public function twoFactorSecret(): ?string + { + return $this->two_factor_secret + ? decrypt($this->two_factor_secret) + : null; + } + + /** @return array */ + public function recoveryCodes(): array + { + if (! $this->two_factor_recovery_codes) { + return []; + } + + return json_decode(decrypt($this->two_factor_recovery_codes), true) ?: []; + } + + public function replaceRecoveryCode(string $code): void + { + $codes = array_map( + fn (string $existing) => hash_equals($existing, $code) ? RecoveryCode::generate() : $existing, + $this->recoveryCodes(), + ); + + $this->forceFill([ + 'two_factor_recovery_codes' => encrypt(json_encode($codes)), + ])->save(); + + RecoveryCodeReplaced::dispatch($this, $code); + } + + public function twoFactorQrCodeUrl(): string + { + return app(TwoFactorProvider::class)->qrCodeUrl( + config('javaabu-auth.two_factor.issuer') ?: config('app.name'), + $this->twoFactorLabel(), + (string) $this->twoFactorSecret(), + ); + } + + /** + * The account name shown in the authenticator app + */ + public function twoFactorLabel(): string + { + return (string) ($this->email ?? $this->getAuthIdentifier()); + } + + public function twoFactorQrCodeSvg(): string + { + $svg = (new Writer( + new ImageRenderer( + new RendererStyle(192, 0, null, null, Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(45, 55, 72))), + new SvgImageBackEnd, + ), + ))->writeString($this->twoFactorQrCodeUrl()); + + return trim(substr($svg, strpos($svg, "\n") + 1)); + } +} diff --git a/src/Contracts/ConfirmPasswordContract.php b/src/Contracts/ConfirmPasswordContract.php deleted file mode 100644 index 69facb9..0000000 --- a/src/Contracts/ConfirmPasswordContract.php +++ /dev/null @@ -1,12 +0,0 @@ - $input + */ + public function create(UserType $type, array $input): Model; +} diff --git a/src/Contracts/DeletesUsers.php b/src/Contracts/DeletesUsers.php new file mode 100644 index 0000000..75770a3 --- /dev/null +++ b/src/Contracts/DeletesUsers.php @@ -0,0 +1,14 @@ + $input + */ + public function reset(UserType $type, Model $user, array $input): void; +} diff --git a/src/Contracts/TwoFactorAuthenticatable.php b/src/Contracts/TwoFactorAuthenticatable.php new file mode 100644 index 0000000..0bf52cd --- /dev/null +++ b/src/Contracts/TwoFactorAuthenticatable.php @@ -0,0 +1,22 @@ + */ + public function recoveryCodes(): array; + + public function replaceRecoveryCode(string $code): void; + + public function twoFactorQrCodeUrl(): string; + + public function twoFactorQrCodeSvg(): string; +} diff --git a/src/Contracts/UpdatePasswordContract.php b/src/Contracts/UpdatePasswordContract.php deleted file mode 100644 index 7187a9b..0000000 --- a/src/Contracts/UpdatePasswordContract.php +++ /dev/null @@ -1,15 +0,0 @@ - $input + */ + public function update(UserType $type, Model $user, array $input): void; +} diff --git a/src/Contracts/UpdatesUserProfileInformation.php b/src/Contracts/UpdatesUserProfileInformation.php new file mode 100644 index 0000000..23cf5fb --- /dev/null +++ b/src/Contracts/UpdatesUserProfileInformation.php @@ -0,0 +1,16 @@ + $input + */ + public function update(UserType $type, Model $user, array $input): void; +} diff --git a/src/Contracts/VerificationContract.php b/src/Contracts/VerificationContract.php deleted file mode 100644 index 83d4a5f..0000000 --- a/src/Contracts/VerificationContract.php +++ /dev/null @@ -1,12 +0,0 @@ - __('Off'), + self::OPTIONAL => __('Optional'), + self::REQUIRED => __('Required'), + }; + } +} diff --git a/src/Enums/UserStatuses.php b/src/Enums/UserStatuses.php index bc94bfb..8a2bdfa 100644 --- a/src/Enums/UserStatuses.php +++ b/src/Enums/UserStatuses.php @@ -2,45 +2,51 @@ namespace Javaabu\Auth\Enums; -use Javaabu\Helpers\Enums\IsStatusEnum; -use Javaabu\Helpers\Enums\NativeEnumsTrait; - -enum UserStatuses: string implements IsStatusEnum +enum UserStatuses: string { - use NativeEnumsTrait; - case APPROVED = 'approved'; case PENDING = 'pending'; case BANNED = 'banned'; - /** - * Initialize Messages - */ - public static function messages(): array - { - return [ - self::APPROVED->value => __('Your account is approved.'), - self::PENDING->value => __('Your account needs to be approved before you can access it.'), - self::BANNED->value => __('Your account has been banned.'), - ]; - } - - public function getMessage(): string + public function label(): string { - return self::messages()[$this->value]; + return match ($this) { + self::APPROVED => __('Approved'), + self::PENDING => __('Pending'), + self::BANNED => __('Banned'), + }; } - public static function getMessageFromKey(string $key): string + /** + * What a user with this status is told when they can't use their account + */ + public function message(): string { - return self::messages()[$key] ?? ''; + return match ($this) { + self::APPROVED => __('Your account is approved.'), + self::PENDING => __('Your account needs to be approved before you can access it.'), + self::BANNED => __('Your account has been banned.'), + }; } - public function getColor(): string + public function color(): string { - return match($this) { + return match ($this) { self::APPROVED => 'success', self::PENDING => 'info', - self::BANNED => 'danger' + self::BANNED => 'danger', }; } + + /** @return array */ + public static function labels(): array + { + $labels = []; + + foreach (self::cases() as $case) { + $labels[$case->value] = $case->label(); + } + + return $labels; + } } diff --git a/src/Events/AccountDeleted.php b/src/Events/AccountDeleted.php new file mode 100644 index 0000000..d3d97fb --- /dev/null +++ b/src/Events/AccountDeleted.php @@ -0,0 +1,17 @@ +user = $user; - } -} diff --git a/src/Events/PasswordChanged.php b/src/Events/PasswordChanged.php deleted file mode 100644 index fe83612..0000000 --- a/src/Events/PasswordChanged.php +++ /dev/null @@ -1,38 +0,0 @@ -user = $user; - $this->causer = $causer; - } -} diff --git a/src/Events/PasswordUpdated.php b/src/Events/PasswordUpdated.php new file mode 100644 index 0000000..96435be --- /dev/null +++ b/src/Events/PasswordUpdated.php @@ -0,0 +1,17 @@ + $this->faker->name, - 'email' => $this->faker->unique()->safeEmail, - 'password' => env('DEFAULT_USER_PASSWORD', 'password'), - 'remember_token' => Str::random(10), - 'status' => $this->faker->randomElement(UserStatuses::getKeys()), - 'email_verified_at' => $this->faker->randomElement([now(), null]), - ]; - } - - public function unverified(): UserFactory - { - return $this->state(function (array $attributes) { - return [ - 'email_verified_at' => null, - ]; - }); - } - - public function verified(): UserFactory - { - return $this->state(function (array $attributes) { - return [ - 'email_verified_at' => now(), - ]; - }); - } - - public function active(): UserFactory - { - return $this->state(function (array $attributes) { - return [ - 'status' => UserStatuses::APPROVED, - 'email_verified_at' => now(), - ]; - }); - } - - public function requirePasswordUpdate(): UserFactory - { - return $this->state(function (array $attributes) { - return [ - 'require_password_update' => true, - ]; - }); - } - - public function banned(): UserFactory - { - return $this->state(function (array $attributes) { - return [ - 'status' => UserStatuses::BANNED, - ]; - }); - } - - public function pending(): UserFactory - { - return $this->state(function (array $attributes) { - return [ - 'status' => UserStatuses::PENDING, - ]; - }); - } -} diff --git a/src/Http/Controllers/AccountStatusController.php b/src/Http/Controllers/AccountStatusController.php new file mode 100644 index 0000000..ffdd763 --- /dev/null +++ b/src/Http/Controllers/AccountStatusController.php @@ -0,0 +1,30 @@ +userType($request); + $user = $this->user($request); + $message = AccountState::inactiveMessage($user); + + if ($message === null) { + return redirect()->intended($type->homeUrl($user)); + } + + return $this->render($request, 'account-inactive', [ + 'message' => $message, + ]); + } +} diff --git a/src/Http/Controllers/Auth/ConfirmPasswordController.php b/src/Http/Controllers/Auth/ConfirmPasswordController.php deleted file mode 100644 index 3d442ff..0000000 --- a/src/Http/Controllers/Auth/ConfirmPasswordController.php +++ /dev/null @@ -1,80 +0,0 @@ -applyMiddlewares(); - } - - /** - * Display the password confirmation view. - */ - public function showConfirmForm() - { - return $this->getConfirmForm(); - } - - /** - * Reset the password confirmation timeout. - * - * @return void - */ - protected function resetPasswordConfirmationTimeout(Request $request) - { - $request->session()->put($this->resetPasswordConfirmationTimeoutKey(), time()); - } - - /** - * Get the password confirmation validation rules. - * - * @return array - */ - protected function rules() - { - return [ - 'password' => 'required|current_password', - ]; - } - - /** - * Get the password confirmation timeout key. - */ - public function resetPasswordConfirmationTimeoutKey(): string - { - return $this->guardName() . '.auth.password_confirmed_at'; - } - - /** - * Apply middlewares for the controller. Used in the constructor. - * Helps with applying/changing applied middlewares for the controller. - */ - public function applyMiddlewares(): void - { - $this->middleware('auth:' . $this->guardName()); - } -} diff --git a/src/Http/Controllers/Auth/ForgotPasswordController.php b/src/Http/Controllers/Auth/ForgotPasswordController.php deleted file mode 100644 index 1d8a15d..0000000 --- a/src/Http/Controllers/Auth/ForgotPasswordController.php +++ /dev/null @@ -1,43 +0,0 @@ -applyMiddlewares(); - } - - /** - * Get the broker to be used during password reset. - * - * @return \Illuminate\Contracts\Auth\PasswordBroker - */ - public function broker(): PasswordBroker - { - return $this->getBroker(); - } - - /** - * Display the form to request a password reset link. - */ - public function showLinkRequestForm() - { - return $this->getPasswordResetForm(); - } -} diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php deleted file mode 100644 index 67fb3b7..0000000 --- a/src/Http/Controllers/Auth/LoginController.php +++ /dev/null @@ -1,69 +0,0 @@ -applyMiddlewares(); - } - - /** - * Show the application's login form. - */ - public function showLoginForm() - { - return $this->getLoginForm(); - } - - /** - * Get the guard to be used during authentication. - */ - public function guard(): StatefulGuard - { - return $this->getGuard(); - } - - /** - * The user has logged out of the application. - * - * @return mixed - */ - protected function loggedOut(Request $request) - { - return redirect()->to($this->redirectPath()); - } - - /** - * Apply middlewares for the controller. Used in the constructor. - * Helps with applying/changing applied middlewares for the controller. - */ - public function applyMiddlewares(): void - { - $this->middleware('guest:' . $this->guardName())->except('logout'); - } -} diff --git a/src/Http/Controllers/Auth/RegisterController.php b/src/Http/Controllers/Auth/RegisterController.php deleted file mode 100644 index 428b4ce..0000000 --- a/src/Http/Controllers/Auth/RegisterController.php +++ /dev/null @@ -1,83 +0,0 @@ -applyMiddlewares(); - } - - public function applyMiddlewares(): void - { - $this->middleware('guest:' . $this->guardName()); - } - - /** - * Get a validator for an incoming registration request. - * - * @return \Illuminate\Contracts\Validation\Validator - */ - protected function validator(array $data) - { - $user_class = $this->userClass(); - - return Validator::make($data, [ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', Rule::unique((new $user_class())->getTable(), 'email')], - 'password' => ['required', 'string', Password::min(8), 'confirmed'], - ]); - } - - /** - * Create a new user instance after a valid registration. - */ - public function create(array $data) - { - $class = $this->userClass(); - - $user = new $class(); - - $user->name = $data['name']; - $user->email = $data['email']; - $user->password = $data['password']; - - $user->save(); - - return $user; - } - - /** - * Get the post register / login redirect path. - * - * @return string - */ - public function redirectPath() - { - return with($this->determinePathForRedirectUsing())->homeUrl(); - } -} diff --git a/src/Http/Controllers/Auth/ResetPasswordController.php b/src/Http/Controllers/Auth/ResetPasswordController.php deleted file mode 100644 index b28fa32..0000000 --- a/src/Http/Controllers/Auth/ResetPasswordController.php +++ /dev/null @@ -1,89 +0,0 @@ -getBroker(); - } - - /** - * Define the guard - * - * @return mixed - */ - public function guard(): StatefulGuard - { - return $this->getGuard(); - } - - /** - * Set the user's password. - * - * @param CanResetPassword $user - * @param string $password - * @return void - */ - protected function setUserPassword($user, $password) - { - $user->password = $password; - } - - /** - * Get the view name for the showResetForm method - */ - abstract public function getResetFormViewName(): string; - - /** - * Display the password reset view for the given token. - * - * If no token is present, display the link request form. - * - * @param string|null $token - * @return Factory|View - */ - public function showResetForm(Request $request, $token = null) - { - return view($this->getResetFormViewName())->with( - ['token' => $token, 'email' => $request->email] - ); - } - - protected function resetPassword($user, $password) - { - // reset login attempts - $user->login_attempts = null; - - $user->markAsPasswordReset(); - - $this->traitResetPassword($user, $password); - } -} diff --git a/src/Http/Controllers/Auth/UpdatePasswordController.php b/src/Http/Controllers/Auth/UpdatePasswordController.php deleted file mode 100644 index 5fea3f0..0000000 --- a/src/Http/Controllers/Auth/UpdatePasswordController.php +++ /dev/null @@ -1,69 +0,0 @@ -applyMiddlewares(); - } - - public function applyMiddlewares(): void - { - $this->middleware([ - 'auth:' . $this->guardName(), - 'active:' . $this->guardName(), - 'password-update-required:' . $this->guardName() - ]); - } - - /** - * Define the guard - */ - protected function guard(): StatefulGuard - { - return $this->getGuard(); - } - - /** - * The user broker - */ - public function broker(): PasswordBroker - { - return $this->getBroker(); - } - - /** - * Show the password update form - */ - public function showPasswordUpdateForm() - { - return $this->getPasswordUpdateForm(); - } -} diff --git a/src/Http/Controllers/Auth/VerificationController.php b/src/Http/Controllers/Auth/VerificationController.php deleted file mode 100644 index c803072..0000000 --- a/src/Http/Controllers/Auth/VerificationController.php +++ /dev/null @@ -1,91 +0,0 @@ -applyMiddlewares(); - } - - /** - * Show verified message - * - * @param string $message - * @return Response|View - */ - public function showEmailVerificationForm(Request $request, User $user, $message) - { - return $this->getEmailVerificationView()->with(compact('user', 'message')); - } - - /** - * Show verification result message - * - * @param null $data - * @param null $errors - * @return Response|View - */ - public function showVerificationResult(Request $request, $data = null, $errors = null) - { - return $this->getVerificationResultView() - ->with($data) - ->withErrors($errors); - } - - public function applyMiddlewares(): void - { - $this->middleware('auth:' . $this->guardName()); - $this->middleware('inactive:' . $this->guardName())->except('verify'); - $this->middleware('signed')->only('verify'); - $this->middleware('throttle:6,1')->only('verify', 'resend'); - $this->middleware('needs-verification')->except('show'); - } - - /** - * Get the post register / login redirect path. - */ - public function redirectPath(): string - { - return $this->determinePathForRedirectUsing() - ->emailVerificationRedirectUrl(); - } -} diff --git a/src/Http/Controllers/AuthBaseController.php b/src/Http/Controllers/AuthBaseController.php deleted file mode 100644 index cb0a984..0000000 --- a/src/Http/Controllers/AuthBaseController.php +++ /dev/null @@ -1,15 +0,0 @@ -userType($request); + + return $this->render($request, 'login', [ + 'can_reset_password' => $type->hasFeature(UserType::PASSWORD_RESET) && $type->hasRoute('password.request'), + 'can_register' => $type->hasFeature(UserType::REGISTRATION) && $type->hasRoute('register'), + 'can_use_passkeys' => $type->hasFeature(UserType::PASSKEYS) && $type->hasRoute('passkey.login'), + ]); + } + + public function store(LoginRequest $request, AttemptLogin $attempt): Response + { + return $attempt($request, $this->userType($request)); + } + + public function destroy(Request $request): Response + { + $type = $this->userType($request); + + $type->auth()->logout(); + + $this->endSession($request, $type); + + return app(LogoutResponse::class)->toResponse($request); + } + + /** + * Throw the session away unless another user type is still signed in on it + */ + protected function endSession(Request $request, UserType $type): void + { + if (! $request->hasSession()) { + return; + } + + foreach (UserTypes::all() as $other) { + if ($other->guardName() !== $type->guardName() && $other->auth()->check()) { + $request->session()->regenerate(); + $request->session()->regenerateToken(); + + return; + } + } + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } +} diff --git a/src/Http/Controllers/BrowserSessionsController.php b/src/Http/Controllers/BrowserSessionsController.php new file mode 100644 index 0000000..d2a9e14 --- /dev/null +++ b/src/Http/Controllers/BrowserSessionsController.php @@ -0,0 +1,30 @@ +user($request); + + if (method_exists($user, 'deleteOtherSessionRecords')) { + $user->deleteOtherSessionRecords(); + } + + $message = __('You have been logged out of your other browser sessions.'); + + if ($request->wantsJson()) { + return new JsonResponse(['message' => $message]); + } + + return back()->with('status', $message); + } +} diff --git a/src/Http/Controllers/ConfirmablePasswordController.php b/src/Http/Controllers/ConfirmablePasswordController.php new file mode 100644 index 0000000..31bcb56 --- /dev/null +++ b/src/Http/Controllers/ConfirmablePasswordController.php @@ -0,0 +1,54 @@ +userType($request); + + return $this->render($request, 'confirm-password', [ + 'can_use_passkeys' => $type->hasFeature(UserType::PASSKEYS) + && $type->hasRoute('passkey.confirm') + && method_exists($this->user($request), 'hasPasskeysEnabled') + && $this->user($request)->hasPasskeysEnabled(), + ]); + } + + public function store(Request $request): Response + { + $type = $this->userType($request); + + $request->validate([ + 'password' => ['required', 'string'], + ]); + + $confirmed = $type->auth()->getProvider()->validateCredentials($this->user($request), [ + 'password' => (string) $request->input('password'), + ]); + + if (! $confirmed) { + throw ValidationException::withMessages([ + 'password' => __('The provided password is incorrect.'), + ]); + } + + RequirePassword::markConfirmed($request, $type); + + if ($request->wantsJson()) { + return new JsonResponse('', 201); + } + + return redirect()->intended($type->homeUrl($this->user($request))); + } +} diff --git a/src/Http/Controllers/Controller.php b/src/Http/Controllers/Controller.php new file mode 100644 index 0000000..7ac847c --- /dev/null +++ b/src/Http/Controllers/Controller.php @@ -0,0 +1,47 @@ +userType($request)->auth()->user(); + } + + /** + * @param array $data + */ + protected function render(Request $request, string $page, array $data = [], string $layout = 'auth'): View|Responsable + { + return app(ViewRenderer::class)->render($this->userType($request), $page, $data, $layout); + } + + protected function backWithStatus(string $status, ?string $message = null): mixed + { + return back()->with('status', $status)->with('status_message', $message); + } +} diff --git a/src/Http/Controllers/EmailVerificationNotificationController.php b/src/Http/Controllers/EmailVerificationNotificationController.php new file mode 100644 index 0000000..91a4d22 --- /dev/null +++ b/src/Http/Controllers/EmailVerificationNotificationController.php @@ -0,0 +1,32 @@ +userType($request); + $user = $this->user($request); + + if ($user->hasVerifiedEmail()) { + return $request->wantsJson() + ? new JsonResponse('', 204) + : redirect()->intended($type->homeUrl($user)); + } + + $user->sendEmailVerificationNotification(); + + $message = __('A new verification link has been sent to your email address.'); + + if ($request->wantsJson()) { + return new JsonResponse(['message' => $message], 202); + } + + return back()->with('status', $message); + } +} diff --git a/src/Http/Controllers/EmailVerificationPromptController.php b/src/Http/Controllers/EmailVerificationPromptController.php new file mode 100644 index 0000000..083b7bc --- /dev/null +++ b/src/Http/Controllers/EmailVerificationPromptController.php @@ -0,0 +1,25 @@ +userType($request); + $user = $this->user($request); + + if ($user->hasVerifiedEmail()) { + return redirect()->intended($type->homeUrl($user)); + } + + return $this->render($request, 'verify-email', [ + 'email' => $user->getEmailForVerification(), + ]); + } +} diff --git a/src/Http/Controllers/NewPasswordController.php b/src/Http/Controllers/NewPasswordController.php new file mode 100644 index 0000000..2141e7e --- /dev/null +++ b/src/Http/Controllers/NewPasswordController.php @@ -0,0 +1,70 @@ +userType($request); + + return $this->render($request, 'reset-password', [ + 'token' => $token, + 'email' => (string) $request->query($type->usernameColumn(), ''), + ]); + } + + public function store(Request $request, ResetsUserPasswords $resetter): Response + { + $type = $this->userType($request); + $username = $type->usernameColumn(); + + $request->validate([ + 'token' => ['required', 'string'], + $username => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]); + + $status = $type->broker()->reset( + [ + $username => mb_strtolower(trim((string) $request->input($username))), + 'password' => $request->input('password'), + 'password_confirmation' => $request->input('password_confirmation'), + 'token' => $request->input('token'), + ], + function ($user) use ($request, $resetter, $type) { + $resetter->reset($type, $user, $request->all()); + + $user->setRememberToken(Str::random(60)); + $user->save(); + + event(new PasswordReset($user)); + }, + ); + + if ($status !== PasswordBroker::PASSWORD_RESET) { + throw ValidationException::withMessages([ + $username => __('This password reset link is invalid or has expired.'), + ]); + } + + $message = __('Your password has been reset.'); + + if ($request->wantsJson()) { + return new JsonResponse(['message' => $message]); + } + + return redirect()->to($type->route('login'))->with('status', $message); + } +} diff --git a/src/Http/Controllers/PasskeyConfirmationController.php b/src/Http/Controllers/PasskeyConfirmationController.php new file mode 100644 index 0000000..69bdea1 --- /dev/null +++ b/src/Http/Controllers/PasskeyConfirmationController.php @@ -0,0 +1,41 @@ +user($request)); + + $request->session()->put('passkey.verification_options', WebAuthn::toJson($options)); + + return new JsonResponse([ + 'options' => WebAuthn::toBrowserArray($options), + ]); + } + + public function store(PasskeyVerificationRequest $request, VerifyPasskey $verify): JsonResponse + { + $type = $this->userType($request); + + $verify($request->credential(), $request->verificationOptions(), $this->user($request)); + + RequirePassword::markConfirmed($request, $type); + + return new JsonResponse([ + 'redirect' => redirect()->getIntendedUrl() ?? $type->homeUrl($this->user($request)), + ]); + } +} diff --git a/src/Http/Controllers/PasskeyLoginController.php b/src/Http/Controllers/PasskeyLoginController.php new file mode 100644 index 0000000..af2853b --- /dev/null +++ b/src/Http/Controllers/PasskeyLoginController.php @@ -0,0 +1,52 @@ +session()->put('passkey.verification_options', WebAuthn::toJson($options)); + + return new JsonResponse([ + 'options' => WebAuthn::toBrowserArray($options), + ]); + } + + public function store(PasskeyVerificationRequest $request, VerifyPasskey $verify, StartAuthenticatedSession $start_session): Response + { + $type = $this->userType($request); + + $passkey = $verify($request->credential(), $request->verificationOptions()); + + $user = $passkey->user; + + // A passkey of another user type can't log in here + if (! $type->owns($user)) { + throw InvalidPasskeyException::make(__('Passkey not recognized. It may have been removed from your account.')); + } + + if (! Passkeys::allowsLogin($request, $passkey)) { + throw InvalidPasskeyException::make(__('Unable to sign in with this account.')); + } + + return $start_session($request, $type, $user, $request->remember(), skip_two_factor: true); + } +} diff --git a/src/Http/Controllers/PasskeysController.php b/src/Http/Controllers/PasskeysController.php new file mode 100644 index 0000000..9923523 --- /dev/null +++ b/src/Http/Controllers/PasskeysController.php @@ -0,0 +1,63 @@ +user($request)); + + $request->session()->put('passkey.registration_options', WebAuthn::toJson($options)); + + return new JsonResponse([ + 'options' => WebAuthn::toBrowserArray($options), + ]); + } + + public function store(PasskeyRegistrationRequest $request, StorePasskey $store): JsonResponse + { + $passkey = $store( + $this->user($request), + $request->string('name')->toString(), + $request->credential(), + $request->registrationOptions(), + ); + + return new JsonResponse([ + 'message' => __('Passkey added.'), + 'passkey' => $passkey instanceof Passkey ? $passkey->toDisplayArray() : ['id' => $passkey->id, 'name' => $passkey->name], + ], 201); + } + + public function destroy(Request $request, string $passkey, DeletePasskey $delete): Response + { + $user = $this->user($request); + + $model = $user->passkeys()->whereKey($passkey)->firstOrFail(); + + $delete($user, $model); + + $message = __('Passkey removed.'); + + if ($request->wantsJson()) { + return new JsonResponse(['message' => $message]); + } + + return back()->with('status', $message); + } +} diff --git a/src/Http/Controllers/PasswordController.php b/src/Http/Controllers/PasswordController.php new file mode 100644 index 0000000..637d777 --- /dev/null +++ b/src/Http/Controllers/PasswordController.php @@ -0,0 +1,24 @@ +update($this->userType($request), $this->user($request), $request->all()); + + $message = __('Your password has been updated.'); + + if ($request->wantsJson()) { + return new JsonResponse(['message' => $message]); + } + + return back()->with('status', $message); + } +} diff --git a/src/Http/Controllers/PasswordResetLinkController.php b/src/Http/Controllers/PasswordResetLinkController.php new file mode 100644 index 0000000..5c96ad4 --- /dev/null +++ b/src/Http/Controllers/PasswordResetLinkController.php @@ -0,0 +1,48 @@ +render($request, 'forgot-password'); + } + + public function store(Request $request): Response + { + $type = $this->userType($request); + $username = $type->usernameColumn(); + + $request->validate([ + $username => ['required', 'string', 'email'], + ]); + + $status = $type->broker()->sendResetLink([ + $username => mb_strtolower(trim((string) $request->input($username))), + ]); + + if ($status === PasswordBroker::RESET_THROTTLED) { + throw ValidationException::withMessages([ + $username => __('Please wait before retrying.'), + ]); + } + + // The same answer whether or not the account exists, so the form can't be used to find accounts + $message = __('If an account with that email exists, we have emailed a password reset link.'); + + if ($request->wantsJson()) { + return new JsonResponse(['message' => $message]); + } + + return back()->with('status', $message); + } +} diff --git a/src/Http/Controllers/ProfileController.php b/src/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..dc0f111 --- /dev/null +++ b/src/Http/Controllers/ProfileController.php @@ -0,0 +1,72 @@ +userType($request); + $user = $this->user($request); + + return $this->render($request, 'settings.profile', [ + 'profile' => [ + 'name' => $user->getAttribute('name'), + $type->usernameColumn() => $user->getAttribute($type->usernameColumn()), + ], + 'must_verify_email' => $type->hasFeature(UserType::EMAIL_VERIFICATION) + && $user instanceof MustVerifyEmail + && ! $user->hasVerifiedEmail(), + 'can_delete_account' => $type->hasFeature(UserType::DELETE_ACCOUNT) && $type->hasRoute('profile.destroy'), + ], 'account'); + } + + public function update(Request $request, UpdatesUserProfileInformation $updater): Response + { + $updater->update($this->userType($request), $this->user($request), $request->all()); + + $message = __('Your profile has been updated.'); + + if ($request->wantsJson()) { + return new JsonResponse(['message' => $message]); + } + + return back()->with('status', $message); + } + + public function destroy(Request $request, DeletesUsers $deleter): Response + { + $type = $this->userType($request); + $user = $this->user($request); + + $request->validate([ + 'password' => ['required', 'string', "current_password:{$type->guardName()}"], + ]); + + $type->auth()->logout(); + + $deleter->delete($type, $user); + + if ($request->hasSession()) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + if ($request->wantsJson()) { + return new JsonResponse('', 204); + } + + return redirect()->to($type->route('login')) + ->with('status', __('Your account has been deleted.')); + } +} diff --git a/src/Http/Controllers/RegisteredUserController.php b/src/Http/Controllers/RegisteredUserController.php new file mode 100644 index 0000000..5da0ccf --- /dev/null +++ b/src/Http/Controllers/RegisteredUserController.php @@ -0,0 +1,33 @@ +render($request, 'register'); + } + + public function store(Request $request, CreatesNewUsers $creator, LoginUser $login): Response + { + $type = $this->userType($request); + + $user = $creator->create($type, $request->all()); + + event(new Registered($user)); + + $login($request, $type, $user); + + return app(RegisterResponse::class)->toResponse($request); + } +} diff --git a/src/Http/Controllers/RequiredPasswordController.php b/src/Http/Controllers/RequiredPasswordController.php new file mode 100644 index 0000000..005f89a --- /dev/null +++ b/src/Http/Controllers/RequiredPasswordController.php @@ -0,0 +1,39 @@ +userType($request); + $user = $this->user($request); + + if (! $user instanceof RequiresPasswordUpdate || ! $user->requiresPasswordUpdate()) { + return redirect()->intended($type->homeUrl($user)); + } + + return $this->render($request, 'update-required-password'); + } + + public function update(Request $request, UpdatesUserPasswords $updater): RedirectResponse + { + $type = $this->userType($request); + $user = $this->user($request); + + $updater->update($type, $user, $request->all()); + + return redirect()->intended($type->homeUrl($user)) + ->with('status', __('Your password has been updated.')); + } +} diff --git a/src/Http/Controllers/SecurityController.php b/src/Http/Controllers/SecurityController.php new file mode 100644 index 0000000..6cfbdfe --- /dev/null +++ b/src/Http/Controllers/SecurityController.php @@ -0,0 +1,98 @@ +userType($request); + $user = $this->user($request); + + return $this->render($request, 'settings.security', [ + 'two_factor' => $this->twoFactor($type, $user), + 'passkeys' => $this->passkeys($type, $user), + 'sessions' => $this->sessions($type, $user), + 'api_tokens' => $this->apiTokens($type, $user), + ], 'account'); + } + + /** @return array|null */ + protected function twoFactor(UserType $type, mixed $user): ?array + { + if (! $type->hasTwoFactor() || ! $user instanceof TwoFactorAuthenticatable) { + return null; + } + + $pending = ! is_null($user->two_factor_secret) && is_null($user->two_factor_confirmed_at); + + return [ + 'enabled' => $user->hasEnabledTwoFactorAuthentication(), + 'pending' => $pending, + 'required' => $type->requiresTwoFactor(), + 'qr_code' => $pending ? $user->twoFactorQrCodeSvg() : null, + 'secret_key' => $pending ? $user->twoFactorSecret() : null, + 'recovery_codes' => session('two_factor_status') === 'two-factor-authentication-confirmed' + || session('two_factor_status') === 'recovery-codes-generated' + ? $user->recoveryCodes() + : null, + ]; + } + + /** @return array>|null */ + protected function passkeys(UserType $type, mixed $user): ?array + { + if (! $type->hasFeature(UserType::PASSKEYS) || ! method_exists($user, 'passkeys')) { + return null; + } + + return $user->passkeys() + ->latest() + ->get() + ->map(fn ($passkey) => method_exists($passkey, 'toDisplayArray') ? $passkey->toDisplayArray() : ['id' => $passkey->id, 'name' => $passkey->name]) + ->all(); + } + + /** @return array>|null */ + protected function sessions(UserType $type, mixed $user): ?array + { + if (! $type->hasFeature(UserType::SESSIONS) || config('session.driver') !== 'multi_auth_database' || ! method_exists($user, 'sessions')) { + return null; + } + + return $user->sessions() + ->orderByDesc('last_activity') + ->get() + ->map(fn (Session $session) => $session->toDisplayArray()) + ->all(); + } + + /** @return array>|null */ + protected function apiTokens(UserType $type, mixed $user): ?array + { + if (! $type->hasFeature(UserType::API_TOKENS) || ! $type->hasRoute('api-tokens.store') || ! method_exists($user, 'tokens')) { + return null; + } + + return $user->tokens() + ->latest() + ->get() + ->map(fn ($token) => [ + 'id' => $token->id, + 'name' => $token->name, + 'last_used_at' => $token->last_used_at?->diffForHumans(), + 'created_at' => $token->created_at?->diffForHumans(), + ]) + ->all(); + } +} diff --git a/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php b/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php new file mode 100644 index 0000000..dca3f2b --- /dev/null +++ b/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php @@ -0,0 +1,134 @@ +userType($request); + + if (! $this->challengedUser($request, $type)) { + return redirect()->to($type->route('login')); + } + + return $this->render($request, 'two-factor-challenge'); + } + + public function store(Request $request, TwoFactorProvider $provider, LoginUser $login): Response + { + $type = $this->userType($request); + $user = $this->challengedUser($request, $type); + + if (! $user) { + throw ValidationException::withMessages([ + 'code' => __('Your login has expired. Please log in again.'), + ]); + } + + $request->validate([ + 'code' => ['nullable', 'string'], + 'recovery_code' => ['nullable', 'string'], + ]); + + $this->ensureIsNotRateLimited($request, $type, $user); + + if (! $this->passes($request, $provider, $user)) { + RateLimiter::hit($this->throttleKey($type, $user)); + + TwoFactorAuthenticationFailed::dispatch($user); + + $field = $request->filled('recovery_code') ? 'recovery_code' : 'code'; + + throw ValidationException::withMessages([ + $field => $field === 'code' + ? __('The provided two-factor authentication code was invalid.') + : __('The provided two-factor recovery code was invalid.'), + ]); + } + + RateLimiter::clear($this->throttleKey($type, $user)); + + $remember = (bool) ($request->session()->get(StartAuthenticatedSession::CHALLENGE_SESSION_KEY)['remember'] ?? false); + + $login($request, $type, $user, $remember); + + return app(LoginResponse::class)->toResponse($request); + } + + protected function passes(Request $request, TwoFactorProvider $provider, TwoFactorAuthenticatable $user): bool + { + if ($request->filled('recovery_code')) { + $code = trim((string) $request->input('recovery_code')); + + foreach ($user->recoveryCodes() as $recovery_code) { + if (hash_equals($recovery_code, $code)) { + $user->replaceRecoveryCode($recovery_code); + + return true; + } + } + + return false; + } + + $secret = $user->twoFactorSecret(); + + return $secret && $request->filled('code') && $provider->verify($secret, (string) $request->input('code')); + } + + /** + * The user waiting for their second step, if they belong to this user type + */ + protected function challengedUser(Request $request, UserType $type): ?Authenticatable + { + $challenge = $request->session()->get(StartAuthenticatedSession::CHALLENGE_SESSION_KEY); + + if (! $challenge || ($challenge['user_type'] ?? null) !== $type->name()) { + return null; + } + + $user = $type->auth()->getProvider()->retrieveById($challenge['id']); + + return $user instanceof TwoFactorAuthenticatable ? $user : null; + } + + protected function ensureIsNotRateLimited(Request $request, UserType $type, Authenticatable $user): void + { + $key = $this->throttleKey($type, $user); + + if (! RateLimiter::tooManyAttempts($key, (int) config('javaabu-auth.rate_limits.two_factor', 5))) { + return; + } + + throw ValidationException::withMessages([ + 'code' => __('Too many attempts. Please try again in :seconds seconds.', [ + 'seconds' => RateLimiter::availableIn($key), + ]), + ])->status(429); + } + + protected function throttleKey(UserType $type, Authenticatable $user): string + { + return "auth.two_factor|{$type->name()}|{$user->getAuthIdentifier()}"; + } +} diff --git a/src/Http/Controllers/TwoFactorAuthenticationController.php b/src/Http/Controllers/TwoFactorAuthenticationController.php new file mode 100644 index 0000000..f295181 --- /dev/null +++ b/src/Http/Controllers/TwoFactorAuthenticationController.php @@ -0,0 +1,111 @@ +twoFactorUser($request), $request->boolean('force')); + + return $this->respond($request, 'two-factor-authentication-enabled', __('Scan the QR code with your authenticator app, then enter the code it shows.')); + } + + public function confirm(Request $request, ConfirmTwoFactorAuthentication $confirm): Response + { + $request->validate([ + 'code' => ['required', 'string'], + ]); + + $confirm($this->twoFactorUser($request), (string) $request->input('code')); + + return $this->respond($request, 'two-factor-authentication-confirmed', __('Two-factor authentication is now on.')); + } + + public function destroy(Request $request, DisableTwoFactorAuthentication $disable): Response + { + if ($this->userType($request)->requiresTwoFactor()) { + abort(403, __('Two-factor authentication is required for your account.')); + } + + $disable($this->twoFactorUser($request)); + + return $this->respond($request, 'two-factor-authentication-disabled', __('Two-factor authentication is now off.')); + } + + public function qrCode(Request $request): JsonResponse + { + $user = $this->twoFactorUser($request); + + if (is_null($user->two_factor_secret)) { + return new JsonResponse([]); + } + + return new JsonResponse([ + 'svg' => $user->twoFactorQrCodeSvg(), + 'url' => $user->twoFactorQrCodeUrl(), + ]); + } + + public function secretKey(Request $request): JsonResponse + { + $user = $this->twoFactorUser($request); + + if (is_null($user->two_factor_secret)) { + abort(404, __('Two-factor authentication has not been enabled.')); + } + + return new JsonResponse(['secret_key' => $user->twoFactorSecret()]); + } + + public function recoveryCodes(Request $request): JsonResponse + { + $user = $this->twoFactorUser($request); + + if (is_null($user->two_factor_secret) || is_null($user->two_factor_recovery_codes)) { + return new JsonResponse([]); + } + + return new JsonResponse($user->recoveryCodes()); + } + + public function regenerateRecoveryCodes(Request $request, GenerateNewRecoveryCodes $generate): Response + { + $generate($this->twoFactorUser($request)); + + return $this->respond($request, 'recovery-codes-generated', __('New recovery codes have been generated.')); + } + + protected function twoFactorUser(Request $request): TwoFactorAuthenticatable + { + $user = $this->user($request); + + if (! $user instanceof TwoFactorAuthenticatable) { + throw new RuntimeException('Add the TwoFactorAuthenticatable trait and contract to ' . $user::class . '.'); + } + + return $user; + } + + protected function respond(Request $request, string $status, string $message): Response + { + if ($request->wantsJson()) { + return new JsonResponse(['status' => $status, 'message' => $message]); + } + + return back()->with('status', $message)->with('two_factor_status', $status); + } +} diff --git a/src/Http/Controllers/VerifyEmailController.php b/src/Http/Controllers/VerifyEmailController.php new file mode 100644 index 0000000..7bf9cc1 --- /dev/null +++ b/src/Http/Controllers/VerifyEmailController.php @@ -0,0 +1,37 @@ +userType($request); + $user = $this->user($request); + + if (! hash_equals((string) $user->getKey(), $id)) { + throw new AuthorizationException(__('This verification link is invalid.')); + } + + if (! hash_equals(sha1($user->getEmailForVerification()), $hash)) { + throw new AuthorizationException(__('This verification link is invalid.')); + } + + if (! $user->hasVerifiedEmail() && $user->markEmailAsVerified()) { + event(new Verified($user)); + } + + if ($request->wantsJson()) { + return new JsonResponse('', 204); + } + + return redirect()->intended($type->homeUrl($user)) + ->with('status', __('Your email address has been verified.')); + } +} diff --git a/src/Http/Middleware/Authenticate.php b/src/Http/Middleware/Authenticate.php new file mode 100644 index 0000000..301b6e9 --- /dev/null +++ b/src/Http/Middleware/Authenticate.php @@ -0,0 +1,64 @@ +attributes->set(UserTypeRegistry::ROUTE_ACTION, $user_type->name()); + + $this->authenticate($request, $user_type); + + if ($mode === 'basic') { + return $next($request); + } + + $checks = [ + EnsureAccountIsActive::class, + EnsureEmailIsVerified::class, + EnsurePasswordIsUpdated::class, + EnsureTwoFactorIsEnabled::class, + ]; + + $pipeline = array_reduce( + array_reverse($checks), + fn (Closure $next, string $check) => fn (Request $request) => app($check)->handle($request, $next, $user_type->name()), + $next, + ); + + return $pipeline($request); + } + + protected function authenticate(Request $request, UserType $type): void + { + $guard = $type->auth(); + + if ($guard->check()) { + auth()->shouldUse($type->guardName()); + + return; + } + + throw new AuthenticationException( + 'Unauthenticated.', + [$type->guardName()], + $request->expectsJson() || ! $type->hasRoute('login') ? null : $type->route('login'), + ); + } +} diff --git a/src/Http/Middleware/EnsureAccountIsActive.php b/src/Http/Middleware/EnsureAccountIsActive.php new file mode 100644 index 0000000..b912b06 --- /dev/null +++ b/src/Http/Middleware/EnsureAccountIsActive.php @@ -0,0 +1,32 @@ +auth()->user()); + + if ($message === null) { + return $next($request); + } + + if ($request->expectsJson() || ! $user_type->hasRoute('account.inactive')) { + throw new AuthorizationException($message); + } + + return redirect()->to($user_type->route('account.inactive')); + } +} diff --git a/src/Http/Middleware/EnsureEmailIsVerified.php b/src/Http/Middleware/EnsureEmailIsVerified.php new file mode 100644 index 0000000..f0300e4 --- /dev/null +++ b/src/Http/Middleware/EnsureEmailIsVerified.php @@ -0,0 +1,37 @@ +auth()->user(); + + if (! $user_type->hasFeature(UserType::EMAIL_VERIFICATION)) { + return $next($request); + } + + if (! $user instanceof MustVerifyEmail || $user->hasVerifiedEmail()) { + return $next($request); + } + + if ($request->expectsJson() || ! $user_type->hasRoute('verification.notice')) { + abort(403, __('Your email address is not verified.')); + } + + return redirect()->guest($user_type->route('verification.notice')); + } +} diff --git a/src/Http/Middleware/EnsurePasswordIsUpdated.php b/src/Http/Middleware/EnsurePasswordIsUpdated.php new file mode 100644 index 0000000..f75a36c --- /dev/null +++ b/src/Http/Middleware/EnsurePasswordIsUpdated.php @@ -0,0 +1,31 @@ +auth()->user(); + + if (! $user instanceof RequiresPasswordUpdate || ! $user->requiresPasswordUpdate()) { + return $next($request); + } + + if ($request->expectsJson() || ! $user_type->hasRoute('password.required')) { + abort(403, __('You need to choose a new password.')); + } + + return redirect()->guest($user_type->route('password.required')); + } +} diff --git a/src/Http/Middleware/EnsureTwoFactorIsEnabled.php b/src/Http/Middleware/EnsureTwoFactorIsEnabled.php new file mode 100644 index 0000000..77be2a5 --- /dev/null +++ b/src/Http/Middleware/EnsureTwoFactorIsEnabled.php @@ -0,0 +1,56 @@ +auth()->user(); + + if (! $user_type->requiresTwoFactor()) { + return $next($request); + } + + if (! $user instanceof TwoFactorAuthenticatable || $user->hasEnabledTwoFactorAuthentication()) { + return $next($request); + } + + foreach (self::ALLOWED_ROUTES as $route) { + if ($request->routeIs($user_type->routeNameFor($route))) { + return $next($request); + } + } + + if ($request->expectsJson() || ! $user_type->hasRoute('security.edit')) { + abort(403, __('You need to set up two-factor authentication.')); + } + + return redirect()->guest($user_type->route('security.edit')) + ->with('status', __('Set up two-factor authentication to continue.')); + } +} diff --git a/src/Http/Middleware/RedirectIfAuthenticated.php b/src/Http/Middleware/RedirectIfAuthenticated.php new file mode 100644 index 0000000..1838744 --- /dev/null +++ b/src/Http/Middleware/RedirectIfAuthenticated.php @@ -0,0 +1,27 @@ +auth()->check()) { + return $request->expectsJson() + ? response()->json(['message' => __('You are already signed in.')], 409) + : redirect()->to($user_type->homeUrl($user_type->auth()->user())); + } + + return $next($request); + } +} diff --git a/src/Http/Middleware/RequirePassword.php b/src/Http/Middleware/RequirePassword.php new file mode 100644 index 0000000..4ab2a63 --- /dev/null +++ b/src/Http/Middleware/RequirePassword.php @@ -0,0 +1,50 @@ +expectsJson()) { + return response()->json(['message' => __('Password confirmation required.')], 423); + } + + return redirect()->guest($user_type->route('password.confirm')); + } + + public static function sessionKey(UserType $type): string + { + return "auth.password_confirmed_at.{$type->name()}"; + } + + public static function markConfirmed(Request $request, UserType $type): void + { + $request->session()->put(static::sessionKey($type), time()); + } + + public static function isConfirmed(Request $request, UserType $type, ?int $timeout = null): bool + { + $timeout ??= (int) config('javaabu-auth.password_timeout', 10800); + $confirmed_at = (int) $request->session()->get(static::sessionKey($type), 0); + + return (time() - $confirmed_at) < $timeout; + } +} diff --git a/src/Http/Middleware/SetUserType.php b/src/Http/Middleware/SetUserType.php new file mode 100644 index 0000000..ffbfd34 --- /dev/null +++ b/src/Http/Middleware/SetUserType.php @@ -0,0 +1,24 @@ +attributes->set(UserTypeRegistry::ROUTE_ACTION, $type->name()); + + return $next($request); + } +} diff --git a/src/Http/Middlewares/RedirectIfActivated.php b/src/Http/Middlewares/RedirectIfActivated.php deleted file mode 100644 index 5c81388..0000000 --- a/src/Http/Middlewares/RedirectIfActivated.php +++ /dev/null @@ -1,39 +0,0 @@ -check()) { - /** @var User $user */ - $user = Auth::guard($guard)->user(); - - if ($user->is_active) { - if (expects_json($request)) { - abort(403, 'Active users not allowed'); - } - - return redirect()->to($user->homeUrl()); - } - } - } - - return $next($request); - } -} diff --git a/src/Http/Middlewares/RedirectIfActivatedAndEmailVerified.php b/src/Http/Middlewares/RedirectIfActivatedAndEmailVerified.php deleted file mode 100644 index 5356b8b..0000000 --- a/src/Http/Middlewares/RedirectIfActivatedAndEmailVerified.php +++ /dev/null @@ -1,39 +0,0 @@ -check()) { - /** @var User $user */ - $user = Auth::guard($guard)->user(); - - if ($user->is_active && $user->hasVerifiedEmail()) { - if (expects_json($request)) { - abort(403, 'Active users with verified emails not allowed'); - } - - return redirect()->to($user->homeUrl()); - } - } - } - - return $next($request); - } -} diff --git a/src/Http/Middlewares/RedirectIfEmailNotVerified.php b/src/Http/Middlewares/RedirectIfEmailNotVerified.php deleted file mode 100644 index e572d07..0000000 --- a/src/Http/Middlewares/RedirectIfEmailNotVerified.php +++ /dev/null @@ -1,33 +0,0 @@ -user() || - ($request->user() instanceof MustVerifyEmail && - ! $request->user()->hasVerifiedEmail())) { - - if (expects_json($request)) { - abort(403, 'Your email address is not verified.'); - } - - return Redirect::to($request->user()->inactiveNoticeUrl()); - } - - return $next($request); - } -} diff --git a/src/Http/Middlewares/RedirectIfEmailVerificationNotNeeded.php b/src/Http/Middlewares/RedirectIfEmailVerificationNotNeeded.php deleted file mode 100644 index 6a07ff6..0000000 --- a/src/Http/Middlewares/RedirectIfEmailVerificationNotNeeded.php +++ /dev/null @@ -1,39 +0,0 @@ -check()) { - /** @var User $user */ - $user = Auth::guard($guard)->user(); - - if (! $user->needsEmailVerification()) { - if (expects_json($request)) { - abort(403, 'Users that do not need email verification not allowed'); - } - - return redirect()->to($user->emailVerificationRedirectUrl()); - } - } - } - - return $next($request); - } -} diff --git a/src/Http/Middlewares/RedirectIfNotActivated.php b/src/Http/Middlewares/RedirectIfNotActivated.php deleted file mode 100644 index bbd2f4e..0000000 --- a/src/Http/Middlewares/RedirectIfNotActivated.php +++ /dev/null @@ -1,42 +0,0 @@ -check()) { - /** @var User $user */ - $user = Auth::guard($guard)->user(); - - if (! $user->is_active) { - if (expects_json($request)) { - throw new AuthorizationException('Account not activated'); - } - - return redirect()->to($user->inactiveNoticeUrl()); - } - } - } - - return $next($request); - } -} diff --git a/src/Http/Requests/LoginRequest.php b/src/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..af5a8c4 --- /dev/null +++ b/src/Http/Requests/LoginRequest.php @@ -0,0 +1,26 @@ +> */ + public function rules(): array + { + $type = UserTypes::fromRequest($this) ?? UserTypes::default(); + + return [ + $type->usernameColumn() => ['required', 'string', 'max:255'], + 'password' => ['required', 'string'], + 'remember' => ['nullable', 'boolean'], + ]; + } +} diff --git a/src/Http/Requests/UsersRequest.php b/src/Http/Requests/UsersRequest.php deleted file mode 100644 index cda3842..0000000 --- a/src/Http/Requests/UsersRequest.php +++ /dev/null @@ -1,100 +0,0 @@ -table_name; - } - - protected function morphClass(): string - { - return $this->morph_class; - } - - abstract protected function editingCurrentUser(): bool; - - abstract protected function getRouteUser(): ?User; - - protected function getUserBeingEdited(): ?User - { - if ($this->editingCurrentUser()) { - return $this->user(); - } - - return $this->getRouteUser(); - } - - protected function baseRules(bool $password_required = true, bool $email_required = true): array - { - $rules = [ - 'email' => ['string', 'email', 'max:255'], - 'name' => ['string', 'max:255'], - 'email_verified' => ['nullable', 'boolean'], - 'password' => ['string', Password::min(8), 'confirmed'], - 'status' => [Rule::in(UserStatuses::getKeys())], - 'avatar' => AllowedMimeTypes::getValidationRule('image'), - 'action' => ['in:approve,ban,mark_pending,update_password'], - 'require_password_update' => ['boolean'], - ]; - - $unique_email = Rule::unique($this->tableName(), 'email'); - - if (! $email_required) { - $rules['email'][] = 'nullable'; - } - - // updating - if ($user = $this->getUserBeingEdited()) { - $unique_email->ignore($user->id); - $rules['password'][] = 'required_if:action,update_password'; - - // current password required - if ($this->editingCurrentUser() && $user->password) { - $rules['current_password'] = [ - 'required_with:password', - 'passcheck:'.$this->tableName().',password,id,'.$user->id, - ]; - } - } else { // creation - if ($password_required) { - $rules['password'][] = 'required'; - } else { - $rules['password'][] = 'nullable'; - } - - if ($email_required) { - $rules['email'][] = 'required'; - } - - $rules['name'][] = 'required'; - } - - $rules['email'][] = $unique_email; - - return $rules; - } - - public function rules(): array - { - return $this->baseRules(); - } -} diff --git a/src/Http/Responses/LoginResponse.php b/src/Http/Responses/LoginResponse.php new file mode 100644 index 0000000..c02ea82 --- /dev/null +++ b/src/Http/Responses/LoginResponse.php @@ -0,0 +1,26 @@ +intended($type->homeUrl($type->auth()->user())); + + if ($request->wantsJson()) { + return new JsonResponse(['two_factor' => false, 'redirect' => $redirect->getTargetUrl()]); + } + + return $redirect; + } +} diff --git a/src/Http/Responses/LogoutResponse.php b/src/Http/Responses/LogoutResponse.php new file mode 100644 index 0000000..95fa122 --- /dev/null +++ b/src/Http/Responses/LogoutResponse.php @@ -0,0 +1,22 @@ +wantsJson()) { + return new JsonResponse('', 204); + } + + $type = UserTypes::fromRequest($request) ?? UserTypes::default(); + + return redirect()->to($type->route('login')); + } +} diff --git a/src/Http/Responses/RegisterResponse.php b/src/Http/Responses/RegisterResponse.php new file mode 100644 index 0000000..d3b46d1 --- /dev/null +++ b/src/Http/Responses/RegisterResponse.php @@ -0,0 +1,22 @@ +wantsJson()) { + return new JsonResponse('', 201); + } + + $type = UserTypes::fromRequest($request) ?? UserTypes::default(); + + return redirect()->intended($type->homeUrl($type->auth()->user())); + } +} diff --git a/src/Http/Responses/TwoFactorChallengeResponse.php b/src/Http/Responses/TwoFactorChallengeResponse.php new file mode 100644 index 0000000..6711957 --- /dev/null +++ b/src/Http/Responses/TwoFactorChallengeResponse.php @@ -0,0 +1,25 @@ +wantsJson()) { + return new JsonResponse(['two_factor' => true, 'redirect' => $type->route('two-factor.login')]); + } + + return redirect()->to($type->route('two-factor.login')); + } +} diff --git a/src/Listeners/RecordFailedLogin.php b/src/Listeners/RecordFailedLogin.php deleted file mode 100644 index 9960bb6..0000000 --- a/src/Listeners/RecordFailedLogin.php +++ /dev/null @@ -1,26 +0,0 @@ -user; - - if ($user) { - // increment the attempts - $user->incrementLoginAttempts(); - $user->save(); - - // log the failed login - activity() - ->causedBy($user) - ->log('failed_login'); - } - } -} diff --git a/src/Listeners/RecordLockout.php b/src/Listeners/RecordLockout.php deleted file mode 100644 index 3b8a2f9..0000000 --- a/src/Listeners/RecordLockout.php +++ /dev/null @@ -1,33 +0,0 @@ -request; - - // log the lockout - activity()->withProperties($request ? $request->all() : []) - ->log('lockout'); - } -} diff --git a/src/Listeners/RecordLogin.php b/src/Listeners/RecordLogin.php deleted file mode 100644 index 7c0bc56..0000000 --- a/src/Listeners/RecordLogin.php +++ /dev/null @@ -1,45 +0,0 @@ -user; - - if ($user) { - // reset login attempts if not already locked out - if (! $user->is_locked_out) { - $user->resetLoginAttempts(); - } - - // save the last_login_at - $user->last_login_at = Carbon::now(); - $user->save(); - - // log the login - activity() - ->causedBy($user) - ->log('login'); - } - } -} diff --git a/src/Listeners/RecordLogout.php b/src/Listeners/RecordLogout.php deleted file mode 100644 index 946af33..0000000 --- a/src/Listeners/RecordLogout.php +++ /dev/null @@ -1,35 +0,0 @@ -user; - - if ($user) { - // log the logout - activity() - ->causedBy($user) - ->log('logout'); - } - } -} diff --git a/src/Listeners/RecordPasswordChanged.php b/src/Listeners/RecordPasswordChanged.php deleted file mode 100644 index aa93e93..0000000 --- a/src/Listeners/RecordPasswordChanged.php +++ /dev/null @@ -1,41 +0,0 @@ -user; - - if ($user) { - // log the logout - $log = activity() - ->performedOn($user); - - - if ($causer = $event->causer) { - $log->causedBy($causer); - } - - $log->log('password_changed'); - } - } -} diff --git a/src/Listeners/RecordPasswordReset.php b/src/Listeners/RecordPasswordReset.php deleted file mode 100644 index 4973874..0000000 --- a/src/Listeners/RecordPasswordReset.php +++ /dev/null @@ -1,35 +0,0 @@ -user; - - if ($user) { - // log the logout - activity() - ->causedBy($user) - ->log('password_reset'); - } - } -} diff --git a/src/Listeners/RecordPasswordResetLinkSent.php b/src/Listeners/RecordPasswordResetLinkSent.php deleted file mode 100644 index effda6e..0000000 --- a/src/Listeners/RecordPasswordResetLinkSent.php +++ /dev/null @@ -1,35 +0,0 @@ -user; - - if ($user) { - // log the logout - activity() - ->causedBy($user) - ->log('password_reset_link_sent'); - } - } -} diff --git a/src/Mail/EmailUpdated.php b/src/Mail/EmailUpdated.php deleted file mode 100644 index 41adeb4..0000000 --- a/src/Mail/EmailUpdated.php +++ /dev/null @@ -1,50 +0,0 @@ -user = $user; - $this->new_email = $new_email; - } - - /** - * Build the message. - * - * @return $this - */ - public function build() - { - $message = (new MailMessage()) - ->greeting("Hi {$this->user->name},") - ->line("We have updated your email address to {$this->new_email}.") - ->line('You can no longer login using your old email address.') - ->line('If you did not make this request, contact us immediately.'); - - return $this->markdown('notifications::email')->with($message->data()); - } -} diff --git a/src/Mail/NewEmailVerification.php b/src/Mail/NewEmailVerification.php deleted file mode 100644 index 7924c87..0000000 --- a/src/Mail/NewEmailVerification.php +++ /dev/null @@ -1,72 +0,0 @@ -user = $user; - } - - /** - * Build the message. - * - * @return $this - */ - public function build() - { - $verificationUrl = $this->verificationUrl($this->user); - - $message = (new MailMessage()) - ->subject(Lang::get('Verify New Email Address')) - ->greeting(Lang::get('Hi :user,', ['user' => $this->user->name])) - ->line(Lang::get('You are receiving this email since you have requested to update your email address to this new email.')) - ->line(Lang::get('Please click the button below to verify your new email address.')) - ->action(Lang::get('Verify New Email Address'), $verificationUrl) - ->line(Lang::get('If you did not request to update your email, no further action is required.')); - - return $this->markdown('notifications::email')->with($message->data()); - } - - /** - * Get the verification URL for the given user. - * - * @param User $user - * @return string - */ - protected function verificationUrl(User $user) - { - return URL::temporarySignedRoute( - $user->getRouteForEmailVerification(), - Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), - [ - 'id' => $user->getKey(), - 'hash' => sha1($user->getEmailForVerification()), - ] - ); - } -} diff --git a/src/Models/User.php b/src/Models/User.php new file mode 100644 index 0000000..ab505ed --- /dev/null +++ b/src/Models/User.php @@ -0,0 +1,66 @@ + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'last_login_at' => 'datetime', + 'password' => 'hashed', + ]; + } + + /** + * Whether the user may use the app: approved, not locked, and with a verified email when the type needs one + */ + public function isActive(): bool + { + if (! $this->hasActiveAccount() || $this->isLockedOut()) { + return false; + } + + return $this->hasVerifiedEmail() || ! $this->userType()->hasFeature('email_verification'); + } +} diff --git a/src/Notifications/EmailUpdateRequest.php b/src/Notifications/EmailUpdateRequest.php deleted file mode 100644 index 28b1a04..0000000 --- a/src/Notifications/EmailUpdateRequest.php +++ /dev/null @@ -1,45 +0,0 @@ -new_email = $new_email; - } - - /** - * Get the notification's channels. - */ - public function via(mixed $notifiable): array|string - { - return ['mail']; - } - - /** - * Build the mail representation of the notification. - * - * @param mixed $notifiable - * @return \Illuminate\Notifications\Messages\MailMessage - */ - public function toMail($notifiable) - { - return (new MailMessage()) - ->greeting("Hi {$notifiable->name},") - ->line("We have received a request to update your email address to {$this->new_email}.") - ->line('Your email address won\'t be updated until you verify the new email address.') - ->line('If you did not make this request, contact us immediately.'); - } -} diff --git a/src/Notifications/NewPasswordNotification.php b/src/Notifications/NewPasswordNotification.php deleted file mode 100644 index 89accef..0000000 --- a/src/Notifications/NewPasswordNotification.php +++ /dev/null @@ -1,69 +0,0 @@ -getMorphClass()); - $account_name = get_setting('app_name').' '.$user_type.' Account'; - - $message = (new MailMessage()) - ->subject($account_name.' Login Details') - ->greeting("Hi {$notifiable->name},") - ->line("The login details of your $account_name are given below:") - ->line("**Email:** {$notifiable->email}") - ->line("**Password:** {$this->password}"); - - - if ($notifiable->requiresPasswordUpdate()) { - $message->line('You will be required to change your password after your next login.'); - } else { - $message->line('We recommend that you change your password after your next login.'); - } - - $message->action('Login Now', $notifiable->loginUrl()); - - return $message; - } -} diff --git a/src/Notifications/ResetPassword.php b/src/Notifications/ResetPassword.php index b5a0de2..8c09fb6 100644 --- a/src/Notifications/ResetPassword.php +++ b/src/Notifications/ResetPassword.php @@ -2,24 +2,34 @@ namespace Javaabu\Auth\Notifications; -use Closure; -use Illuminate\Contracts\Foundation\Application; -use Illuminate\Contracts\Routing\UrlGenerator; +use Illuminate\Auth\Notifications\ResetPassword as BaseResetPassword; +use Javaabu\Auth\UserTypes; -class ResetPassword extends \Illuminate\Auth\Notifications\ResetPassword +/** + * Laravel's password reset email, linking to the user type's reset page + */ +class ResetPassword extends BaseResetPassword { - /** - * The callback that should be used to create the reset password URL. - * - * @var Closure|null - */ - public static $createUrlCallback = [self::class, 'createPasswordResetUrl']; + public function __construct( + $token, + public ?string $user_type = null, + ) { + parent::__construct($token); + } - public static function createPasswordResetUrl($notifiable, $token): string|UrlGenerator|Application + protected function resetUrl($notifiable): string { - return url(route($notifiable->getRouteForPasswordReset(), [ - 'token' => $token, + if (static::$createUrlCallback) { + return call_user_func(static::$createUrlCallback, $notifiable, $this->token); + } + + $type = $this->user_type + ? UserTypes::get($this->user_type) + : (UserTypes::for($notifiable) ?? UserTypes::default()); + + return $type->route('password.reset', [ + 'token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset(), - ], false)); + ]); } } diff --git a/src/Notifications/VerifyEmail.php b/src/Notifications/VerifyEmail.php index f1fea6a..18762a8 100644 --- a/src/Notifications/VerifyEmail.php +++ b/src/Notifications/VerifyEmail.php @@ -2,27 +2,38 @@ namespace Javaabu\Auth\Notifications; +use Illuminate\Auth\Notifications\VerifyEmail as BaseVerifyEmail; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\URL; +use Javaabu\Auth\UserTypes; -class VerifyEmail extends \Illuminate\Auth\Notifications\VerifyEmail +/** + * Laravel's verification email, linking to the user type's verification route + */ +class VerifyEmail extends BaseVerifyEmail { - /** - * Get the verification URL for the given notifiable. - * - * @param mixed $notifiable - * @return string - */ - protected function verificationUrl($notifiable) + public function __construct( + public ?string $user_type = null, + ) {} + + protected function verificationUrl($notifiable): string { + if (static::$createUrlCallback) { + return call_user_func(static::$createUrlCallback, $notifiable); + } + + $type = $this->user_type + ? UserTypes::get($this->user_type) + : (UserTypes::for($notifiable) ?? UserTypes::default()); + return URL::temporarySignedRoute( - $notifiable->getRouteForEmailVerification(), + $type->routeNameFor('verification.verify'), Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), [ 'id' => $notifiable->getKey(), 'hash' => sha1($notifiable->getEmailForVerification()), - ] + ], ); } } diff --git a/src/Passkeys/Passkey.php b/src/Passkeys/Passkey.php new file mode 100644 index 0000000..9a479b8 --- /dev/null +++ b/src/Passkeys/Passkey.php @@ -0,0 +1,45 @@ +morphTo('user'); + } + + /** + * What the security page shows for the passkey + * + * @return array{ + * id: int, + * name: string, + * authenticator: string|null, + * created_at: string|null, + * last_used_at: string|null + * } + */ + public function toDisplayArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'authenticator' => $this->authenticator, + 'created_at' => $this->created_at?->diffForHumans(), + 'last_used_at' => $this->last_used_at?->diffForHumans(), + ]; + } +} diff --git a/src/Passkeys/VerifyPasskey.php b/src/Passkeys/VerifyPasskey.php new file mode 100644 index 0000000..4d12bbf --- /dev/null +++ b/src/Passkeys/VerifyPasskey.php @@ -0,0 +1,28 @@ +getAttribute('user_type') !== $user->getMorphClass()) { + throw InvalidPasskeyException::make(__('Passkey not recognized. It may have been removed from your account.')); + } + } +} diff --git a/src/PasswordResetsSchema.php b/src/PasswordResetsSchema.php deleted file mode 100644 index 51ad870..0000000 --- a/src/PasswordResetsSchema.php +++ /dev/null @@ -1,18 +0,0 @@ -string('email')->index(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - } -} diff --git a/src/PasswordUpdate/Middleware/RedirectIfPasswordUpdateNotRequired.php b/src/PasswordUpdate/Middleware/RedirectIfPasswordUpdateNotRequired.php deleted file mode 100644 index e81c691..0000000 --- a/src/PasswordUpdate/Middleware/RedirectIfPasswordUpdateNotRequired.php +++ /dev/null @@ -1,38 +0,0 @@ -check()) { - /** @var PasswordUpdatableContract $user */ - $user = Auth::guard($guard)->user(); - - if (! $user->requiresPasswordUpdate()) { - if (expects_json($request)) { - throw new AuthorizationException('Users that do not require password update not allowed'); - } - - return redirect()->to($user->passwordUpdateRedirectUrl()); - } - } - - return $next($request); - } -} diff --git a/src/PasswordUpdate/Middleware/RedirectIfPasswordUpdateRequired.php b/src/PasswordUpdate/Middleware/RedirectIfPasswordUpdateRequired.php deleted file mode 100644 index 5785d3b..0000000 --- a/src/PasswordUpdate/Middleware/RedirectIfPasswordUpdateRequired.php +++ /dev/null @@ -1,38 +0,0 @@ -check()) { - /** @var PasswordUpdatableContract $user */ - $user = Auth::guard($guard)->user(); - - if ($user->requiresPasswordUpdate()) { - if (expects_json($request)) { - throw new AuthorizationException('Password update required'); - } - - return redirect()->to($user->passwordUpdateUrl()); - } - } - - return $next($request); - } -} diff --git a/src/PasswordUpdate/PasswordUpdatable.php b/src/PasswordUpdate/PasswordUpdatable.php deleted file mode 100644 index 1a3f4f8..0000000 --- a/src/PasswordUpdate/PasswordUpdatable.php +++ /dev/null @@ -1,61 +0,0 @@ -isDirty('password') && $user->shouldLogPasswordChanged()) { - event(new PasswordChanged($user, auth()->user())); - } - }); - } - - public function shouldLogPasswordChanged(): bool - { - return ! $this->password_reset; - } - - public function markAsPasswordReset() - { - $this->password_reset = true; - } - - /** - * Whether a password update is required - * - * @return bool - */ - public function requiresPasswordUpdate() - { - return $this->require_password_update; - } - - /** - * The password update redirect url - * - * @return string - */ - public function passwordUpdateRedirectUrl() - { - return $this->homeUrl(); - } - - /** - * The clear require password update - * - * @return void - */ - public function clearRequirePasswordUpdate() - { - $this->require_password_update = false; - } -} diff --git a/src/PasswordUpdate/PasswordUpdatableContract.php b/src/PasswordUpdate/PasswordUpdatableContract.php deleted file mode 100644 index cdf56d7..0000000 --- a/src/PasswordUpdate/PasswordUpdatableContract.php +++ /dev/null @@ -1,38 +0,0 @@ -guard()->user(); - } - - /** - * The user broker - */ - public function broker(): Builder - { - return User::query(); - } - - /** - * Show the password update form - */ - public function showPasswordUpdateForm(): Factory|View - { - return view('admin.auth.passwords.update'); - } - - /** - * Update the password - * - * @return RedirectResponse - */ - public function updatePassword(Request $request) - { - // validate - $this->validatePasswordUpdateRequest($request); - - // update password - $this->updateUserPassword($request); - - // send password updated response - return $this->sendPasswordUpdatedResponse(); - } - - /** - * Validate the password update request - */ - protected function validatePasswordUpdateRequest(Request $request) - { - /*$user = $this->user(); - $table = $this->broker()->getModel()->getTable();*/ - - $this->validate($request, [ - 'current_password' => 'required|current_password:', - 'password' => 'required|string|min:8|confirmed|different:current_password', - ]); - } - - /** - * Update the user password - */ - protected function updateUserPassword(Request $request): void - { - $user = $this->user(); - - $user->password = $request->password; - $user->clearRequirePasswordUpdate(); - - $user->save(); - } - - /** - * Get the response for a successful password update. - */ - protected function sendPasswordUpdatedResponse(string $message = ''): RedirectResponse - { - return redirect($this->redirectPath()) - ->with('alerts', [ - [ - 'type' => 'success', - 'title' => '', - 'text' => $message ?: __('Your password has been updated successfully!'), - ], - ]); - } -} diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php deleted file mode 100644 index 1e612c4..0000000 --- a/src/Providers/EventServiceProvider.php +++ /dev/null @@ -1,62 +0,0 @@ - [ - RecordFailedLogin::class, - ], - - PasswordResetLinkSent::class => [ - RecordPasswordResetLinkSent::class, - ], - - PasswordChanged::class => [ - RecordPasswordChanged::class, - ], - - PasswordReset::class => [ - RecordPasswordReset::class, - ], - - Login::class => [ - RecordLogin::class, - ], - - Logout::class => [ - RecordLogout::class, - ], - - Lockout::class => [ - RecordLockout::class, - ], - ]; - - public function boot(): void - { - parent::boot(); - } -} diff --git a/src/Routing/AuthRoutes.php b/src/Routing/AuthRoutes.php new file mode 100644 index 0000000..67c74c9 --- /dev/null +++ b/src/Routing/AuthRoutes.php @@ -0,0 +1,244 @@ +, + * domain?: string|null, + * account_prefix?: string, + * except?: array + * } $options + */ + public static function register(Router $router, ?string $type = null, array $options = []): void + { + $user_type = $type ? UserTypes::get($type) : UserTypes::default(); + + (new static($router, $user_type, $options))->registerRoutes(); + } + + /** @var array */ + protected array $except; + + /** + * @param array $options + */ + public function __construct( + protected Router $router, + protected UserType $type, + protected array $options = [], + ) { + $this->except = $options['except'] ?? []; + } + + public function registerRoutes(): void + { + $attributes = [ + 'prefix' => $this->options['prefix'] ?? '', + 'as' => $this->type->routeNamePrefix(), + 'middleware' => (array) ($this->options['middleware'] ?? ['web']), + UserTypeRegistry::ROUTE_ACTION => $this->type->name(), + ]; + + if (! empty($this->options['domain'])) { + $attributes['domain'] = $this->options['domain']; + } + + $this->router->group($attributes, function () { + $this->guestRoutes(); + $this->signedInRoutes(); + $this->accountRoutes(); + }); + } + + protected function guestRoutes(): void + { + $name = $this->type->name(); + + $this->router->middleware("auth.guest:{$name}")->group(function () { + $login = $this->controller('login'); + + $this->router->get('login', [$login, 'create'])->name('login'); + $this->router->post('login', [$login, 'store'])->name('login.store'); + + if ($this->wants(UserType::REGISTRATION)) { + $register = $this->controller('register'); + + $this->router->get('register', [$register, 'create'])->name('register'); + $this->router->post('register', [$register, 'store'])->name('register.store'); + } + + if ($this->wants(UserType::PASSWORD_RESET)) { + $link = $this->controller('password_reset_link'); + $new_password = $this->controller('new_password'); + + $this->router->get('forgot-password', [$link, 'create'])->name('password.request'); + $this->router->post('forgot-password', [$link, 'store'])->middleware('throttle:auth.password-reset')->name('password.email'); + $this->router->get('reset-password/{token}', [$new_password, 'create'])->name('password.reset'); + $this->router->post('reset-password', [$new_password, 'store'])->middleware('throttle:auth.password-reset')->name('password.update'); + } + + if ($this->type->hasTwoFactor() && $this->wants('two_factor')) { + $challenge = $this->controller('two_factor_challenge'); + + $this->router->get('two-factor-challenge', [$challenge, 'create'])->name('two-factor.login'); + $this->router->post('two-factor-challenge', [$challenge, 'store'])->name('two-factor.login.store'); + } + + if ($this->wants(UserType::PASSKEYS)) { + $passkey_login = $this->controller('passkey_login'); + + $this->router->get('passkeys/login/options', [$passkey_login, 'options'])->middleware('throttle:auth.passkeys')->name('passkey.login-options'); + $this->router->post('passkeys/login', [$passkey_login, 'store'])->middleware('throttle:auth.passkeys')->name('passkey.login'); + } + + foreach ($this->type->loginMethodsList() as $key => $method) { + if (! in_array("login_method:{$key}", $this->except, true)) { + $method->routes($this->router, $this->type); + } + } + }); + } + + /** + * Pages for signed-in users who may not have finished setting up their account + */ + protected function signedInRoutes(): void + { + $name = $this->type->name(); + + $this->router->middleware("auth.type:{$name},basic")->group(function () { + $this->router->post('logout', [$this->controller('login'), 'destroy'])->name('logout'); + + $this->router->get('account/inactive', $this->controller('account_status'))->name('account.inactive'); + + if ($this->wants(UserType::EMAIL_VERIFICATION)) { + $this->router->get('verify-email', $this->controller('email_verification_prompt'))->name('verification.notice'); + $this->router->get('verify-email/{id}/{hash}', $this->controller('verify_email')) + ->middleware(['signed', 'throttle:auth.verification']) + ->name('verification.verify'); + $this->router->post('email/verification-notification', [$this->controller('email_verification_notification'), 'store']) + ->middleware('throttle:auth.verification') + ->name('verification.send'); + } + + if ($this->wants(UserType::PASSWORD_CONFIRMATION)) { + $confirm = $this->controller('confirm_password'); + + $this->router->get('confirm-password', [$confirm, 'show'])->name('password.confirm'); + $this->router->post('confirm-password', [$confirm, 'store'])->name('password.confirm.store'); + + if ($this->wants(UserType::PASSKEYS)) { + $passkey_confirm = $this->controller('passkey_confirmation'); + + $this->router->get('passkeys/confirm/options', [$passkey_confirm, 'options'])->middleware('throttle:auth.passkeys')->name('passkey.confirm-options'); + $this->router->post('passkeys/confirm', [$passkey_confirm, 'store'])->middleware('throttle:auth.passkeys')->name('passkey.confirm'); + } + } + + $required = $this->controller('required_password'); + + $this->router->get('update-password', [$required, 'edit'])->name('password.required'); + $this->router->put('update-password', [$required, 'update'])->name('password.required.update'); + }); + } + + /** + * The profile and security pages + */ + protected function accountRoutes(): void + { + $name = $this->type->name(); + $confirm = $this->wants(UserType::PASSWORD_CONFIRMATION) ? ["auth.password.confirm:{$name}"] : []; + + $this->router->middleware("auth.type:{$name}") + ->prefix($this->options['account_prefix'] ?? 'settings') + ->group(function () use ($confirm) { + if ($this->wants(UserType::PROFILE)) { + $profile = $this->controller('profile'); + + $this->router->get('profile', [$profile, 'edit'])->name('profile.edit'); + $this->router->patch('profile', [$profile, 'update'])->name('profile.update'); + + if ($this->wants(UserType::DELETE_ACCOUNT)) { + $this->router->delete('profile', [$profile, 'destroy'])->name('profile.destroy'); + } + } + + if (! $this->wants('security')) { + return; + } + + $this->router->get('security', [$this->controller('security'), 'edit'])->middleware($confirm)->name('security.edit'); + $this->router->put('password', [$this->controller('password'), 'update'])->middleware('throttle:6,1')->name('user-password.update'); + + if ($this->type->hasTwoFactor() && $this->wants('two_factor')) { + $two_factor = $this->controller('two_factor'); + + $this->router->middleware($confirm)->group(function () use ($two_factor) { + $this->router->post('two-factor', [$two_factor, 'store'])->name('two-factor.enable'); + $this->router->post('two-factor/confirm', [$two_factor, 'confirm'])->name('two-factor.confirm'); + $this->router->delete('two-factor', [$two_factor, 'destroy'])->name('two-factor.disable'); + $this->router->get('two-factor/qr-code', [$two_factor, 'qrCode'])->name('two-factor.qr-code'); + $this->router->get('two-factor/secret-key', [$two_factor, 'secretKey'])->name('two-factor.secret-key'); + $this->router->get('two-factor/recovery-codes', [$two_factor, 'recoveryCodes'])->name('two-factor.recovery-codes'); + $this->router->post('two-factor/recovery-codes', [$two_factor, 'regenerateRecoveryCodes'])->name('two-factor.regenerate-recovery-codes'); + }); + } + + if ($this->wants(UserType::PASSKEYS)) { + $passkeys = $this->controller('passkeys'); + + $this->router->middleware([...$confirm, 'throttle:auth.passkeys'])->group(function () use ($passkeys) { + $this->router->get('passkeys/options', [$passkeys, 'options'])->name('passkey.registration-options'); + $this->router->post('passkeys', [$passkeys, 'store'])->name('passkey.store'); + $this->router->delete('passkeys/{passkey}', [$passkeys, 'destroy'])->name('passkey.destroy'); + }); + } + + if ($this->wants(UserType::SESSIONS)) { + $this->router->delete('sessions', [$this->controller('sessions'), 'destroy'])->middleware($confirm)->name('sessions.destroy'); + } + + if ($this->wants(UserType::API_TOKENS)) { + $tokens = $this->controller('api_tokens'); + + $this->router->post('api-tokens', [$tokens, 'store'])->middleware('throttle:auth.api-tokens')->name('api-tokens.store'); + $this->router->delete('api-tokens/{token}', [$tokens, 'destroy'])->name('api-tokens.destroy'); + } + }); + } + + /** + * Whether to register a feature's routes: the type has it and the macro didn't leave it out + */ + protected function wants(string $feature): bool + { + if (in_array($feature, $this->except, true)) { + return false; + } + + if (in_array($feature, ['two_factor', 'security'], true)) { + return true; + } + + return $this->type->hasFeature($feature); + } + + /** @return class-string */ + protected function controller(string $key): string + { + return config("javaabu-auth.controllers.{$key}"); + } +} diff --git a/src/Rules/UniqueGmail.php b/src/Rules/UniqueGmail.php index 77db19e..6ed19c5 100644 --- a/src/Rules/UniqueGmail.php +++ b/src/Rules/UniqueGmail.php @@ -5,8 +5,8 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Model as UserModel; use Illuminate\Support\Str; -use Javaabu\Auth\User; class UniqueGmail implements ValidationRule { @@ -17,13 +17,12 @@ class UniqueGmail implements ValidationRule /** * Constructor * - * @param class-string $model_class + * @param class-string $model_class */ public function __construct( protected string $model_class, protected string $email_column = '', - ) { - } + ) {} public function ignore(string|Model $user_id, string $id_column = ''): static { @@ -52,7 +51,7 @@ protected function getIdColumn(): string $model_class = $this->model_class; /** @var Model $model */ - $model = new $model_class(); + $model = new $model_class; return $model->getKeyName(); } @@ -77,17 +76,17 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $model_class = $this->model_class; $has_alias = $model_class::query() - ->hasGmailAlias($value, $this->getEmailColumn($attribute)) - ->when($this->getIgnoreUserId(), function ($query) { - $query->where($this->getIdColumn(), '!=', $this->ignore_user_id); - }) - ->exists(); + ->hasGmailAlias($value, $this->getEmailColumn($attribute)) + ->when($this->getIgnoreUserId(), function ($query) { + $query->where($this->getIdColumn(), '!=', $this->ignore_user_id); + }) + ->exists(); if ($has_alias) { $attribute_key = 'validation.attributes.' . $attribute; $attribute = trans()->has($attribute_key) ? trans($attribute_key) : $attribute; - $fail(trans('auth::validation.unique-gmail', compact('attribute'))); + $fail(__('The same gmail :attribute is already taken.', compact('attribute'))); } } } diff --git a/src/Schema/UserSchema.php b/src/Schema/UserSchema.php new file mode 100644 index 0000000..37c7cdb --- /dev/null +++ b/src/Schema/UserSchema.php @@ -0,0 +1,77 @@ +id(); + $table->string('name'); + + $email = $table->string('email')->unique(); + + if (! $email_required) { + $email->nullable(); + } + + $table->timestamp('email_verified_at')->nullable(); + + $password = $table->string('password'); + + if (! $password_required) { + $password->nullable(); + } + + $table->rememberToken(); + $table->timestamps(); + + static::accountColumns($table); + static::twoFactorColumns($table); + + $table->softDeletes(); + } + + /** + * HasAccountStatus, LocksAfterFailedLogins and RequiresPasswordUpdate, plus the last login + */ + public static function accountColumns(Blueprint $table): void + { + $table->timestamp('last_login_at')->nullable()->index(); + $table->unsignedInteger('login_attempts')->nullable(); + $table->boolean('require_password_update')->default(false); + $table->string('status')->default(UserStatuses::PENDING->value)->index(); + } + + /** + * TwoFactorAuthenticatable, in laravel/fortify's format + */ + public static function twoFactorColumns(Blueprint $table): void + { + $table->text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); + } + + /** + * A sessions table for the `multi_auth_database` driver + */ + public static function sessionColumns(Blueprint $table): void + { + $table->string('id')->primary(); + $table->nullableMorphs('user'); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + } +} diff --git a/src/Session/MultiAuthDatabaseSessionHandler.php b/src/Session/MultiAuthDatabaseSessionHandler.php deleted file mode 100644 index 017365a..0000000 --- a/src/Session/MultiAuthDatabaseSessionHandler.php +++ /dev/null @@ -1,36 +0,0 @@ -container->bound(Guard::class) && ($user_type = $this->userType())) { - $payload['user_id'] = $this->userId(); - $payload['user_type'] = $this->userType(); - } - - return $this; - } - - /** - * Get the currently authenticated user. - * - * @return mixed - */ - protected function userType(): ?string - { - $user = $this->container->make(Guard::class)->user(); - - if ($user instanceof Model) { - return $user->getMorphClass(); - } - - return null; - } -} diff --git a/src/Session/Session.php b/src/Session/Session.php deleted file mode 100644 index 6592472..0000000 --- a/src/Session/Session.php +++ /dev/null @@ -1,57 +0,0 @@ - 'datetime', - ]; - - - public function __construct(array $attributes = []) - { - $this->table = config('session.table'); - $this->connection = config('session.connection'); - - parent::__construct($attributes); - } - - public function getAgentAttribute(): Agent - { - return $this->createAgent(); - } - - public function getIsCurrentDeviceAttribute(): bool - { - return $this->id === request()->session()->getId(); - } - - public function user(): MorphTo - { - return $this->morphTo('user'); - } - - /** - * Create a new agent instance from the given session. - * - * @return Agent - */ - protected function createAgent(): Agent - { - return tap(new Agent(), fn ($agent) => $agent->setUserAgent($this->user_agent)); - } -} diff --git a/src/Session/SessionSchema.php b/src/Session/SessionSchema.php deleted file mode 100644 index 00f87c0..0000000 --- a/src/Session/SessionSchema.php +++ /dev/null @@ -1,21 +0,0 @@ -string('id')->primary(); - $table->nullableMorphs('user'); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - } -} diff --git a/src/Agent.php b/src/Sessions/Agent.php similarity index 96% rename from src/Agent.php rename to src/Sessions/Agent.php index dccc0f7..ca0ef4a 100644 --- a/src/Agent.php +++ b/src/Sessions/Agent.php @@ -1,6 +1,6 @@ authenticatedUser(); + + if ($user) { + $payload['user_id'] = $user->getKey(); + $payload['user_type'] = $user->getMorphClass(); + } + + return $this; + } + + protected function authenticatedUser(): ?Model + { + if (! $this->container?->bound(AuthFactory::class)) { + return null; + } + + $auth = $this->container->make(AuthFactory::class); + + foreach (UserTypes::all() as $type) { + $user = $auth->guard($type->guardName())->user(); + + if ($user instanceof Model) { + return $user; + } + } + + return null; + } +} diff --git a/src/Sessions/Session.php b/src/Sessions/Session.php new file mode 100644 index 0000000..68400d8 --- /dev/null +++ b/src/Sessions/Session.php @@ -0,0 +1,75 @@ +table = config('session.table', 'sessions'); + $this->connection = config('session.connection'); + + parent::__construct($attributes); + } + + public function user(): MorphTo + { + return $this->morphTo('user'); + } + + public function agent(): Agent + { + return tap(new Agent, fn (Agent $agent) => $agent->setUserAgent((string) $this->user_agent)); + } + + public function isCurrentDevice(): bool + { + return request()->hasSession() + && $this->id === request()->session()->getId(); + } + + /** + * What the security page shows for the session + * + * @return array{ + * id: string, + * ip_address: string|null, + * platform: string|null, + * browser: string|null, + * is_desktop: bool, + * is_current_device: bool, + * last_active: string + * } + */ + public function toDisplayArray(): array + { + $agent = $this->agent(); + + return [ + 'id' => $this->id, + 'ip_address' => $this->ip_address, + 'platform' => $agent->platform() ?: null, + 'browser' => $agent->browser() ?: null, + 'is_desktop' => $agent->isDesktop(), + 'is_current_device' => $this->isCurrentDevice(), + 'last_active' => now()->setTimestamp((int) $this->last_activity)->diffForHumans(), + ]; + } +} diff --git a/src/Support/AccountState.php b/src/Support/AccountState.php new file mode 100644 index 0000000..8a5b209 --- /dev/null +++ b/src/Support/AccountState.php @@ -0,0 +1,30 @@ +isLockedOut()) { + return __('Your account has been locked due to too many failed logins. Reset your password to unlock it.'); + } + + if ($user instanceof HasAccountStatus && ! $user->hasActiveAccount()) { + return $user->accountStatusMessage(); + } + + return null; + } + + public static function isActive(mixed $user): bool + { + return static::inactiveMessage($user) === null; + } +} diff --git a/src/Support/ActivityLogger.php b/src/Support/ActivityLogger.php new file mode 100644 index 0000000..a868a08 --- /dev/null +++ b/src/Support/ActivityLogger.php @@ -0,0 +1,78 @@ + activity description */ + public const array EVENTS = [ + Login::class => 'login', + Logout::class => 'logout', + Failed::class => 'failed_login', + AccountLocked::class => 'lockout', + PasswordReset::class => 'password_reset', + PasswordResetLinkSent::class => 'password_reset_link_sent', + PasswordUpdated::class => 'password_changed', + TwoFactorAuthenticationConfirmed::class => 'two_factor_enabled', + TwoFactorAuthenticationDisabled::class => 'two_factor_disabled', + PasskeyRegistered::class => 'passkey_added', + PasskeyDeleted::class => 'passkey_removed', + ]; + + public function __construct( + protected Dispatcher $events, + ) {} + + public static function isAvailable(): bool + { + return config('javaabu-auth.log_activity', true) && function_exists('activity'); + } + + public function register(): void + { + foreach (self::EVENTS as $event => $description) { + $this->events->listen($event, function (object $event) use ($description) { + $this->log($event, $description); + }); + } + } + + public function log(object $event, string $description): void + { + if (! static::isAvailable()) { + return; + } + + $user = $event->user ?? null; + + if (! $user instanceof Model) { + return; + } + + activity() + ->causedBy($user) + ->performedOn($user) + ->withProperties(array_filter([ + 'guard' => $event->guard ?? null, + 'ip' => request()->ip(), + ])) + ->log($description); + } +} diff --git a/src/Support/PageData.php b/src/Support/PageData.php new file mode 100644 index 0000000..1bfeac8 --- /dev/null +++ b/src/Support/PageData.php @@ -0,0 +1,89 @@ + */ + public function shared(UserType $type): array + { + return [ + 'auth_type' => [ + 'name' => $type->name(), + 'features' => $type->allFeatures(), + 'two_factor' => $type->twoFactorMode()->value, + 'username' => $type->usernameColumn(), + 'login_methods' => array_values(array_map(fn ($method) => [ + 'key' => $method->key(), + 'label' => $method->label(), + 'url' => $method->url($type), + ], $type->loginMethodsList())), + ], + 'urls' => $this->urls($type), + 'status' => session('status'), + ]; + } + + /** + * The URLs of the type's routes, keyed by their name without the type prefix + * + * @return array + */ + public function urls(UserType $type): array + { + $prefix = $type->routeNamePrefix(); + $urls = []; + + foreach (Route::getRoutes()->getRoutesByName() as $name => $route) { + if ($prefix !== '' && ! str_starts_with($name, $prefix)) { + continue; + } + + if ($route->getAction('auth_user_type') !== $type->name()) { + continue; + } + + $short_name = substr($name, strlen($prefix)); + + if (in_array($short_name, self::PARAMETER_ROUTES, true)) { + continue; + } + + $urls[$short_name] = route($name); + } + + $urls['home'] = $type->homeUrl($type->auth()->user()); + + return $urls; + } + + /** + * The translated strings of the React pages + * + * @return array + */ + public function strings(): array + { + $strings = []; + + foreach (Strings::ALL as $key => $english) { + $strings[$key] = __($english); + } + + return $strings; + } +} diff --git a/src/Support/Strings.php b/src/Support/Strings.php new file mode 100644 index 0000000..0dc47d9 --- /dev/null +++ b/src/Support/Strings.php @@ -0,0 +1,11 @@ +determinePathForRedirectUsing()->homeUrl(); - } - - public function userType(): User - { - return $this->determinePathForRedirectUsing(); - } -} diff --git a/src/Traits/InteractsWithGuards.php b/src/Traits/InteractsWithGuards.php deleted file mode 100644 index 34314ee..0000000 --- a/src/Traits/InteractsWithGuards.php +++ /dev/null @@ -1,21 +0,0 @@ -userType()->guardName(); - } - - public function getGuard(): StatefulGuard - { - return Auth::guard($this->guardName()); - } -} diff --git a/src/Traits/InteractsWithUserTypes.php b/src/Traits/InteractsWithUserTypes.php deleted file mode 100644 index e31c990..0000000 --- a/src/Traits/InteractsWithUserTypes.php +++ /dev/null @@ -1,11 +0,0 @@ -userType()); - } -} diff --git a/src/TwoFactor/RecoveryCode.php b/src/TwoFactor/RecoveryCode.php new file mode 100644 index 0000000..151741b --- /dev/null +++ b/src/TwoFactor/RecoveryCode.php @@ -0,0 +1,13 @@ +engine->generateSecretKey($length ?? (int) config('javaabu-auth.two_factor.secret_length', 16)); + } + + public function qrCodeUrl(string $company_name, string $company_email, string $secret): string + { + return $this->engine->getQRCodeUrl($company_name, $company_email, $secret); + } + + public function verify(string $secret, string $code): bool + { + $code = preg_replace('/\s+/', '', $code); + + if (! preg_match('/^\d{6}$/', $code)) { + return false; + } + + $window = (int) config('javaabu-auth.two_factor.window', 1); + $cache_key = 'javaabu-auth.2fa_codes.' . md5($secret . $code); + + if ($this->cache?->get($cache_key)) { + return false; + } + + if (! $this->engine->verifyKey($secret, $code, $window)) { + return false; + } + + $this->cache?->put($cache_key, true, ($window + 1) * 60); + + return true; + } + + /** The code for now, for tests and seeders */ + public function currentCode(string $secret): string + { + return $this->engine->getCurrentOtp($secret); + } +} diff --git a/src/User.php b/src/User.php deleted file mode 100644 index ad467c3..0000000 --- a/src/User.php +++ /dev/null @@ -1,656 +0,0 @@ - UserStatuses::PENDING, - ]; - - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return [ - 'name' => 'string', - 'email' => 'string', - 'email_verified_at' => 'datetime', - 'last_login_at' => 'datetime', - 'login_attempts' => 'integer', - 'require_password_update' => 'boolean', - 'status' => UserStatuses::class, - 'new_email' => 'string', - ]; - } - - /** - * Get the user identifier - */ - public function getPassportCookieIdentifier(): string - { - return $this->makeUserIdentifier($this->getKey(), $this->getMorphClass()); - } - - /** - * Convert dates to Carbon - */ - public function setLastLoginAtAttribute($date): void - { - $this->attributes['last_login_at'] = $date ? Carbon::parse($date) : null; - } - - /** - * Hash the password before saving - */ - public function setPasswordAttribute($value): void - { - $this->attributes['password'] = $value ? Hash::make($value) : null; - } - - /** - * Send the password reset notification. - * - * @param string $token - */ - public function sendPasswordResetNotification($token): void - { - $this->notify(new ResetPassword($token)); - } - - /** - * Send the email verification notification. - */ - public function sendEmailVerificationNotification(): void - { - $this->notify(new VerifyEmail()); - } - - /** - * Send email verification link to - * new email address - */ - public function sendNewEmailVerification(): void - { - Mail::to($this->new_email) - ->send(new NewEmailVerification($this)); - } - - /** - * Notify the current email that - * there has been a request to change - * the email address - */ - public function sendEmailUpdateRequestNotification($new_email): void - { - $this->notify(new EmailUpdateRequest($new_email)); - } - - /** - * Inform the old email that it has - * been updated to the new email - */ - public function sendEmailUpdatedNotification($old_email, $new_email): void - { - Mail::to($old_email) - ->send(new EmailUpdated($this, $new_email)); - } - - /** - * Update the email - */ - public function updateEmail() - { - if ($this->new_email) { - // note the current email - $old_email = $this->email; - - // set the email to the new email - $this->email = $this->new_email; - $this->new_email = null; - $this->save(); - - //notify old email - if ($old_email != $this->email) { - $this->sendEmailUpdatedNotification($old_email, $this->email); - } - - return $this->email; - } - - return false; - } - - /** - * Request for an email update - * Saves the new email until it can be verified - */ - public function requestEmailUpdate($new_email): bool - { - //check if new email is different - if ($new_email && $this->email != $new_email) { - $this->new_email = $new_email; - - // send the email_token - $this->sendNewEmailVerification(); - - // inform current email - $this->sendEmailUpdateRequestNotification($new_email); - - return $new_email; - } - - return false; - } - - /** - * Approve the user - * - * @param bool $save - */ - public function approve($save = false): void - { - $this->updateStatus(UserStatuses::APPROVED, $save); - } - - /** - * Set the user to pending - * - * @param bool $save - */ - public function markAsPending($save = false): void - { - $this->updateStatus(UserStatuses::PENDING, $save); - } - - /** - * Ban the user - * - * @param bool $save - */ - public function ban($save = false): void - { - $this->updateStatus(UserStatuses::BANNED, $save); - } - - /** - * Only allow active users for passport - */ - public function findForPassport($username): mixed - { - return $this->where('email', $username) - ->active() - ->first(); - } - - /** - * Get pending key - */ - public function getPendingKey(): UserStatuses - { - return UserStatuses::PENDING; - } - - /** - * Pending users scope - */ - public function scopePending($query) - { - return $query->where($this->getTable().'.status', $this->getPendingKey()); - } - - /** - * Check if new email is available - */ - public function isNewEmailAvailable(): bool - { - return ! static::where('email', $this->new_email) - ->withTrashed() - ->exists(); - } - - /** - * Check whether the current user's email should be verified - */ - public function shouldVerifyEmail(): bool - { - return $this->status != UserStatuses::BANNED; - } - - /** - * Check if wants new email - */ - public function wantsNewEmail(): bool - { - return $this->hasVerifiedEmail() && $this->new_email; - } - - /** - * Check if needs email verification - * Email verification is needed if the user is - * allowed to verify email and does not have - * a verified email or wants a new email - */ - public function needsEmailVerification(): bool - { - return $this->shouldVerifyEmail() && - (! $this->hasVerifiedEmail() || $this->wantsNewEmail()); - } - - /** - * Get the email address that should be used for verification. - */ - public function getEmailForVerification(): string - { - return $this->wantsNewEmail() ? $this->new_email : $this->email; - } - - /** - * Email unverified scope - */ - public function scopeEmailUnverified($query): mixed - { - return $query->whereNull($this->getTable().'.email_verified_at'); - } - - /** - * Email verified scope - */ - public function scopeEmailVerified($query) - { - return $query->whereNotNull($this->getTable().'.email_verified_at'); - } - - /** - * Get the user status message - */ - public function getStatusMessageAttribute(): string - { - if ($this->is_locked_out) { - return __('Your account has been locked due to too many login attempts. Use forgot password to reset your account password.'); - } - - if ($this->shouldVerifyEmail() && ! $this->hasVerifiedEmail()) { - return __('Please verify your email address to access your account.'); - } - - return $this->status->getMessage(); - } - - /** - * Set email verified at - * - * @param bool $verified - */ - public function setEmailVerificationStatus($verified): void - { - if ($verified) { - if (! $this->hasVerifiedEmail()) { - $this->email_verified_at = $this->freshTimestamp(); - } - } else { - $this->email_verified_at = null; - } - } - - /** - * Should verify email scope - */ - public function scopeShouldVerifyEmail($query) - { - return $query->where('status', '!=', UserStatuses::BANNED); - } - - /** - * Approved users scope - */ - public function scopeApproved($query) - { - return $query->where($this->getTable().'.status', UserStatuses::APPROVED); - } - - /** - * Active users scope - */ - public function scopeActive($query) - { - return $query->approved() - ->emailVerified() - ->notLockedOut(); - } - - /** - * Check if the user is active - */ - public function getIsActiveAttribute(): bool - { - return $this->hasVerifiedEmail() && - $this->status == UserStatuses::APPROVED && - (! $this->is_locked_out); - } - - /** - * Get the max login attempts - */ - public function maxLoginAttempts(): int - { - return config('auth.max_login_attempts'); - } - - /** - * Check if is locked out - */ - public function getIsLockedOutAttribute(): bool - { - return $this->login_attempts >= $this->maxLoginAttempts(); - } - - /** - * Locked out users scope - */ - public function scopeLockedOut($query) - { - return $query->where('login_attempts', '>=', $this->maxLoginAttempts()); - } - - /** - * Not locked out users scope - */ - public function scopeNotLockedOut($query) - { - return $query->whereNull('login_attempts') - ->orWhere('login_attempts', '<', $this->maxLoginAttempts()); - } - - /** - * Check if is approved - */ - public function getIsApprovedAttribute(): bool - { - return $this->status == UserStatuses::APPROVED; - } - - /** - * Check if is banned - */ - public function getIsBannedAttribute(): bool - { - return $this->status == UserStatuses::BANNED; - } - - /** - * Check if is pending - */ - public function getIsPendingAttribute(): bool - { - return $this->status == UserStatuses::PENDING; - } - - /** - * User visible - */ - public function scopeUserVisible($query): mixed - { - $user = auth()->user(); - - if ($user) { - if ($user->can('create', static::class)) { - //can view all - return $query; - } - } - - // everyone can view published - return $query->approved(); - } - - /** - * Get is published attribute - */ - public function getIsPublishedAttribute(): bool - { - return $this->is_approved; - } - - /** - * Get email verification redirect url - */ - public function emailVerificationRedirectUrl(): string - { - return $this->homeUrl(); - } - - /** - * Get initials attribute - */ - public function getInitialsAttribute(): string - { - $names = explode(' ', preg_replace('/\s+/', ' ', $this->name)); - $initials = substr($names[0] ?? '', 0, 1); - - if (($count = count($names)) > 1) { - $initials .= substr($names[$count - 1], 0, 1); - } - - return $initials; - } - - /** - * Increment login attempts - */ - public function incrementLoginAttempts(): void - { - $this->login_attempts = ($this->login_attempts ?: 0) + 1; - } - - /** - * Reset login attempts - */ - public function resetLoginAttempts(): void - { - $this->login_attempts = null; - } - - /** - * Define media collections - */ - public function registerMediaCollections(): void - { - $this->addMediaCollection('avatar') - ->singleFile() - ->acceptsFile(function (File $file) { - return AllowedMimeTypes::isAllowedMimeType($file->mimeType, 'image'); - }); - } - - /** - * Register image conversions - * - * @throws \Spatie\Image\Exceptions\InvalidManipulation - */ - public function registerMediaConversions(?Media $media = null): void - { - $this->addMediaConversion('avatar') - ->width(200) - ->height(200) - ->fit(Fit::Crop, 200, 200) - ->keepOriginalImageFormat() - ->performOnCollections('avatar'); - - $this->addMediaConversion('preview') - ->width(400) - ->height(400); - } - - /** - * Get the avatar url - */ - public function getAvatarAttribute(): string - { - $avatar = $this->getFirstMediaUrl('avatar', 'avatar'); - - return $avatar ?: asset(get_setting('default_avatar')); - } - - /** - * Get the provider for this user type - */ - public function getProvider(): string - { - return $this->getTable(); - } - - /** - * Get the list name - */ - public function getListNameAttribute(): string - { - return __(':name (:email)', ['name' => $this->name, 'email' => $this->email]); - } - - /** - * Get the sessions of the user - */ - public function sessions(): MorphMany - { - return $this->morphMany(Session::class, 'user'); - } - - /** - * Delete the other browser session records from storage. - * - * @return void - */ - public function deleteOtherSessionRecords() - { - if (config('session.driver') !== 'multi_auth_database') { - return; - } - - $this->sessions() - ->where('id', '!=', request()->session()->getId()) - ->delete(); - } - - public function emailVerified(): Attribute - { - return Attribute::get(function () { - return $this->hasVerifiedEmail(); - }); - } -} diff --git a/src/UserContract.php b/src/UserContract.php deleted file mode 100644 index 99581cc..0000000 --- a/src/UserContract.php +++ /dev/null @@ -1,56 +0,0 @@ -id(); - $table->string('name'); - - if ($email_required) { - $table->string('email')->unique(); - } else { - $table->string('email')->nullable()->unique(); - } - - $table->timestamp('email_verified_at')->nullable(); - - if ($password_required) { - $table->string('password'); - } else { - $table->string('password')->nullable(); - } - - $table->rememberToken(); - $table->timestamps(); - - // custom - $table->timestamp('last_login_at')->nullable()->index(); - $table->unsignedInteger('login_attempts')->nullable(); - $table->boolean('require_password_update')->default(false); - $table->nativeEnum('status', UserStatuses::class)->index(); - $table->string('new_email')->index()->nullable(); - $table->softDeletes(); - } -} diff --git a/src/UserType.php b/src/UserType.php new file mode 100644 index 0000000..5c23e56 --- /dev/null +++ b/src/UserType.php @@ -0,0 +1,435 @@ + true, + self::PASSWORD_RESET => true, + self::EMAIL_VERIFICATION => true, + self::PASSWORD_CONFIRMATION => true, + self::PROFILE => true, + self::DELETE_ACCOUNT => false, + self::SESSIONS => true, + self::PASSKEYS => false, + self::API_TOKENS => false, + ]; + + /** @var class-string */ + protected string $model; + + protected string $guard; + + protected string $passwords; + + protected string $route_name; + + protected string|Closure $home = '/'; + + protected ?string $views = null; + + /** @var array */ + protected array $layouts = []; + + protected string $username = 'email'; + + /** @var array */ + protected array $features = self::DEFAULT_FEATURES; + + protected TwoFactorModes $two_factor = TwoFactorModes::OPTIONAL; + + protected ?int $max_login_attempts = null; + + /** @var array|LoginMethod> */ + protected array $login_methods = []; + + public function __construct( + protected string $name, + ) { + if (! preg_match('/^[a-z][a-z0-9_-]*$/', $name)) { + throw new InvalidArgumentException("User type names are lowercase letters, numbers, dashes and underscores; got `{$name}`."); + } + + $this->guard = $name; + $this->passwords = str($name)->plural()->toString(); + $this->route_name = $name; + } + + /** + * Build a user type from its config array + * + * @param array $config + */ + public static function fromArray(string $name, array $config): static + { + $type = new static($name); + + foreach ($config as $key => $value) { + match ($key) { + 'model' => $type->model($value), + 'guard' => $type->guard($value), + 'passwords' => $type->passwords($value), + 'route_name' => $type->routeName($value), + 'home' => $type->home($value), + 'views' => $type->views($value), + 'layouts' => $type->layouts($value), + 'username' => $type->username($value), + 'features' => $type->features($value), + 'two_factor' => $type->twoFactor($value), + 'max_login_attempts' => $type->maxLoginAttempts($value), + 'login_methods' => $type->loginMethods($value), + default => throw new InvalidArgumentException("Unknown option `{$key}` for the `{$name}` user type."), + }; + } + + return $type; + } + + public function name(): string + { + return $this->name; + } + + /** + * @param class-string $model + */ + public function model(string $model): static + { + $this->model = $model; + + return $this; + } + + /** @return class-string */ + public function modelClass(): string + { + if (isset($this->model)) { + return $this->model; + } + + $provider = config("auth.guards.{$this->guard}.provider"); + $model = $provider ? config("auth.providers.{$provider}.model") : null; + + if (! $model) { + throw new InvalidArgumentException("Give the `{$this->name}` user type a model, or a guard with an eloquent provider."); + } + + return $model; + } + + public function newModel(): Model + { + $model = $this->modelClass(); + + return new $model; + } + + public function guard(string $guard): static + { + $this->guard = $guard; + + return $this; + } + + public function guardName(): string + { + return $this->guard; + } + + public function auth(): StatefulGuard + { + return Auth::guard($this->guard); + } + + public function passwords(string $broker): static + { + $this->passwords = $broker; + + return $this; + } + + public function passwordBrokerName(): string + { + return $this->passwords; + } + + public function broker(): PasswordBroker + { + return Password::broker($this->passwords); + } + + /** + * The prefix of the route names, e.g. `admin` for `admin.login`; empty for `login` + */ + public function routeName(string $route_name): static + { + $this->route_name = trim($route_name, '.'); + + return $this; + } + + public function routeNamePrefix(): string + { + return $this->route_name === '' ? '' : "{$this->route_name}."; + } + + /** + * The full name of one of this type's routes: `login` → `admin.login` + */ + public function routeNameFor(string $route): string + { + return $this->routeNamePrefix() . $route; + } + + public function hasRoute(string $route): bool + { + return Route::has($this->routeNameFor($route)); + } + + /** + * The URL of one of this type's routes + * + * @param array $parameters + */ + public function route(string $route, array $parameters = [], bool $absolute = true): string + { + return route($this->routeNameFor($route), $parameters, $absolute); + } + + /** + * Where users go after logging in: a path, a route name, or a closure given the user + */ + public function home(string|Closure $home): static + { + $this->home = $home; + + return $this; + } + + public function homeUrl(?Model $user = null): string + { + $home = $this->home; + + if ($home instanceof Closure) { + return $home($user); + } + + if (Route::has($home)) { + return route($home); + } + + return url($home); + } + + /** + * The view stack for this type's pages, `blade` or `inertia`; null uses `javaabu-auth.views` + */ + public function views(?string $views): static + { + $this->views = $views; + + return $this; + } + + public function viewStack(): string + { + return $this->views ?? config('javaabu-auth.views', 'blade'); + } + + /** + * The Blade layouts this type's pages extend: `auth` for the guest pages, `account` for the signed-in ones; + * missing ones use `javaabu-auth.layouts` + * + * @param array $layouts + */ + public function layouts(array $layouts): static + { + $this->layouts = [...$this->layouts, ...$layouts]; + + return $this; + } + + public function layoutName(string $kind = 'auth'): string + { + return $this->layouts[$kind] + ?? config("javaabu-auth.layouts.{$kind}") + ?? "auth::layouts.{$kind}"; + } + + /** + * The column users log in with + */ + public function username(string $username): static + { + $this->username = $username; + + return $this; + } + + public function usernameColumn(): string + { + return $this->username; + } + + /** + * Turn features on or off: `['registration' => false, 'passkeys' => true]` + * + * @param array|array $features + */ + public function features(array $features): static + { + foreach ($features as $feature => $enabled) { + if (is_int($feature)) { + $feature = $enabled; + $enabled = true; + } + + if (! array_key_exists($feature, self::DEFAULT_FEATURES)) { + throw new InvalidArgumentException("Unknown feature `{$feature}` for the `{$this->name}` user type."); + } + + $this->features[$feature] = (bool) $enabled; + } + + return $this; + } + + public function enable(string ...$features): static + { + return $this->features(array_fill_keys($features, true)); + } + + public function disable(string ...$features): static + { + return $this->features(array_fill_keys($features, false)); + } + + public function hasFeature(string $feature): bool + { + return $this->features[$feature] ?? false; + } + + /** @return array */ + public function allFeatures(): array + { + return $this->features; + } + + public function twoFactor(TwoFactorModes|string $mode): static + { + $this->two_factor = $mode instanceof TwoFactorModes + ? $mode + : TwoFactorModes::from($mode); + + return $this; + } + + public function twoFactorMode(): TwoFactorModes + { + return $this->two_factor; + } + + public function hasTwoFactor(): bool + { + return $this->two_factor !== TwoFactorModes::OFF; + } + + public function requiresTwoFactor(): bool + { + return $this->two_factor === TwoFactorModes::REQUIRED; + } + + /** + * Lock accounts after this many failed logins in a row, until the password is reset; null never locks + */ + public function maxLoginAttempts(?int $attempts): static + { + $this->max_login_attempts = $attempts; + + return $this; + } + + public function maxLoginAttemptsAllowed(): ?int + { + return $this->max_login_attempts ?? config('javaabu-auth.max_login_attempts'); + } + + /** + * Ways to log in besides the password form, e.g. a mobile number and one-time code + * + * @param array|LoginMethod> $methods + */ + public function loginMethods(array $methods): static + { + foreach ($methods as $method) { + $this->loginMethod($method); + } + + return $this; + } + + /** + * @param class-string|LoginMethod $method + */ + public function loginMethod(string|LoginMethod $method): static + { + $instance = is_string($method) ? app($method) : $method; + + if (! $instance instanceof LoginMethod) { + throw new InvalidArgumentException('Login methods implement ' . LoginMethod::class . '.'); + } + + $this->login_methods[$instance->key()] = $instance; + + return $this; + } + + /** @return array */ + public function loginMethodsList(): array + { + return $this->login_methods; + } + + /** + * Whether a model is one of this type's users + */ + public function owns(mixed $user): bool + { + return $user instanceof Model && is_a($user, $this->modelClass()); + } +} diff --git a/src/UserTypeRegistry.php b/src/UserTypeRegistry.php new file mode 100644 index 0000000..410bdb1 --- /dev/null +++ b/src/UserTypeRegistry.php @@ -0,0 +1,153 @@ + */ + protected array $types = []; + + protected bool $loaded_config = false; + + /** + * Add a user type, or change one: `register('admin', ['guard' => 'web_admin'])` + * + * @param array|Closure(UserType): mixed|null $config + */ + public function register(string $name, array|Closure|null $config = null): UserType + { + $this->loadConfig(); + + $type = is_array($config) + ? UserType::fromArray($name, $config) + : ($this->types[$name] ?? new UserType($name)); + + if ($config instanceof Closure) { + $config($type); + } + + return $this->types[$name] = $type; + } + + public function has(string $name): bool + { + $this->loadConfig(); + + return isset($this->types[$name]); + } + + public function get(string $name): UserType + { + $this->loadConfig(); + + return $this->types[$name] + ?? throw new InvalidArgumentException("No user type named `{$name}`. Add it to javaabu-auth.user_types or register it with UserTypes::register()."); + } + + /** @return array */ + public function all(): array + { + $this->loadConfig(); + + return $this->types; + } + + /** + * The first registered user type + */ + public function default(): UserType + { + $types = $this->all(); + + if (! $types) { + throw new InvalidArgumentException('No user types are registered.'); + } + + return reset($types); + } + + /** + * The user type of a model or model class; exact classes win over parent classes + * + * @param Model|class-string $user + */ + public function for(Model|string $user): ?UserType + { + $class = is_string($user) ? $user : $user::class; + + foreach ($this->all() as $type) { + if ($type->modelClass() === $class) { + return $type; + } + } + + foreach ($this->all() as $type) { + if (is_a($class, $type->modelClass(), true)) { + return $type; + } + } + + return null; + } + + public function forGuard(?string $guard): ?UserType + { + $guard ??= config('auth.defaults.guard'); + + foreach ($this->all() as $type) { + if ($type->guardName() === $guard) { + return $type; + } + } + + return null; + } + + /** + * The user type of the current route, from `Route::authRoutes()` or the `user-type` middleware + */ + public function fromRequest(?Request $request = null): ?UserType + { + $request ??= request(); + + $name = $request->attributes->get(self::ROUTE_ACTION) + ?? $request->route()?->getAction(self::ROUTE_ACTION); + + return $name ? $this->get($name) : null; + } + + public function forget(string $name): void + { + unset($this->types[$name]); + } + + public function flush(): void + { + $this->types = []; + $this->loaded_config = true; + } + + protected function loadConfig(): void + { + if ($this->loaded_config) { + return; + } + + $this->loaded_config = true; + + foreach (config('javaabu-auth.user_types', []) as $name => $config) { + $this->types[$name] = UserType::fromArray($name, $config); + } + } +} diff --git a/src/UserTypes.php b/src/UserTypes.php new file mode 100644 index 0000000..c1d4c3c --- /dev/null +++ b/src/UserTypes.php @@ -0,0 +1,30 @@ + all() + * @method static UserType default() + * @method static UserType|null for(Model|string $user) + * @method static UserType|null forGuard(?string $guard) + * @method static UserType|null fromRequest(?Request $request = null) + * @method static void forget(string $name) + * @method static void flush() + * + * @see UserTypeRegistry + */ +class UserTypes extends Facade +{ + protected static function getFacadeAccessor(): string + { + return UserTypeRegistry::class; + } +} diff --git a/src/VerifiesEmails.php b/src/VerifiesEmails.php deleted file mode 100644 index 4205723..0000000 --- a/src/VerifiesEmails.php +++ /dev/null @@ -1,158 +0,0 @@ -user(); - $message = $user->status_message; - - return $this->showEmailVerificationForm($request, $user, $message); - } - - /** - * Show verified message - * - * @param string $message - * @return \Illuminate\Http\Response|\Illuminate\View\View - */ - abstract public function showEmailVerificationForm(Request $request, User $user, $message); - - /** - * Show verification result message - * - * @param null $data - * @param null $errors - * @return Response|View - */ - abstract public function showVerificationResult(Request $request, $data = null, $errors = null); - - /** - * Mark the authenticated user's email address as verified. - * - * @return \Illuminate\Http\Response - * - * @throws \Illuminate\Auth\Access\AuthorizationException - */ - public function verify(Request $request) - { - /** @var User $user */ - $user = $request->user(); - - try { - if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) { - throw new AuthorizationException(); - } - - if (! hash_equals((string) $request->route('hash'), sha1($user->getEmailForVerification()))) { - throw new AuthorizationException(); - } - } catch (AuthorizationException $e) { - return $this->showVerificationResult($request, null, ['token' => __('Verification token is invalid.')]); - } - - if (! $user->needsEmailVerification()) { - return $request->wantsJson() - ? new Response('', 204) - : redirect($this->redirectPath()); - } - - $new_email = null; - - if ($user->wantsNewEmail()) { - $new_email = $user->new_email; - - if (! $user->isNewEmailAvailable()) { - return $request->wantsJson() - ? new Response('', 204) - : $this->showVerificationResult( - $request, - ['email_unavailable' => true], - ['email' => __('The email :new_email is no longer available.', compact('new_email'))] - ); - } - - if ($user->updateEmail()) { - event(new EmailUpdated($user)); - } - } else { - if ($user->markEmailAsVerified()) { - event(new Verified($user)); - } - - if ($response = $this->verified($request)) { - return $response; - } - } - - if ($request->wantsJson()) { - return new Response('', 204); - } - - return $new_email ? - $this->showVerificationResult($request, [ - 'message' => __('Your email has been updated successfully to :new_email.', compact('new_email')), - 'email_updated' => true, - ]) : - $this->showVerificationResult($request, [ - 'message' => __('Your email has been verified successfully.'), - 'verified' => true, - ]); - } - - /** - * Resend the email verification notification. - * - * @return \Illuminate\Http\Response|\Illuminate\Http\Response - */ - public function resend(Request $request) - { - /** @var User $user */ - $user = $request->user(); - - if (! $user->needsEmailVerification()) { - return $request->wantsJson() - ? new Response('', 204) - : redirect($this->redirectPath()); - } - - $user->sendEmailVerificationNotification(); - - return $request->wantsJson() - ? new Response('', 202) - : $this->sendVerifyLinkResponse(); - } - - /** - * Get the response for a successful email verification link. - * - * @return \Illuminate\Http\Response|RedirectResponse - */ - protected function sendVerifyLinkResponse() - { - $status = __('We\'ve emailed you the verification link'); - - return back()->with([ - 'status' => $status, - 'resent' => true, - ]); - } -} diff --git a/src/Views/ViewRenderer.php b/src/Views/ViewRenderer.php new file mode 100644 index 0000000..18d4a21 --- /dev/null +++ b/src/Views/ViewRenderer.php @@ -0,0 +1,70 @@ + $data + * @param string $layout `auth` for guest pages, `account` for signed-in pages + */ + public function render(UserType $type, string $page, array $data = [], string $layout = 'auth'): View|Responsable + { + $data = [...$this->page_data->shared($type), ...$data]; + + if ($type->viewStack() === self::INERTIA) { + return $this->renderInertia($page, $data); + } + + return view("auth::{$page}", [ + ...$data, + 'user_type' => $type, + 'layout' => $type->layoutName($layout), + 'layout_section' => config('javaabu-auth.layout_section', 'content'), + ]); + } + + /** + * The Inertia page name for a page: `settings.profile` → `auth::Settings/Profile` + */ + public function inertiaPage(string $page): string + { + $segments = array_map(fn (string $segment) => str($segment)->studly()->toString(), explode('.', $page)); + + return 'auth::' . implode('/', $segments); + } + + /** + * @param array $data + */ + protected function renderInertia(string $page, array $data): Responsable + { + if (! class_exists(Inertia::class)) { + throw new RuntimeException('Install inertiajs/inertia-laravel to use the inertia views, or set javaabu-auth.views to blade.'); + } + + return Inertia::render($this->inertiaPage($page), [ + ...$data, + 'strings' => $this->page_data->strings(), + ]); + } +} diff --git a/tests/Factories/AdminFactory.php b/tests/Factories/AdminFactory.php new file mode 100644 index 0000000..690bc5b --- /dev/null +++ b/tests/Factories/AdminFactory.php @@ -0,0 +1,50 @@ + + */ +class AdminFactory extends Factory +{ + protected $model = Admin::class; + + public function definition(): array + { + return [ + 'name' => 'އަޙްމަދު ޢަލީ', + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => 'password', + 'status' => UserStatuses::APPROVED, + ]; + } + + public function unverified(): static + { + return $this->state(['email_verified_at' => null]); + } + + public function pending(): static + { + return $this->state(['status' => UserStatuses::PENDING]); + } + + public function banned(): static + { + return $this->state(['status' => UserStatuses::BANNED]); + } + + public function withTwoFactor(string $secret = 'JBSWY3DPEHPK3PXP'): static + { + return $this->state([ + 'two_factor_secret' => encrypt($secret), + 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1', 'recovery-code-2'])), + 'two_factor_confirmed_at' => now(), + ]); + } +} diff --git a/tests/Factories/UserFactory.php b/tests/Factories/UserFactory.php new file mode 100644 index 0000000..1e37fd7 --- /dev/null +++ b/tests/Factories/UserFactory.php @@ -0,0 +1,50 @@ + + */ +class UserFactory extends Factory +{ + protected $model = User::class; + + public function definition(): array + { + return [ + 'name' => 'އަޙްމަދު ޢަލީ', + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => 'password', + 'status' => UserStatuses::APPROVED, + ]; + } + + public function unverified(): static + { + return $this->state(['email_verified_at' => null]); + } + + public function pending(): static + { + return $this->state(['status' => UserStatuses::PENDING]); + } + + public function banned(): static + { + return $this->state(['status' => UserStatuses::BANNED]); + } + + public function withTwoFactor(string $secret = 'JBSWY3DPEHPK3PXP'): static + { + return $this->state([ + 'two_factor_secret' => encrypt($secret), + 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1', 'recovery-code-2'])), + 'two_factor_confirmed_at' => now(), + ]); + } +} diff --git a/tests/Feature/ConfirmPasswordControllerTest.php b/tests/Feature/ConfirmPasswordControllerTest.php deleted file mode 100644 index 790e7b7..0000000 --- a/tests/Feature/ConfirmPasswordControllerTest.php +++ /dev/null @@ -1,88 +0,0 @@ -runMigrations(); - $this->seedDefaultUsers(); - } - - #[Test] - public function it_redirects_to_the_password_confirmation_page_if_the_user_is_required_to_confirm_the_password() - { - $user = $this->getUser('user@example.com'); - - $this->actingAs($user, 'web'); - - $this->get('/test') - ->assertRedirect('/password/confirm'); - } - - #[Test] - public function it_can_display_the_password_confirmation_page() - { - $this->withoutExceptionHandling(); - $user = $this->getUser('user@example.com'); - - $this->actingAs($user, 'web'); - - $this->get('/password/confirm') - ->assertStatus(200) - ->assertSee('Confirm Password'); - } - - #[Test] - public function it_does_not_allow_the_password_to_be_confirmed_using_an_invalid_current_password() - { - $user = $this->getUser('user@example.com'); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->actingAs($user, 'web'); - - $this->post('/password/confirm', [ - 'password' => '12312312', - ]) - ->assertSessionHasErrors('password') - ->assertSessionMissing('web.auth.password_confirmed_at') - ->assertRedirect(); - } - - #[Test] - public function it_can_confirm_the_password() - { - $this->withoutExceptionHandling(); - - $user = $this->getUser('user@example.com'); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->actingAs($user, 'web'); - - $this->followingRedirects() - ->get('/test') - ->assertStatus(200) - ->assertSee('Confirm Password'); - - $this->post("/password/confirm", [ - 'password' => 'password', - ]) - ->assertSessionMissing('errors') - ->assertSessionHas('web.auth.password_confirmed_at') - ->assertRedirect('/test'); - } -} diff --git a/tests/Feature/ForgotPasswordControllerTest.php b/tests/Feature/ForgotPasswordControllerTest.php deleted file mode 100644 index 593ac05..0000000 --- a/tests/Feature/ForgotPasswordControllerTest.php +++ /dev/null @@ -1,109 +0,0 @@ -runMigrations(); - $this->seedDefaultUsers(); - } - - #[Test] - public function it_can_display_the_forgot_password_page() - { - $this->get('/password/reset') - ->assertStatus(200) - ->assertSee('Forgot Password'); - } - - #[Test] - public function it_can_send_the_forgot_password_link() - { - $user = $this->getUser('user@example.com'); - - $this->post('/password/email', [ - 'email' => 'user@example.com', - ]) - ->assertSessionMissing('errors'); - - Notification::assertSentToTimes( - $user, - ResetPassword::class, - ); - } - - #[Test] - public function it_records_the_password_reset_link_sent_event() - { - $this->withoutExceptionHandling(); - - $now = '2024-09-08 12:56:00'; - - $this->travelTo($now); - - $user = $this->getUser('user@example.com'); - - $this->post('/password/email', [ - 'email' => 'user@example.com', - ]) - ->assertSessionMissing('errors') - ->assertRedirect(); - - Notification::assertSentToTimes( - $user, - ResetPassword::class, - ); - - /** @var Activity $log */ - $log = Activity::latest('id')->first(); - - $this->assertDatabaseHas('activity_log', [ - 'id' => $log->id, - 'description' => 'password_reset_link_sent', - 'causer_type' => $user->getMorphClass(), - 'causer_id' => $user->id, - 'created_at' => $now, - ]); - } - - #[Test] - public function it_can_validate_the_forgot_password_inputs() - { - $user = $this->getUser('user@example.com'); - - $this->post('/password/email', [ - 'email' => 'another-user@example.com', - ]) - ->assertSessionHasErrors('email'); - - Notification::assertNotSentTo( - [$user], - ResetPassword::class - ); - - $this->post('/password/email', [ - 'email' => '', - ]) - ->assertSessionHasErrors('email'); - - Notification::assertNotSentTo( - [$user], - ResetPassword::class - ); - } -} diff --git a/tests/Feature/Http/Controllers/ConfirmPasswordController.php b/tests/Feature/Http/Controllers/ConfirmPasswordController.php deleted file mode 100644 index 08f7967..0000000 --- a/tests/Feature/Http/Controllers/ConfirmPasswordController.php +++ /dev/null @@ -1,23 +0,0 @@ -middleware('guest:web'); - } - - /** - * Get the broker to be used during password reset. - * - * @return \Illuminate\Contracts\Auth\PasswordBroker - */ - public function getBroker(): PasswordBroker - { - return Password::broker('users'); - } - - /** - * Display the form to request a password reset link. - */ - public function getPasswordResetForm(): View - { - return view('passwords.email'); - } -} diff --git a/tests/Feature/Http/Controllers/HomeController.php b/tests/Feature/Http/Controllers/HomeController.php deleted file mode 100644 index dd33674..0000000 --- a/tests/Feature/Http/Controllers/HomeController.php +++ /dev/null @@ -1,14 +0,0 @@ -user(); - } -} diff --git a/tests/Feature/Http/Controllers/LoginController.php b/tests/Feature/Http/Controllers/LoginController.php deleted file mode 100644 index d9573bb..0000000 --- a/tests/Feature/Http/Controllers/LoginController.php +++ /dev/null @@ -1,20 +0,0 @@ -user(); - - if ($action = $request->input('action')) { - switch ($action) { - case 'update_password': - // update password - if ($password = $request->input('password')) { - $user->password = $password; - $user->save(); - - flash_push('alerts', [ - 'text' => __('Password changed successfully'), - 'type' => 'success', - 'title' => __('Success!'), - ]); - } - break; - } - } else { - //update the email - if (($new_email = $request->email) && $new_email != $user->email) { - $new_email = $user->requestEmailUpdate($new_email); - if ($new_email) { - flash_push('alerts', [ - 'text' => __('We\'ve sent a verification link to :new_email. Please verify the new email address to update it', compact('new_email')), - 'type' => 'success', - 'title' => __('New Email Verification Sent'), - ]); - } - } - - $user->fill($request->all()); - $user->save(); - - //update avatar - $user->updateSingleMedia('avatar', $request); - - $this->flashSuccessMessage(); - } - - return $this->redirect($request, route('user.account')); - } -} diff --git a/tests/Feature/Http/Controllers/VerificationController.php b/tests/Feature/Http/Controllers/VerificationController.php deleted file mode 100644 index f2f0d98..0000000 --- a/tests/Feature/Http/Controllers/VerificationController.php +++ /dev/null @@ -1,25 +0,0 @@ -user() instanceof User) { - if ($user = $this->getRouteUser()) { - return $user->id == $this->user()->id; - } else { - return if_route_pattern('account.*'); - } - } - - return false; - } - - protected function getRouteUser(): ?User - { - return $this->route('user'); - } -} diff --git a/tests/Feature/LoginControllerTest.php b/tests/Feature/LoginControllerTest.php deleted file mode 100644 index d1823cb..0000000 --- a/tests/Feature/LoginControllerTest.php +++ /dev/null @@ -1,351 +0,0 @@ -runMigrations(); - $this->seedDefaultUsers(); - } - - #[Test] - public function it_can_show_the_login_form_page(): void - { - $this->get('/login') - ->assertStatus(200) - ->assertViewIs('login'); - } - - #[Test] - public function it_can_login_a_user(): void - { - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $this->assertEquals($user->id, Auth::guard('web')->id()); - } - - #[Test] - public function it_records_the_login_event() - { - $now = '2024-09-08 12:56:00'; - - $this->travelTo($now); - - $user = $this->getUser('user@example.com'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'last_login_at' => null, - ]); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $this->assertEquals($user->id, Auth::guard('web')->id()); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'last_login_at' => $now, - ]); - - /** @var Activity $log */ - $log = Activity::latest('id')->first(); - - $this->assertDatabaseHas('activity_log', [ - 'id' => $log->id, - 'description' => 'login', - 'causer_type' => $user->getMorphClass(), - 'causer_id' => $user->id, - 'created_at' => $now, - ]); - } - - #[Test] - public function it_can_logout_a_user(): void - { - $this->withoutExceptionHandling(); - - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $this->assertEquals($user->id, Auth::guard('web')->id()); - - $this->post('/logout') - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $this->assertNull(Auth::guard('web')->id(), 'Invalid logged in user id'); - } - - #[Test] - public function it_records_logout_events(): void - { - $this->withoutExceptionHandling(); - - $now = '2024-09-08 12:56:00'; - - $this->travelTo($now); - - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $this->assertEquals($user->id, Auth::guard('web')->id()); - - $this->post('/logout') - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $this->assertNull(Auth::guard('web')->id(), 'Invalid logged in user id'); - - /** @var Activity $log */ - $log = Activity::latest('id')->first(); - - $this->assertDatabaseHas('activity_log', [ - 'id' => $log->id, - 'description' => 'logout', - 'causer_type' => $user->getMorphClass(), - 'causer_id' => $user->id, - 'created_at' => $now, - ]); - } - - #[Test] - public function it_can_validate_the_login_inputs() - { - $this->post('/login', [ - 'email' => '', - 'password' => '', - ]) - ->assertSessionHasErrors('password', 'email'); - } - - #[Test] - public function it_does_not_allow_a_user_to_be_logged_in_using_an_invalid_password() - { - $this->post('/login', [ - 'email' => 'user@example.com', - 'password' => '9876544', - ]) - ->assertSessionHasErrors('email'); - - $this->assertNull(Auth::guard('web')->id(), 'Invalid logged in user id'); - } - - #[Test] - public function it_records_failed_login_events() - { - $now = '2024-09-08 12:56:00'; - - $this->travelTo($now); - - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => 'user@example.com', - 'password' => '9876544', - ]) - ->assertSessionHasErrors('email'); - - $this->assertNull(Auth::guard('web')->id(), 'Invalid logged in user id'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'last_login_at' => null, - ]); - - /** @var Activity $log */ - $log = Activity::latest('id')->first(); - - $this->assertDatabaseHas('activity_log', [ - 'id' => $log->id, - 'description' => 'failed_login', - 'causer_type' => $user->getMorphClass(), - 'causer_id' => $user->id, - 'created_at' => $now, - ]); - } - - #[Test] - public function it_increments_the_users_login_attempts_if_an_invalid_password_is_entered() - { - $user = $this->getUser('user@example.com'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => null, - ]); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'not-the-password', - ]) - ->assertSessionHasErrors('email'); - - $this->assertNull(Auth::guard('web')->id(), 'Invalid logged in user id'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => 1, - ]); - } - - #[Test] - public function it_records_lockout_events() - { - $now = '2024-09-08 12:56:00'; - - $this->travelTo($now); - - $user = $this->getUser('user@example.com'); - - for ($i = 0; $i < 6; $i++) { - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'not-the-password', - ]) - ->assertSessionHasErrors('email'); - - $this->assertNull(Auth::guard('web')->id(), 'Invalid logged in user id'); - } - - /** @var Activity $log */ - $log = Activity::latest('id')->first(); - - $this->assertDatabaseHas('activity_log', [ - 'id' => $log->id, - 'description' => 'lockout', - 'created_at' => $now, - ]); - } - - #[Test] - public function it_resets_the_login_attempts_when_the_correct_password_is_entered() - { - $this->withoutExceptionHandling(); - $user = $this->getUser('user@example.com'); - - $user->login_attempts = 2; - $user->save(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => 2, - ]); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionMissing('errors') - ->assertRedirect(); - - $this->assertEquals($user->id, Auth::guard('web')->id(), 'Invalid logged in user id'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => null, - ]); - } - - #[Test] - public function it_does_not_reset_the_login_attempts_if_the_user_is_already_locked_out_even_when_the_correct_password_is_entered() - { - $user = $this->getUser('user@example.com'); - - $user->login_attempts = 5; - $user->save(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => 5, - ]); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionMissing('errors') - ->assertRedirect(); - - $this->assertEquals($user->id, Auth::guard('web')->id(), 'Invalid logged in user id'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => 5, - ]); - } - - #[Test] - public function it_shows_a_lock_out_message_if_there_are_too_many_login_attempts() - { - $this->withoutExceptionHandling(); - $user = $this->getUser('user@example.com'); - - $user->login_attempts = 6; - $user->save(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => 6, - ]); - - $response = $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionMissing('errors') - ->assertRedirect(); - - $response = $this->get($response->headers->get('Location')) - ->assertRedirect('/verify'); - - $this->get($response->headers->get('Location')) - ->assertStatus(200) - ->assertSee('Your account has been locked due to too many login attempts'); - - $this->assertEquals($user->id, Auth::guard('web')->id(), 'Invalid logged in user id'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => 6, - ]); - } -} diff --git a/tests/Feature/LoginTest.php b/tests/Feature/LoginTest.php new file mode 100644 index 0000000..30031e5 --- /dev/null +++ b/tests/Feature/LoginTest.php @@ -0,0 +1,200 @@ +get('/login') + ->assertOk() + ->assertSee('data-auth-page="login"', false) + ->assertSee(route('login.store'), false) + ->assertSee(route('register'), false); + + $this->get('/admin/login') + ->assertOk() + ->assertSee(route('admin.login.store'), false) + ->assertDontSee(route('register'), false); + } + + #[Test] + public function it_logs_a_user_in_on_their_own_guard(): void + { + $user = User::factory()->create(['email' => 'ahmed@example.com']); + + Event::fake([Login::class]); + + $this->post('/login', ['email' => 'Ahmed@Example.com', 'password' => 'password']) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user, 'web'); + $this->assertGuest('web_admin'); + + Event::assertDispatched(Login::class, fn (Login $event) => $event->guard === 'web'); + } + + #[Test] + public function it_logs_an_admin_in_on_the_admin_guard(): void + { + $admin = Admin::factory()->create(['email' => 'admin@example.com']); + + $this->post('/admin/login', ['email' => 'admin@example.com', 'password' => 'password']) + ->assertRedirect('/admin'); + + $this->assertAuthenticatedAs($admin, 'web_admin'); + $this->assertGuest('web'); + } + + #[Test] + public function a_user_cannot_log_in_on_the_admin_login_page(): void + { + User::factory()->create(['email' => 'ahmed@example.com']); + + $this->post('/admin/login', ['email' => 'ahmed@example.com', 'password' => 'password']) + ->assertSessionHasErrors('email'); + + $this->assertGuest('web'); + $this->assertGuest('web_admin'); + } + + #[Test] + public function it_rejects_a_wrong_password_and_fires_the_failed_event(): void + { + User::factory()->create(['email' => 'ahmed@example.com']); + + Event::fake([Failed::class]); + + $this->from('/login') + ->post('/login', ['email' => 'ahmed@example.com', 'password' => 'wrong']) + ->assertRedirect('/login') + ->assertSessionHasErrors(['email' => 'These credentials do not match our records.']); + + $this->assertGuest('web'); + + Event::assertDispatched(Failed::class); + } + + #[Test] + public function it_rate_limits_login_attempts(): void + { + config()->set('javaabu-auth.max_login_attempts', null); + + User::factory()->create(['email' => 'ahmed@example.com']); + + foreach (range(1, 5) as $attempt) { + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'wrong']); + } + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']) + ->assertSessionHasErrors('email'); + + $this->assertGuest('web'); + } + + #[Test] + public function it_locks_the_account_after_too_many_failed_logins(): void + { + config()->set('javaabu-auth.max_login_attempts', 3); + config()->set('javaabu-auth.rate_limits.login', 100); + + $user = User::factory()->create(['email' => 'ahmed@example.com']); + + foreach (range(1, 3) as $attempt) { + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'wrong']); + } + + $this->assertTrue($user->fresh()->isLockedOut()); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']) + ->assertSessionHasErrors('email'); + + $this->assertGuest('web'); + } + + #[Test] + public function it_resets_failed_logins_after_a_successful_login(): void + { + $user = User::factory()->create(['email' => 'ahmed@example.com', 'login_attempts' => 2]); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']) + ->assertRedirect('/dashboard'); + + $this->assertNull($user->fresh()->login_attempts); + } + + #[Test] + public function it_sends_signed_in_users_away_from_the_login_page(): void + { + $this->actingAs(User::factory()->create(), 'web') + ->get('/login') + ->assertRedirect('/dashboard'); + + // signed in as a user, but not as an admin + $this->get('/admin/login')->assertOk(); + } + + #[Test] + public function it_returns_json_for_json_logins(): void + { + User::factory()->create(['email' => 'ahmed@example.com']); + + $this->postJson('/login', ['email' => 'ahmed@example.com', 'password' => 'password']) + ->assertOk() + ->assertJson(['two_factor' => false, 'redirect' => url('/dashboard')]); + } + + #[Test] + public function it_logs_out_only_the_guard_of_the_route(): void + { + $user = User::factory()->create(); + $admin = Admin::factory()->create(); + + $this->actingAs($user, 'web')->actingAs($admin, 'web_admin'); + + $this->post('/admin/logout')->assertRedirect('/admin/login'); + + $this->assertGuest('web_admin'); + $this->assertAuthenticatedAs($user, 'web'); + } + + #[Test] + public function it_redirects_guests_to_the_login_page_of_the_type(): void + { + $this->get('/dashboard')->assertRedirect('/login'); + $this->get('/admin')->assertRedirect('/admin/login'); + } + + #[Test] + public function it_sends_pending_and_banned_users_to_the_inactive_page(): void + { + $user = User::factory()->pending()->create(); + + $this->actingAs($user, 'web') + ->get('/dashboard') + ->assertRedirect(route('account.inactive')); + + $this->get(route('account.inactive')) + ->assertOk() + ->assertSee('Your account needs to be approved before you can access it.'); + } + + #[Test] + public function it_sends_users_with_unverified_emails_to_the_verification_notice(): void + { + $user = User::factory()->unverified()->create(); + + $this->actingAs($user, 'web') + ->get('/dashboard') + ->assertRedirect(route('verification.notice')); + } +} diff --git a/tests/Feature/Models/User.php b/tests/Feature/Models/User.php deleted file mode 100644 index b961904..0000000 --- a/tests/Feature/Models/User.php +++ /dev/null @@ -1,77 +0,0 @@ -app['config']->set('session.driver', 'multi_auth_database'); - - $this->runMigrations(); - $this->seedDefaultUsers(); - } - - #[Test] - public function it_records_the_user_type_in_the_sessions_table(): void - { - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $session_id = session()->getId(); - - $this->assertDatabaseHas('sessions', [ - 'id' => $session_id, - 'user_id' => $user->id, - 'user_type' => $user->getMorphClass(), - ]); - } - - #[Test] - public function it_can_record_session_data_even_when_not_logged_in(): void - { - $this->post('/session-test') - ->assertSessionHas('test_key', 'test_value') - ->assertSuccessful(); - - $session_id = session()->getId(); - - $this->assertDatabaseHas('sessions', [ - 'id' => $session_id, - 'user_id' => null, - 'user_type' => null, - ]); - } - - #[Test] - public function it_can_retrieve_the_sessions_of_a_user(): void - { - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $session_id = session()->getId(); - - $last_session = $user->sessions()->first(); - - $this->assertEquals($session_id, $last_session->id); - $this->assertEquals($user->id, $last_session->user_id); - $this->assertEquals($user->getMorphClass(), $last_session->user_type); - $this->assertEquals('user@example.com', $last_session->user->email); - } - - #[Test] - public function it_knows_the_current_device(): void - { - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ])->withoutCookie('laravel_session'); - - $current_session_id = session()->getId(); - - /** @var Session $current_session */ - $current_session = $user->sessions()->whereId($current_session_id)->first(); - - Model::unguard(); - - $old_session = new Session($current_session->getAttributes()); - $old_session->id = $current_session->id . '123'; - $old_session->save(); - - $this->assertEquals($current_session_id, $current_session->id); - $this->assertTrue($current_session->is_current_device); - - $this->assertEquals($user->id, $current_session->user_id); - $this->assertEquals($user->getMorphClass(), $current_session->user_type); - - $old_session = $user->sessions()->whereId($current_session_id . '123')->first(); - - $this->assertEquals($current_session_id . '123', $old_session->id); - $this->assertFalse($old_session->is_current_device); - - $this->assertEquals($user->id, $old_session->user_id); - $this->assertEquals($user->getMorphClass(), $old_session->user_type); - } - - #[Test] - public function it_creates_the_session_user_agent(): void - { - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $session_id = session()->getId(); - - $last_session = $user->sessions()->first(); - - $this->assertEquals($session_id, $last_session->id); - $this->assertEquals($user->id, $last_session->user_id); - $this->assertEquals($user->getMorphClass(), $last_session->user_type); - $this->assertInstanceOf(Agent::class, $last_session->agent); - } - - #[Test] - public function it_records_the_last_activity_time(): void - { - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]) - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/'); - - $session_id = session()->getId(); - - $last_session = $user->sessions()->first(); - - $this->assertEquals($session_id, $last_session->id); - $this->assertEquals($user->id, $last_session->user_id); - $this->assertEquals($user->getMorphClass(), $last_session->user_type); - $this->assertInstanceOf(Carbon::class, $last_session->last_activity); - } - - #[Test] - public function it_can_delete_other_sessions_of_the_user(): void - { - $user = $this->getUser('user@example.com'); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ])->withoutCookie('laravel_session'); - - $current_session_id = session()->getId(); - - /** @var Session $current_session */ - $current_session = $user->sessions()->whereId($current_session_id)->first(); - - Model::unguard(); - - $old_session = new Session($current_session->getAttributes()); - $old_session->id = $current_session->id . '123'; - $old_session->save(); - - $this->assertDatabaseHas('sessions', [ - 'id' => $current_session_id, - 'user_id' => $user->id, - 'user_type' => $user->getMorphClass(), - ]); - - $this->assertDatabaseHas('sessions', [ - 'id' => $current_session->id . '123', - 'user_id' => $user->id, - 'user_type' => $user->getMorphClass(), - ]); - - $user->deleteOtherSessionRecords(); - - $this->assertDatabaseHas('sessions', [ - 'id' => $current_session_id, - ]); - - $this->assertDatabaseMissing('sessions', [ - 'id' => $current_session->id . '123', - ]); - } - -} diff --git a/tests/Feature/RegisterControllerTest.php b/tests/Feature/RegisterControllerTest.php deleted file mode 100644 index 09c544c..0000000 --- a/tests/Feature/RegisterControllerTest.php +++ /dev/null @@ -1,99 +0,0 @@ -runMigrations(); - $this->seedDefaultUsers(); - } - - #[Test] - public function it_can_display_the_user_registration_page() - { - $this->withoutExceptionHandling(); - - $this->get('/register') - ->assertStatus(200) - ->assertSee('Register'); - } - - #[Test] - public function it_can_register_a_user() - { - $this->withoutExceptionHandling(); - - $this->post('/register', [ - 'name' => 'User', - 'email' => 'user@javaabu.com', - 'password' => 'TestPass123', - 'password_confirmation' => 'TestPass123', - ]) - ->assertSessionMissing('errors') - ->assertRedirect(''); - - $user = User::whereEmail('user@javaabu.com')->first(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'name' => 'User', - 'email' => 'user@javaabu.com', - 'email_verified_at' => null, - 'status' => UserStatuses::PENDING, - ]); - - $this->assertEquals($user->id, Auth::guard('web')->id()); - - Notification::assertSentTo( - [$user], - VerifyEmail::class - ); - - Notification::assertSentToTimes( - $user, - VerifyEmail::class, - 1); - } - - #[Test] - public function it_can_validate_the_registration_inputs() - { - $this->post('/register', [ - 'name' => 'User', - ]) - ->assertSessionHasErrors('password', 'email'); - - $this->assertDatabaseMissing('users', [ - 'name' => 'User', - ]); - - $this->post('/register', [ - 'name' => '', - 'email' => '', - 'password' => 'TestPass123', - 'password_confirmation' => 'TestPass123', - ]) - ->assertSessionHasErrors('name', 'email'); - - $this->assertDatabaseMissing('users', [ - 'name' => '', - ]); - } -} diff --git a/tests/Feature/ResetPasswordControllerTest.php b/tests/Feature/ResetPasswordControllerTest.php deleted file mode 100644 index ce929ab..0000000 --- a/tests/Feature/ResetPasswordControllerTest.php +++ /dev/null @@ -1,160 +0,0 @@ -runMigrations(); - $this->seedDefaultUsers(); - } - - /** - * Get the token for the user - * - * @param null $broker - */ - protected function getResetToken($user, $broker = null): string - { - return app(PasswordBrokerManager::class) - ->broker($broker) - ->createToken($user); - } - - #[Test] - public function it_can_display_the_reset_password_page() - { - $user = $this->getUser('user@example.com'); - $token = $this->getResetToken($user); - - $this->get("/password/reset/{$token}") - ->assertStatus(200) - ->assertSee('Reset Password'); - } - - #[Test] - public function it_resets_the_login_attempts_when_the_password_is_reset() - { - $this->withoutExceptionHandling(); - - $user = $this->getUser('user@example.com'); - $user->login_attempts = 6; - $user->save(); - - $token = $this->getResetToken($user, 'users'); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => 6, - ]); - - $this->post('/password/reset', [ - 'token' => $token, - 'email' => 'user@example.com', - 'password' => 'abc12345', - 'password_confirmation' => 'abc12345', - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $user = $user->fresh(); - $this->assertTrue(Hash::check('abc12345', $user->password), 'Invalid password'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'login_attempts' => null, - ]); - } - - #[Test] - public function it_can_reset_the_password() - { - $this->withoutExceptionHandling(); - - $user = $this->getUser('user@example.com'); - $token = $this->getResetToken($user, 'users'); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->post('/password/reset', [ - 'token' => $token, - 'email' => 'user@example.com', - 'password' => 'abc12345', - 'password_confirmation' => 'abc12345', - ]) - ->assertSessionMissing('errors') - ->assertRedirect(); - - $user = $user->fresh(); - $this->assertTrue(Hash::check('abc12345', $user->password), 'Invalid password'); - } - - #[Test] - public function it_records_the_password_reset_event() - { - $this->withoutExceptionHandling(); - - $now = '2024-09-08 12:56:00'; - - $this->travelTo($now); - - $user = $this->getUser('user@example.com'); - $token = $this->getResetToken($user, 'users'); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->post('/password/reset', [ - 'token' => $token, - 'email' => 'user@example.com', - 'password' => 'abc12345', - 'password_confirmation' => 'abc12345', - ]) - ->assertSessionMissing('errors') - ->assertRedirect(); - - $user = $user->fresh(); - $this->assertTrue(Hash::check('abc12345', $user->password), 'Invalid password'); - - $this->assertDatabaseHas('activity_log', [ - 'description' => 'password_reset', - 'causer_type' => $user->getMorphClass(), - 'causer_id' => $user->id, - 'created_at' => $now, - ]); - } - - #[Test] - public function it_does_not_allow_the_password_to_reset_using_an_invalid_token() - { - $user = $this->getUser('user@example.com'); - $token = $this->getResetToken($user); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->post('/password/reset', [ - 'token' => 'invalid_token', - 'email' => 'user@example.com', - 'password' => 'abc12345', - 'password_confirmation' => 'abc12345', - ]) - ->assertSessionHasErrors('email'); - - $user = $user->fresh(); - $this->assertFalse(Hash::check('abc12345', $user->password), 'Invalid password'); - } -} diff --git a/tests/Feature/Rules/UniqueGmailRuleTest.php b/tests/Feature/Rules/UniqueGmailRuleTest.php deleted file mode 100644 index f81ea24..0000000 --- a/tests/Feature/Rules/UniqueGmailRuleTest.php +++ /dev/null @@ -1,149 +0,0 @@ -runMigrations(); - } - - #[Test] - public function it_fails_if_there_is_a_matching_gmail() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $rule = new UniqueGmail(User::class); - $passed = $this->checkRule($rule, 'email', 'ahmed+ali@gmail.com'); - - $this->assertFalse($passed); - } - - #[Test] - public function it_can_specify_custom_field_name() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $rule = new UniqueGmail(User::class, 'email'); - $passed = $this->checkRule($rule, 'name', 'ahmed@gmail.com'); - - $this->assertFalse($passed); - } - - #[Test] - public function it_can_ignore_user_ids() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $rule = (new UniqueGmail(User::class))->ignore($user_1); - $passed = $this->checkRule($rule, 'email', 'ahmed@gmail.com'); - - $this->assertTrue($passed); - } - - #[Test] - public function it_can_ignore_user_custom_user_ids() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $rule = (new UniqueGmail(User::class))->ignore('Ahmed', 'name'); - $passed = $this->checkRule($rule, 'email', 'ahmed@gmail.com'); - - $this->assertTrue($passed); - } - - #[Test] - public function it_passes_if_there_is_no_matching_gmail() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $rule = new UniqueGmail(User::class); - $passed = $this->checkRule($rule, 'email', 'mohemd+ali@gmail.com'); - - $this->assertTrue($passed); - } - - #[Test] - public function it_passes_if_it_is_not_a_gmail() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $rule = new UniqueGmail(User::class); - $passed = $this->checkRule($rule, 'email', 'ahmed@hotmail.com'); - - $this->assertTrue($passed); - } -} diff --git a/tests/Feature/UpdatePasswordControllerTest.php b/tests/Feature/UpdatePasswordControllerTest.php deleted file mode 100644 index 284bca8..0000000 --- a/tests/Feature/UpdatePasswordControllerTest.php +++ /dev/null @@ -1,212 +0,0 @@ -runMigrations(); - $this->seedDefaultUsers(); - } - - #[Test] - public function it_redirects_to_the_password_update_page_if_the_user_is_required_to_update_the_password() - { - $user = $this->getUser('user@example.com'); - - $user->require_password_update = true; - $user->save(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => true, - ]); - - $this->actingAs($user, 'web'); - - $this->get('') - ->assertRedirect('/password/update'); - } - - #[Test] - public function it_redirects_to_the_dashboard_if_password_update_is_not_required() - { - $user = $this->getUser('user@example.com'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => false, - ]); - - $this->actingAs($user, 'web'); - - $this->get('/password/update') - ->assertRedirect('') - ->assertDontSee('Update Password'); - } - - #[Test] - public function it_can_display_the_password_update_page() - { - $user = $this->getUser('user@example.com'); - - $user->require_password_update = true; - $user->save(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => true, - ]); - - $this->actingAs($user, 'web'); - - $this->get('/password/update') - ->assertStatus(200) - ->assertSee('Update Password'); - } - - #[Test] - public function it_does_not_allow_the_password_to_be_updated_using_an_invalid_current_password() - { - $user = $this->getUser('user@example.com'); - - $user->require_password_update = true; - $user->save(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => true, - ]); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->actingAs($user, 'web'); - - $this->post('/password/update', [ - 'current_password' => '12312312', - 'password' => 'abc12345', - 'password_confirmation' => 'abc12345', - ]) - ->assertSessionHasErrors('current_password') - ->assertRedirect(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => true, - ]); - - $user = $user->fresh(); - $this->assertFalse(Hash::check('abc12345', $user->password), 'Invalid password'); - } - - #[Test] - public function it_does_not_allow_the_password_to_be_updated_using_a_same_password_as_the_current_password() - { - $user = $this->getUser('user@example.com'); - - $user->require_password_update = true; - $user->save(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => true, - ]); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->actingAs($user, 'web'); - - $this->post('/password/update', [ - 'current_password' => 'password', - 'password' => 'password', - 'password_confirmation' => 'password', - ]) - ->assertSessionHasErrors('password') - ->assertRedirect(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => true, - ]); - } - - #[Test] - public function it_requires_the_new_password_to_be_confirmed_when_updating_the_password() - { - $user = $this->getUser('user@example.com'); - - $user->require_password_update = true; - $user->save(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => true, - ]); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->actingAs($user, 'web'); - - $this->post('/password/update', [ - 'current_password' => 'password', - 'password' => 'abc12345', - 'password_confirmation' => 'abc1234', - ]) - ->assertSessionHasErrors('password') - ->assertRedirect(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => true, - ]); - - $user = $user->fresh(); - $this->assertFalse(Hash::check('abc12345', $user->password), 'Invalid password'); - } - - #[Test] - public function it_can_update_the_password() - { - $user = $this->getUser('user@example.com'); - - $user->require_password_update = true; - $user->save(); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => true, - ]); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->actingAs($user, 'web'); - - $this->post('/password/update', [ - 'current_password' => 'password', - 'password' => 'abc12345', - 'password_confirmation' => 'abc12345', - ]) - ->assertSessionMissing('errors') - ->assertRedirect(''); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'require_password_update' => false, - ]); - - $user = $user->fresh(); - $this->assertTrue(Hash::check('abc12345', $user->password), 'Invalid password'); - } -} diff --git a/tests/Feature/UserControllerTest.php b/tests/Feature/UserControllerTest.php deleted file mode 100644 index 947afcb..0000000 --- a/tests/Feature/UserControllerTest.php +++ /dev/null @@ -1,66 +0,0 @@ -runMigrations(); - $this->seedDefaultUsers(); - } - - #[Test] - public function it_records_the_password_changed_event() - { - $this->withoutExceptionHandling(); - - $now = '2024-09-08 12:56:00'; - - $this->travelTo($now); - - $user = $this->getUser('user@example.com'); - - $this->assertTrue(Hash::check('password', $user->password), 'Invalid password'); - - $this->actingAs($user); - - $this->patch('/account', [ - 'action' => 'update_password', - 'current_password' => 'password', - 'password' => '12345678', - 'password_confirmation' => '12345678', - ]) - ->assertSessionDoesntHaveErrors() - ->assertRedirect('/account'); - - $user->fresh(); - $this->assertTrue(Hash::check('12345678', $user->password), 'Invalid password'); - - /** @var Activity $log */ - $log = Activity::latest('id')->first(); - - $this->assertDatabaseHas('activity_log', [ - 'id' => $log->id, - 'description' => 'password_changed', - 'causer_type' => $user->getMorphClass(), - 'causer_id' => $user->id, - 'subject_type' => $user->getMorphClass(), - 'subject_id' => $user->id, - 'created_at' => $now, - ]); - } -} diff --git a/tests/Feature/UserFactory.php b/tests/Feature/UserFactory.php deleted file mode 100644 index fae114f..0000000 --- a/tests/Feature/UserFactory.php +++ /dev/null @@ -1,8 +0,0 @@ -runMigrations(); - $this->seedDefaultUsers(); - } - - #[Test] - public function it_redirects_to_email_verification_page_if_the_user_is_not_verified() - { - $user = User::factory() - ->unverified() - ->pending() - ->create([ - 'email' => 'verification-test-user@example.com', - ]); - - $this->actingAs($user); - - $this->get('/') - ->assertRedirect('/verify'); - - $this->get('/verify') - ->assertSee('Resend Verification'); - } - - #[Test] - public function it_can_resend_the_email_verification() - { - $user = User::factory() - ->unverified() - ->pending() - ->create([ - 'email' => 'verification-test-user@example.com', - ]); - - $this->actingAs($user); - - $this->post('/verify/email/resend') - ->assertSessionMissing('errors'); - - Notification::assertSentTo( - [$user], - VerifyEmail::class - ); - } - - #[Test] - public function it_can_verify_the_email() - { - $this->withoutExceptionHandling(); - - $user = User::factory() - ->unverified() - ->pending() - ->create([ - 'email' => 'verification-test-user@example.com', - ]); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'status' => UserStatuses::PENDING, - 'email_verified_at' => null, - ]); - - $this->actingAs($user); - - $url = URL::temporarySignedRoute( - $user->getRouteForEmailVerification(), - \Illuminate\Support\Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), - [ - 'id' => $user->getKey(), - 'hash' => sha1($user->getEmailForVerification()), - ] - ); - - $this->get($url) - ->assertStatus(200) - ->assertSessionMissing('errors') - ->assertSee('Your email has been verified successfully'); - - $this->assertNotNull($user->email_verified_at); - } - - #[Test] - public function it_can_verify_the_new_email() - { - $this->withoutExceptionHandling(); - - $user = User::factory() - ->active() - ->create([ - 'email' => 'old@example.com', - 'new_email' => 'new-email@example.com' - ]); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'email' => 'old@example.com', - 'new_email' => 'new-email@example.com', - ]); - - $this->actingAs($user); - - $url = URL::temporarySignedRoute( - $user->getRouteForEmailVerification(), - \Illuminate\Support\Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), - [ - 'id' => $user->getKey(), - 'hash' => sha1($user->getEmailForVerification()), - ] - ); - - $this->get($url) - ->assertStatus(200) - ->assertSessionMissing('errors') - ->assertSee('Your email has been updated successfully to new-email@example.com'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'email' => 'new-email@example.com', - 'new_email' => null, - ]); - - Mail::assertSent(EmailUpdated::class, function (EmailUpdated $mail) { - return $mail->hasTo('old@example.com'); - }); - } - - #[Test] - public function it_does_not_allow_the_email_to_be_verified_using_an_invalid_token() - { - $this->withoutExceptionHandling(); - $user = User::factory() - ->unverified() - ->pending() - ->create([ - 'email' => 'verification-test-user@example.com', - ]); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'status' => UserStatuses::PENDING, - 'email_verified_at' => null, - ]); - - $this->actingAs($user); - - $url = URL::temporarySignedRoute( - $user->getRouteForEmailVerification(), - \Illuminate\Support\Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), - [ - 'id' => $user->getKey() + 1, - 'hash' => sha1($user->getEmailForVerification()), - ] - ); - - $thing = $this->get($url) - ->assertStatus(200) - ->assertSessionMissing('errors') - ->assertSee('Verification token is invalid'); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'status' => UserStatuses::PENDING, - 'email_verified_at' => null, - ]); - } -} diff --git a/tests/Feature/views/login.blade.php b/tests/Feature/views/login.blade.php deleted file mode 100644 index bc755d2..0000000 --- a/tests/Feature/views/login.blade.php +++ /dev/null @@ -1 +0,0 @@ -Login Form diff --git a/tests/Feature/views/passwords/confirm.blade.php b/tests/Feature/views/passwords/confirm.blade.php deleted file mode 100644 index 37960e0..0000000 --- a/tests/Feature/views/passwords/confirm.blade.php +++ /dev/null @@ -1 +0,0 @@ -Confirm Password diff --git a/tests/Feature/views/passwords/email.blade.php b/tests/Feature/views/passwords/email.blade.php deleted file mode 100644 index 71950a7..0000000 --- a/tests/Feature/views/passwords/email.blade.php +++ /dev/null @@ -1 +0,0 @@ -Forgot Password diff --git a/tests/Feature/views/passwords/reset.blade.php b/tests/Feature/views/passwords/reset.blade.php deleted file mode 100644 index a3c8e45..0000000 --- a/tests/Feature/views/passwords/reset.blade.php +++ /dev/null @@ -1 +0,0 @@ -Reset Password diff --git a/tests/Feature/views/passwords/update.blade.php b/tests/Feature/views/passwords/update.blade.php deleted file mode 100644 index 4da1826..0000000 --- a/tests/Feature/views/passwords/update.blade.php +++ /dev/null @@ -1 +0,0 @@ -Update Password diff --git a/tests/Feature/views/register.blade.php b/tests/Feature/views/register.blade.php deleted file mode 100644 index 0f1067a..0000000 --- a/tests/Feature/views/register.blade.php +++ /dev/null @@ -1 +0,0 @@ -Register diff --git a/tests/Feature/views/verification/result.blade.php b/tests/Feature/views/verification/result.blade.php deleted file mode 100644 index 005c61b..0000000 --- a/tests/Feature/views/verification/result.blade.php +++ /dev/null @@ -1,17 +0,0 @@ -@if($errors->isEmpty()) - {{ $message }} -@endif - -@php - $error = $errors->all(); -@endphp - -@unless(empty($error)) - @if(count($error) > 1) - @foreach($error as $error_text) - {{ $error_text }} - @endforeach - @else - {{ $error[0] }} - @endif -@endunless diff --git a/tests/Feature/views/verification/verify.blade.php b/tests/Feature/views/verification/verify.blade.php deleted file mode 100644 index 97b4d17..0000000 --- a/tests/Feature/views/verification/verify.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -Resend Verification - -{{ $message }} - diff --git a/tests/InteractsWithDatabase.php b/tests/InteractsWithDatabase.php deleted file mode 100644 index 9cd7e47..0000000 --- a/tests/InteractsWithDatabase.php +++ /dev/null @@ -1,18 +0,0 @@ -up(); - (new \CreatePasswordResetsTable())->up(); - (new \CreateSessionsTable())->up(); - - } -} diff --git a/tests/Models/Admin.php b/tests/Models/Admin.php new file mode 100644 index 0000000..4e1b261 --- /dev/null +++ b/tests/Models/Admin.php @@ -0,0 +1,21 @@ +app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); - - $this->app['config']->set('session.serialization', 'php'); - - // Set the view path to include your package's views directory - $this->app['config']->set('view.paths', [__DIR__.'/Feature/views']); - - $this->app['config']->set('app.api_prefix', 'api/v1'); - - $this->app['config']->set('auth.providers.users.model', User::class); - - // set password reset - $this->app['config']->set('auth.passwords.users', [ - 'provider' => 'users', - 'table' => 'password_resets', - 'expire' => 60, - ]); - - if (empty(glob($this->app->databasePath('migrations/*_create_activity_log_table.php')))) { - Artisan::call('vendor:publish', [ - '--provider' => 'Spatie\\Activitylog\\ActivitylogServiceProvider', - '--tag' => 'activitylog-migrations', - ]); - } - - $this->registerRoutes(); - - Mail::fake(); - Notification::fake(); + Manifest::fake(); } - protected function getPackageProviders($app) + protected function getPackageProviders($app): array { return [ - SchemaServiceProvider::class, - SettingsServiceProvider::class, - HelpersServiceProvider::class, - EventServiceProvider::class, - AuthServiceProvider::class, ActivitylogServiceProvider::class, + PermissionServiceProvider::class, + PermissionsServiceProvider::class, + ManifestServiceProvider::class, + FormsServiceProvider::class, + PasskeysServiceProvider::class, + AuthServiceProvider::class, ]; } - protected function registerTestRoute( - string $uri, - string $controller, - $function, - string $method = 'get', - ?string $name = null, - array $middlewares = ['web'] - ): void { - Route::middleware(array_merge(['web'], $middlewares ?? [])) - ->group(function () use ($uri, $controller, $function, $method, $name) { - $route = Route::$method($uri, [$controller, $function]); - - if ($name) { - $route->name($name); - } - }); - } - - public function seedDatabase(): void + protected function defineEnvironment($app): void { - if (! SeedState::$seeded) { - $this->seedDefaultUsers(); + $config = $app['config']; - SeedState::$seeded = true; - } - } + $config->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + $config->set('app.url', 'http://localhost'); + $config->set('session.serialization', 'php'); + $config->set('view.paths', [__DIR__ . '/views', ...$config->get('view.paths', [])]); - protected function seedDefaultUsers(): void - { - Model::unguard(); - - User::firstOrCreate([ - 'name' => 'John Doe', - 'email' => 'user@example.com', - ], [ - 'password' => 'password', - 'status' => UserStatuses::APPROVED, - 'email_verified_at' => now(), + $config->set('auth.guards', [ + 'web' => ['driver' => 'session', 'provider' => 'users'], + 'web_admin' => ['driver' => 'session', 'provider' => 'admins'], + ]); + + $config->set('auth.providers', [ + 'users' => ['driver' => 'eloquent', 'model' => User::class], + 'admins' => ['driver' => 'eloquent', 'model' => Admin::class], + ]); + + $config->set('auth.passwords', [ + 'users' => ['provider' => 'users', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 0], + 'admins' => ['provider' => 'admins', 'table' => 'admin_password_reset_tokens', 'expire' => 60, 'throttle' => 0], + ]); + + $config->set('javaabu-auth.user_types', [ + 'user' => [ + 'guard' => 'web', + 'passwords' => 'users', + 'route_name' => '', + 'home' => '/dashboard', + 'features' => ['passkeys' => true, 'delete_account' => true], + ], + 'admin' => [ + 'guard' => 'web_admin', + 'passwords' => 'admins', + 'home' => '/admin', + 'features' => ['registration' => false, 'passkeys' => true], + ], ]); - Model::reguard(); + $config->set('javaabu-auth.layouts', [ + 'auth' => 'layouts.app', + 'account' => 'layouts.app', + ]); } - protected function getUser(string $email): ?User + protected function defineDatabaseMigrations(): void { - return User::where('email', $email)->first(); + $this->loadMigrationsFrom(__DIR__ . '/database'); } - protected function registerRoutes(): void + protected function defineRoutes($router): void { - Route::group([ - 'middleware' => ['web', 'auth:web', 'active:web', 'password-update-not-required:web'], - ], function () { - Route::get('/test', function () { - return 'Test'; - })->middleware('password.confirm:password.confirm'); - }); - - Route::group([ - 'middleware' => ['web'], - ], function () { - Route::post('/session-test', function () { - session()->put('test_key', 'test_value'); - - return 'Done!'; - }); - }); - - $this->registerTestRoute( - '/login', - LoginController::class, - 'getLoginForm', - name: 'login' - ); - - $this->registerTestRoute( - '/logout', - LoginController::class, - 'logout', - method: 'post', - name: 'logout' - ); - - $this->registerTestRoute( - '/login', - LoginController::class, - 'login', - method: 'post' - ); - - $this->registerTestRoute( - '/register', - RegisterController::class, - 'register', - method: 'post' - ); - - $this->registerTestRoute( - '/register', - RegisterController::class, - 'showRegistrationForm', - ); - - $this->registerTestRoute( - '/', - HomeController::class, - 'index', - name: 'home', - middlewares: ['auth:web', 'active:web', 'password-update-not-required:web'] - ); - - // Email Verification - Route::group([ - 'namespace' => 'Auth', - 'prefix' => 'verify', - 'as' => 'verification.', - ], function () { - Route::get('/', [VerificationController::class, 'show'])->name('notice'); - Route::post('email/resend', [VerificationController::class, 'resend'])->name('resend'); - Route::get('email/{id}/{hash}', [VerificationController::class, 'verify'])->name('verify'); - }); - - Route::group([ - 'prefix' => 'password', - 'as' => 'password.', - 'middleware' => ['web'], - ], function () { - // Forgot Password - Route::get('reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('request'); - Route::post('email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('email'); - Route::get('reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('reset'); - Route::post('reset', [ResetPasswordController::class, 'reset'])->name('update'); - - // Confirm Password - Route::get('confirm', [ConfirmPasswordController::class, 'showConfirmForm'])->name('confirm'); - Route::post('confirm', [ConfirmPasswordController::class, 'confirm'])->name('confirm-post'); - - // Password Update - Route::get('update', [UpdatePasswordController::class, 'showPasswordUpdateForm'])->name('new-password'); - Route::post('update', [UpdatePasswordController::class, 'updatePassword'])->name('new-password-post'); - }); - - /** - * Protected routes - */ - Route::group([ - 'middleware' => [ - 'auth', - 'active', - 'password-update-not-required' - ], - ], function () { - Route::get('account', function () { - return 'User Account Edit'; - })->name('user.account'); - Route::match(['PUT', 'PATCH'], 'account', [UserController::class, 'update'])->name('account.update'); - }); + $this->registerAuthRoutes(); } - protected function checkRule(mixed $rule, string $attribute, mixed $value): bool + protected function registerAuthRoutes(): void { - $passed = true; - $fail = function () use (&$passed) { - $passed = false; - }; + Route::authRoutes('user'); + Route::authRoutes('admin', ['prefix' => 'admin']); - $rule->validate($attribute, $value, $fail); + Route::middleware(['web', 'auth.type:user'])->get('dashboard', fn () => 'user dashboard')->name('dashboard'); + Route::middleware(['web', 'auth.type:admin'])->get('admin', fn () => 'admin dashboard')->name('admin.home'); + } - return $passed; + protected function userType(string $name = 'user'): UserType + { + return UserTypes::get($name); } } diff --git a/tests/Unit/DetectsGmailAliasesTest.php b/tests/Unit/DetectsGmailAliasesTest.php deleted file mode 100644 index e142ca5..0000000 --- a/tests/Unit/DetectsGmailAliasesTest.php +++ /dev/null @@ -1,292 +0,0 @@ -runMigrations(); - } - - #[Test] - public function it_can_detect_exact_same_gmail() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahmed@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_can_detect_plus_variation_in_db() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed+aisha@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahmed@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_can_detect_plus_variation_in_given_email() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahmed+aisha@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_can_detect_plus_variation_in_given_email_and_db() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed+mohamed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahmed+aisha@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_can_detect_dot_variation_in_db() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ah.med@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahmed@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_can_detect_dot_variation_in_given_email() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahm.ed@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_can_detect_dot_variation_in_given_email_and_db() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ah.me.d@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahm.ed@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_can_detect_combined_dot_and_plus_variation() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ah.me.d@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahm.ed+aisha@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_can_handle_different_case_in_db() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'Ah.me.d@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahm.ed+aisha@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_can_handle_different_case_in_the_given_email() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ah.me.d@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahm.eD+aisha@gmail.com')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } - - #[Test] - public function it_works_even_if_there_is_no_matching_gmail() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('mohamed+ahmed@gmail.com')->get(); - - $this->assertEquals(0, $users->count()); - } - - #[Test] - public function it_works_even_if_there_is_no_gmails_in_the_db() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@hotmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@hotmail.com', - ]); - - $users = User::hasGmailAlias('ahmed@gmail.com')->get(); - - $this->assertEquals(0, $users->count()); - } - - #[Test] - public function it_works_even_if_there_is_no_gmails_in_the_given_email() - { - $user_1 = User::factory() - ->create([ - 'name' => 'Ahmed', - 'email' => 'ahmed@gmail.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Aisha', - 'email' => 'aisha@gmail.com', - ]); - - $users = User::hasGmailAlias('ahmed@hotmail.com')->get(); - - $this->assertEquals(0, $users->count()); - } -} diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php deleted file mode 100644 index 92909dc..0000000 --- a/tests/Unit/UserTest.php +++ /dev/null @@ -1,43 +0,0 @@ -runMigrations(); - } - - #[Test] - public function it_can_search_users() - { - $user_1 = User::factory() - ->create([ - 'name' => 'John', - 'email' => 'user-1@example.com', - ]); - - $user_2 = User::factory() - ->create([ - 'name' => 'Doe', - 'email' => 'user-2@example.com', - ]); - - $users = User::search('John')->get(); - - $this->assertEquals(1, $users->count()); - $this->assertEquals($user_1->id, $users->first()->id); - } -} diff --git a/tests/database/2024_01_01_000000_create_test_tables.php b/tests/database/2024_01_01_000000_create_test_tables.php new file mode 100644 index 0000000..0451787 --- /dev/null +++ b/tests/database/2024_01_01_000000_create_test_tables.php @@ -0,0 +1,43 @@ +string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('admin_password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + UserSchema::sessionColumns($table); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + Schema::dropIfExists('admin_password_reset_tokens'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('admins'); + Schema::dropIfExists('users'); + } +}; diff --git a/tests/database/2024_01_01_000001_create_activity_log_table.php b/tests/database/2024_01_01_000001_create_activity_log_table.php new file mode 100644 index 0000000..8b0f0f2 --- /dev/null +++ b/tests/database/2024_01_01_000001_create_activity_log_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('properties')->nullable(); + $table->uuid('batch_uuid')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_log'); + } +}; diff --git a/tests/database/create_password_resets_table.php b/tests/database/create_password_resets_table.php deleted file mode 100644 index 497d541..0000000 --- a/tests/database/create_password_resets_table.php +++ /dev/null @@ -1,26 +0,0 @@ - + +{{ config('app.name') }} + +@yield('content') + + From 97c29b3fe6cd27e3d2d7337cfd8f0e2652ed8450 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 16:21:31 +0500 Subject: [PATCH 02/14] Test registration, password reset, verification, confirmation, profile, security and two-factor Co-Authored-By: Claude Opus 5.5 --- src/Models/User.php | 2 + tests/Feature/EmailVerificationTest.php | 76 ++++++ tests/Feature/PasswordConfirmationTest.php | 52 +++++ tests/Feature/PasswordResetTest.php | 82 +++++++ tests/Feature/ProfileTest.php | 98 ++++++++ tests/Feature/RegistrationTest.php | 62 +++++ tests/Feature/SecurityTest.php | 77 ++++++ tests/Feature/TwoFactorTest.php | 219 ++++++++++++++++++ .../2024_01_01_000000_create_test_tables.php | 12 + 9 files changed, 680 insertions(+) create mode 100644 tests/Feature/EmailVerificationTest.php create mode 100644 tests/Feature/PasswordConfirmationTest.php create mode 100644 tests/Feature/PasswordResetTest.php create mode 100644 tests/Feature/ProfileTest.php create mode 100644 tests/Feature/RegistrationTest.php create mode 100644 tests/Feature/SecurityTest.php create mode 100644 tests/Feature/TwoFactorTest.php diff --git a/src/Models/User.php b/src/Models/User.php index ab505ed..c2d40fc 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -3,6 +3,7 @@ namespace Javaabu\Auth\Models; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Javaabu\Auth\Concerns\DetectsGmailAliases; @@ -33,6 +34,7 @@ abstract class User extends Authenticatable implements HasAccountStatusContract, use LocksAfterFailedLogins; use Notifiable; use RequiresPasswordUpdate; + use SoftDeletes; use TwoFactorAuthenticatable; protected $hidden = [ diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php new file mode 100644 index 0000000..20fd00f --- /dev/null +++ b/tests/Feature/EmailVerificationTest.php @@ -0,0 +1,76 @@ +unverified()->create(); + + $this->actingAs($user, 'web') + ->get('/verify-email') + ->assertOk() + ->assertSee('data-auth-page="verify-email"', false); + } + + #[Test] + public function it_verifies_the_email_from_the_signed_link(): void + { + Event::fake([Verified::class]); + + $user = User::factory()->unverified()->create(); + + $url = URL::temporarySignedRoute('verification.verify', now()->addHour(), [ + 'id' => $user->id, + 'hash' => sha1($user->email), + ]); + + $this->actingAs($user, 'web')->get($url)->assertRedirect('/dashboard'); + + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + + Event::assertDispatched(Verified::class); + } + + #[Test] + public function it_rejects_a_link_for_someone_else(): void + { + $user = User::factory()->unverified()->create(); + $other = User::factory()->unverified()->create(); + + $url = URL::temporarySignedRoute('verification.verify', now()->addHour(), [ + 'id' => $other->id, + 'hash' => sha1($other->email), + ]); + + $this->actingAs($user, 'web')->get($url)->assertForbidden(); + } + + #[Test] + public function it_resends_the_link_to_the_type_route(): void + { + Notification::fake(); + + $admin = Admin::factory()->unverified()->create(); + + $this->actingAs($admin, 'web_admin') + ->post('/admin/email/verification-notification') + ->assertSessionHas('status'); + + Notification::assertSentTo($admin, VerifyEmail::class, function (VerifyEmail $notification) use ($admin) { + return str_contains($notification->toMail($admin)->actionUrl, '/admin/verify-email/'); + }); + } +} diff --git a/tests/Feature/PasswordConfirmationTest.php b/tests/Feature/PasswordConfirmationTest.php new file mode 100644 index 0000000..450a387 --- /dev/null +++ b/tests/Feature/PasswordConfirmationTest.php @@ -0,0 +1,52 @@ +actingAs(User::factory()->create(), 'web') + ->get('/settings/security') + ->assertRedirect('/confirm-password'); + } + + #[Test] + public function it_confirms_the_password_and_continues(): void + { + $user = User::factory()->create(); + + $this->actingAs($user, 'web')->get('/settings/security'); + + $this->post('/confirm-password', ['password' => 'password']) + ->assertRedirect('/settings/security'); + + $this->get('/settings/security')->assertOk(); + } + + #[Test] + public function it_rejects_a_wrong_password(): void + { + $this->actingAs(User::factory()->create(), 'web') + ->post('/confirm-password', ['password' => 'wrong']) + ->assertSessionHasErrors('password'); + } + + #[Test] + public function confirming_as_a_user_does_not_confirm_as_an_admin(): void + { + $this->actingAs(User::factory()->create(), 'web') + ->actingAs(Admin::factory()->create(), 'web_admin'); + + $this->post('/confirm-password', ['password' => 'password']); + + $this->get('/settings/security')->assertOk(); + $this->get('/admin/settings/security')->assertRedirect('/admin/confirm-password'); + } +} diff --git a/tests/Feature/PasswordResetTest.php b/tests/Feature/PasswordResetTest.php new file mode 100644 index 0000000..b1b4a52 --- /dev/null +++ b/tests/Feature/PasswordResetTest.php @@ -0,0 +1,82 @@ +create(['email' => 'admin@example.com']); + + $this->post('/admin/forgot-password', ['email' => 'admin@example.com']) + ->assertSessionHas('status'); + + Notification::assertSentTo($admin, ResetPassword::class, function (ResetPassword $notification) use ($admin) { + $url = $notification->toMail($admin)->actionUrl; + + return str_starts_with($url, url('/admin/reset-password/')); + }); + } + + #[Test] + public function it_answers_the_same_for_unknown_emails(): void + { + Notification::fake(); + + $this->post('/forgot-password', ['email' => 'nobody@example.com']) + ->assertSessionHasNoErrors() + ->assertSessionHas('status'); + + Notification::assertNothingSent(); + } + + #[Test] + public function it_resets_the_password_and_unlocks_the_account(): void + { + $user = User::factory()->create(['email' => 'ahmed@example.com', 'login_attempts' => 10, 'require_password_update' => true]); + $token = Password::broker('users')->createToken($user); + + $this->get("/reset-password/{$token}?email=ahmed@example.com") + ->assertOk() + ->assertSee('data-auth-page="reset-password"', false); + + $this->post('/reset-password', [ + 'token' => $token, + 'email' => 'ahmed@example.com', + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ])->assertRedirect('/login'); + + $user->refresh(); + + $this->assertTrue(password_verify('new-password-123', $user->password)); + $this->assertNull($user->login_attempts); + $this->assertFalse($user->requiresPasswordUpdate()); + } + + #[Test] + public function a_token_of_one_type_does_not_reset_another_type(): void + { + $user = User::factory()->create(['email' => 'same@example.com']); + Admin::factory()->create(['email' => 'same@example.com']); + $token = Password::broker('users')->createToken($user); + + $this->post('/admin/reset-password', [ + 'token' => $token, + 'email' => 'same@example.com', + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ])->assertSessionHasErrors('email'); + } +} diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..f9d38a4 --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,98 @@ +actingAs(User::factory()->create(['name' => 'އައިޝަތު ޒިޔާ']), 'web') + ->get('/settings/profile') + ->assertOk() + ->assertSee('އައިޝަތު ޒިޔާ') + ->assertSee('data-delete-account', false); + } + + #[Test] + public function it_updates_the_name(): void + { + $user = User::factory()->create(); + + $this->actingAs($user, 'web') + ->patch('/settings/profile', ['name' => 'ހަސަން ރަޝީދު', 'email' => $user->email]) + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('users', ['id' => $user->id, 'name' => 'ހަސަން ރަޝީދު']); + $this->assertNotNull($user->fresh()->email_verified_at); + } + + #[Test] + public function changing_the_email_asks_for_verification_again(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->actingAs($user, 'web') + ->patch('/settings/profile', ['name' => $user->name, 'email' => 'new@example.com']) + ->assertSessionHasNoErrors(); + + $user->refresh(); + + $this->assertEquals('new@example.com', $user->email); + $this->assertNull($user->email_verified_at); + + Notification::assertSentTo($user, VerifyEmail::class); + } + + #[Test] + public function the_email_must_be_unique_within_the_type_only(): void + { + User::factory()->create(['email' => 'taken@example.com']); + $admin = Admin::factory()->create(); + + $this->actingAs($admin, 'web_admin') + ->patch('/admin/settings/profile', ['name' => $admin->name, 'email' => 'taken@example.com']) + ->assertSessionHasNoErrors(); + + $user = User::factory()->create(); + + $this->actingAs($user, 'web') + ->patch('/settings/profile', ['name' => $user->name, 'email' => 'taken@example.com']) + ->assertSessionHasErrors('email'); + } + + #[Test] + public function it_deletes_the_account_with_the_password(): void + { + $user = User::factory()->create(); + + $this->actingAs($user, 'web') + ->delete('/settings/profile', ['password' => 'wrong']) + ->assertSessionHasErrors('password'); + + $this->delete('/settings/profile', ['password' => 'password']) + ->assertRedirect('/login'); + + $this->assertSoftDeleted('users', ['id' => $user->id]); + $this->assertGuest('web'); + } + + #[Test] + public function types_without_account_deletion_cannot_delete(): void + { + $admin = Admin::factory()->create(); + + $this->actingAs($admin, 'web_admin') + ->delete('/admin/settings/profile', ['password' => 'password']) + ->assertStatus(405); + } +} diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php new file mode 100644 index 0000000..6ab2cf3 --- /dev/null +++ b/tests/Feature/RegistrationTest.php @@ -0,0 +1,62 @@ +get('/register') + ->assertOk() + ->assertSee('data-auth-page="register"', false); + } + + #[Test] + public function it_registers_a_user_with_a_thaana_name_and_logs_them_in(): void + { + Event::fake([Registered::class]); + + $this->post('/register', [ + 'name' => 'މުޙައްމަދު ނާޒިމް', + 'email' => 'Nazim@Example.com', + 'password' => 'a-long-password', + 'password_confirmation' => 'a-long-password', + ])->assertRedirect('/dashboard'); + + $user = User::where('email', 'nazim@example.com')->firstOrFail(); + + $this->assertEquals('މުޙައްމަދު ނާޒިމް', $user->name); + $this->assertAuthenticatedAs($user, 'web'); + + Event::assertDispatched(Registered::class); + } + + #[Test] + public function it_validates_registration(): void + { + User::factory()->create(['email' => 'taken@example.com']); + + $this->post('/register', [ + 'name' => '', + 'email' => 'taken@example.com', + 'password' => 'a-long-password', + 'password_confirmation' => 'different', + ])->assertSessionHasErrors(['name', 'email', 'password']); + + $this->assertGuest('web'); + } + + #[Test] + public function types_without_registration_have_no_registration_routes(): void + { + $this->get('/admin/register')->assertNotFound(); + $this->assertFalse($this->userType('admin')->hasRoute('register')); + } +} diff --git a/tests/Feature/SecurityTest.php b/tests/Feature/SecurityTest.php new file mode 100644 index 0000000..a8641e0 --- /dev/null +++ b/tests/Feature/SecurityTest.php @@ -0,0 +1,77 @@ +userType($type)) => time()]; + } + + #[Test] + public function it_shows_the_security_page(): void + { + $this->actingAs(User::factory()->create(), 'web') + ->withSession($this->confirmedSession()) + ->get('/settings/security') + ->assertOk() + ->assertSee('data-password-form', false) + ->assertSee('data-two-factor', false) + ->assertSee('data-passkeys', false); + } + + #[Test] + public function it_updates_the_password(): void + { + $user = User::factory()->create(); + + $this->actingAs($user, 'web') + ->put('/settings/password', [ + 'current_password' => 'password', + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ]) + ->assertSessionHasNoErrors(); + + $this->assertTrue(password_verify('new-password-123', $user->fresh()->password)); + } + + #[Test] + public function it_needs_the_current_password(): void + { + $this->actingAs(User::factory()->create(), 'web') + ->put('/settings/password', [ + 'current_password' => 'wrong', + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ]) + ->assertSessionHasErrors('current_password'); + } + + #[Test] + public function users_who_must_change_their_password_are_sent_to_the_new_password_page(): void + { + $user = User::factory()->create(['require_password_update' => true]); + + $this->actingAs($user, 'web') + ->get('/dashboard') + ->assertRedirect('/update-password'); + + $this->get('/update-password')->assertOk()->assertSee('data-auth-page="update-required-password"', false); + + $this->put('/update-password', [ + 'current_password' => 'password', + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ])->assertRedirect('/dashboard'); + + $this->assertFalse($user->fresh()->requiresPasswordUpdate()); + $this->get('/dashboard')->assertOk(); + } +} diff --git a/tests/Feature/TwoFactorTest.php b/tests/Feature/TwoFactorTest.php new file mode 100644 index 0000000..9b74aab --- /dev/null +++ b/tests/Feature/TwoFactorTest.php @@ -0,0 +1,219 @@ +currentCode($secret); + } + + protected function confirmed(string $type = 'user'): array + { + return [RequirePassword::sessionKey($this->userType($type)) => time()]; + } + + #[Test] + public function it_turns_two_factor_on_and_confirms_it(): void + { + $user = User::factory()->create(); + + $this->actingAs($user, 'web')->withSession($this->confirmed()) + ->post('/settings/two-factor') + ->assertSessionHas('two_factor_status', 'two-factor-authentication-enabled'); + + $user->refresh(); + + $this->assertTrue($user->hasPendingTwoFactorAuthentication()); + $this->assertFalse($user->hasEnabledTwoFactorAuthentication()); + $this->assertCount(8, $user->recoveryCodes()); + + $this->get('/settings/security') + ->assertOk() + ->assertSee('data-two-factor-qr-code', false); + + $this->post('/settings/two-factor/confirm', ['code' => '000000']) + ->assertSessionHasErrors('code'); + + $this->post('/settings/two-factor/confirm', ['code' => $this->code($user->twoFactorSecret())]) + ->assertSessionHasNoErrors(); + + $this->assertTrue($user->fresh()->hasEnabledTwoFactorAuthentication()); + } + + #[Test] + public function managing_two_factor_needs_a_confirmed_password(): void + { + $this->actingAs(User::factory()->create(), 'web') + ->post('/settings/two-factor') + ->assertRedirect('/confirm-password'); + } + + #[Test] + public function it_asks_for_a_code_after_the_password(): void + { + Event::fake([TwoFactorAuthenticationChallenged::class]); + + $user = User::factory()->withTwoFactor(self::SECRET)->create(['email' => 'ahmed@example.com']); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']) + ->assertRedirect('/two-factor-challenge'); + + $this->assertGuest('web'); + Event::assertDispatched(TwoFactorAuthenticationChallenged::class); + + $this->get('/two-factor-challenge')->assertOk()->assertSee('data-two-factor-challenge', false); + + $this->post('/two-factor-challenge', ['code' => '123456']) + ->assertSessionHasErrors('code'); + + $this->assertGuest('web'); + + $this->post('/two-factor-challenge', ['code' => $this->code()]) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user, 'web'); + } + + #[Test] + public function a_code_can_only_be_used_once(): void + { + $provider = app(TwoFactorProvider::class); + $code = $this->code(); + + $this->assertTrue($provider->verify(self::SECRET, $code)); + $this->assertFalse($provider->verify(self::SECRET, $code)); + } + + #[Test] + public function it_accepts_a_recovery_code_once(): void + { + $user = User::factory()->withTwoFactor()->create(['email' => 'ahmed@example.com']); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']); + + $this->post('/two-factor-challenge', ['recovery_code' => 'recovery-code-1']) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user, 'web'); + $this->assertNotContains('recovery-code-1', $user->fresh()->recoveryCodes()); + $this->assertContains('recovery-code-2', $user->fresh()->recoveryCodes()); + } + + #[Test] + public function a_challenge_of_one_type_cannot_be_finished_on_another(): void + { + User::factory()->withTwoFactor()->create(['email' => 'ahmed@example.com']); + Admin::factory()->create(['id' => 1]); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']); + + $this->get('/admin/two-factor-challenge')->assertRedirect('/admin/login'); + + $this->post('/admin/two-factor-challenge', ['code' => $this->code()]) + ->assertSessionHasErrors('code'); + + $this->assertGuest('web_admin'); + } + + #[Test] + public function it_rate_limits_codes(): void + { + User::factory()->withTwoFactor()->create(['email' => 'ahmed@example.com']); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']); + + foreach (range(1, 5) as $attempt) { + $this->post('/two-factor-challenge', ['code' => '000000']); + } + + $this->post('/two-factor-challenge', ['code' => $this->code()]) + ->assertSessionHasErrors('code'); + + $this->assertGuest('web'); + } + + #[Test] + public function types_with_two_factor_off_skip_the_challenge(): void + { + UserTypes::get('user')->twoFactor('off'); + + User::factory()->withTwoFactor()->create(['email' => 'ahmed@example.com']); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']) + ->assertRedirect('/dashboard'); + } + + #[Test] + public function required_two_factor_sends_users_to_set_it_up(): void + { + UserTypes::get('admin')->twoFactor('required'); + + $admin = Admin::factory()->create(); + + $this->actingAs($admin, 'web_admin')->withSession($this->confirmed('admin')) + ->get('/admin') + ->assertRedirect('/admin/settings/security'); + + $this->get('/admin/settings/security')->assertOk(); + + $this->actingAs(Admin::factory()->withTwoFactor()->create(), 'web_admin') + ->delete('/admin/settings/two-factor') + ->assertForbidden(); + } + + #[Test] + public function it_turns_two_factor_off(): void + { + $user = User::factory()->withTwoFactor()->create(); + + $this->actingAs($user, 'web')->withSession($this->confirmed()) + ->delete('/settings/two-factor') + ->assertSessionHasNoErrors(); + + $this->assertFalse($user->fresh()->hasEnabledTwoFactorAuthentication()); + $this->assertNull($user->fresh()->two_factor_secret); + } + + #[Test] + public function it_regenerates_recovery_codes_and_shows_them_once(): void + { + $user = User::factory()->withTwoFactor()->create(); + + $this->actingAs($user, 'web')->withSession($this->confirmed()) + ->post('/settings/two-factor/recovery-codes'); + + $codes = $user->fresh()->recoveryCodes(); + + $this->assertNotContains('recovery-code-1', $codes); + + $this->getJson('/settings/two-factor/recovery-codes')->assertExactJson($codes); + } + + #[Test] + public function it_returns_the_qr_code_and_secret_as_json(): void + { + $user = User::factory()->withTwoFactor()->create(); + + $this->actingAs($user, 'web')->withSession($this->confirmed()) + ->getJson('/settings/two-factor/qr-code') + ->assertOk() + ->assertJsonStructure(['svg', 'url']); + + $this->getJson('/settings/two-factor/secret-key') + ->assertExactJson(['secret_key' => self::SECRET]); + } +} diff --git a/tests/database/2024_01_01_000000_create_test_tables.php b/tests/database/2024_01_01_000000_create_test_tables.php index 0451787..7433931 100644 --- a/tests/database/2024_01_01_000000_create_test_tables.php +++ b/tests/database/2024_01_01_000000_create_test_tables.php @@ -30,10 +30,22 @@ public function up(): void Schema::create('sessions', function (Blueprint $table) { UserSchema::sessionColumns($table); }); + + Schema::create('personal_access_tokens', function (Blueprint $table) { + $table->id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); } public function down(): void { + Schema::dropIfExists('personal_access_tokens'); Schema::dropIfExists('sessions'); Schema::dropIfExists('admin_password_reset_tokens'); Schema::dropIfExists('password_reset_tokens'); From f0c039a22a0f3c433d149a7dd79399e936ddf09a Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 16:23:48 +0500 Subject: [PATCH 03/14] Test multi-auth: named routes per type, guest redirects per guard, login methods, session driver Co-Authored-By: Claude Opus 5.5 --- src/AuthServiceProvider.php | 9 ++- tests/Feature/MultiAuthTest.php | 120 +++++++++++++++++++++++++++++ tests/Fixtures/CodeLoginMethod.php | 46 +++++++++++ tests/Unit/UserTypeTest.php | 93 ++++++++++++++++++++++ 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/MultiAuthTest.php create mode 100644 tests/Fixtures/CodeLoginMethod.php create mode 100644 tests/Unit/UserTypeTest.php diff --git a/src/AuthServiceProvider.php b/src/AuthServiceProvider.php index 6a97ac9..5e3363f 100644 --- a/src/AuthServiceProvider.php +++ b/src/AuthServiceProvider.php @@ -227,7 +227,14 @@ protected function registerGuestRedirects(): void } $handler->renderable(function (AuthenticationException $exception, Request $request) { - if ($request->expectsJson() || $exception->redirectTo($request)) { + if ($request->expectsJson()) { + return null; + } + + // Respect a redirect the app chose, but not Laravel's default `route('login')` + $redirect_to = $exception->redirectTo($request); + + if ($redirect_to && (! Route::has('login') || $redirect_to !== route('login'))) { return null; } diff --git a/tests/Feature/MultiAuthTest.php b/tests/Feature/MultiAuthTest.php new file mode 100644 index 0000000..143a894 --- /dev/null +++ b/tests/Feature/MultiAuthTest.php @@ -0,0 +1,120 @@ +loginMethod(CodeLoginMethod::class); + + $this->registerAuthRoutes(); + + Route::middleware(['web', 'auth:web_admin'])->get('admin/reports', fn () => 'reports'); + } + + #[Test] + public function every_type_gets_its_own_named_routes(): void + { + $this->assertEquals(url('/login'), route('login')); + $this->assertEquals(url('/admin/login'), route('admin.login')); + $this->assertEquals(url('/settings/profile'), route('profile.edit')); + $this->assertEquals(url('/admin/settings/profile'), route('admin.profile.edit')); + } + + #[Test] + public function the_type_is_resolved_from_models_and_guards(): void + { + $this->assertEquals('admin', UserTypes::for(Admin::class)->name()); + $this->assertEquals('user', UserTypes::for(new User)->name()); + $this->assertEquals('admin', UserTypes::forGuard('web_admin')->name()); + $this->assertEquals('admin', (new Admin)->userType()->name()); + } + + #[Test] + public function laravel_auth_middleware_sends_guests_to_the_login_page_of_the_guard(): void + { + $this->get('/admin/reports')->assertRedirect('/admin/login'); + } + + #[Test] + public function an_alternative_login_method_adds_its_routes_and_a_link_on_the_login_page(): void + { + $this->get('/login') + ->assertOk() + ->assertSee('data-login-method="code"', false) + ->assertSee(route('login.code'), false); + + $this->assertFalse(Route::has('admin.login.code')); + } + + #[Test] + public function an_alternative_login_method_finishes_with_the_two_factor_challenge(): void + { + User::factory()->withTwoFactor()->create(['email' => 'ahmed@example.com']); + + $this->post('/login/code', ['email' => 'ahmed@example.com', 'code' => '1234']) + ->assertRedirect('/two-factor-challenge'); + + $this->assertGuest('web'); + } + + #[Test] + public function an_alternative_login_method_logs_in(): void + { + $user = User::factory()->create(['email' => 'ahmed@example.com']); + + $this->post('/login/code', ['email' => 'ahmed@example.com', 'code' => '1234']) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user, 'web'); + } + + #[Test] + public function the_multi_auth_session_driver_stores_the_user_type(): void + { + config()->set('session.driver', 'multi_auth_database'); + + $admin = Admin::factory()->create(['email' => 'admin@example.com']); + + $this->post('/admin/login', ['email' => 'admin@example.com', 'password' => 'password']) + ->assertRedirect('/admin'); + + $this->assertDatabaseHas('sessions', [ + 'user_id' => $admin->id, + 'user_type' => $admin->getMorphClass(), + ]); + + $this->assertCount(1, $admin->sessions); + } + + #[Test] + public function logging_out_other_sessions_deletes_only_the_users_other_sessions(): void + { + config()->set('session.driver', 'multi_auth_database'); + + $admin = Admin::factory()->create(); + $user = User::factory()->create(['id' => $admin->id]); + + DB::table('sessions')->insert([ + ['id' => 'admin-other', 'user_id' => $admin->id, 'user_type' => $admin->getMorphClass(), 'payload' => '', 'last_activity' => time()], + ['id' => 'user-other', 'user_id' => $user->id, 'user_type' => $user->getMorphClass(), 'payload' => '', 'last_activity' => time()], + ]); + + $this->assertCount(1, $admin->sessions); + + $admin->deleteOtherSessionRecords(); + + $this->assertDatabaseMissing('sessions', ['id' => 'admin-other']); + $this->assertDatabaseHas('sessions', ['id' => 'user-other']); + } +} diff --git a/tests/Fixtures/CodeLoginMethod.php b/tests/Fixtures/CodeLoginMethod.php new file mode 100644 index 0000000..e5e87ae --- /dev/null +++ b/tests/Fixtures/CodeLoginMethod.php @@ -0,0 +1,46 @@ +get('login/code', fn () => 'code form')->name('login.code'); + + $router->post('login/code', function (Request $request, StartAuthenticatedSession $start_session) { + $type = UserTypes::fromRequest($request); + + abort_unless($request->input('code') === '1234', 422); + + $user = $type->newModel()->newQuery()->where('email', $request->input('email'))->firstOrFail(); + + return $start_session($request, $type, $user); + })->name('login.code.store'); + } + + public function url(UserType $type): string + { + return $type->route('login.code'); + } +} diff --git a/tests/Unit/UserTypeTest.php b/tests/Unit/UserTypeTest.php new file mode 100644 index 0000000..51e0a3a --- /dev/null +++ b/tests/Unit/UserTypeTest.php @@ -0,0 +1,93 @@ +assertEquals('vendor', $type->guardName()); + $this->assertEquals('vendors', $type->passwordBrokerName()); + $this->assertEquals('vendor.login', $type->routeNameFor('login')); + $this->assertEquals(TwoFactorModes::OPTIONAL, $type->twoFactorMode()); + $this->assertTrue($type->hasFeature(UserType::REGISTRATION)); + $this->assertFalse($type->hasFeature(UserType::PASSKEYS)); + } + + #[Test] + public function it_finds_the_model_from_the_guard(): void + { + $this->assertEquals(Admin::class, UserTypes::get('admin')->modelClass()); + $this->assertEquals(User::class, UserTypes::get('user')->modelClass()); + } + + #[Test] + public function it_builds_from_config(): void + { + $type = UserType::fromArray('public', [ + 'model' => User::class, + 'guard' => 'web', + 'route_name' => '', + 'features' => ['registration' => false, 'passkeys'], + 'two_factor' => 'required', + 'max_login_attempts' => 3, + 'layouts' => ['auth' => 'layouts.guest'], + ]); + + $this->assertEquals('login', $type->routeNameFor('login')); + $this->assertFalse($type->hasFeature(UserType::REGISTRATION)); + $this->assertTrue($type->hasFeature(UserType::PASSKEYS)); + $this->assertTrue($type->requiresTwoFactor()); + $this->assertEquals(3, $type->maxLoginAttemptsAllowed()); + $this->assertEquals('layouts.guest', $type->layoutName('auth')); + $this->assertEquals('layouts.app', $type->layoutName('account')); + } + + #[Test] + public function it_rejects_unknown_options_and_features(): void + { + $this->expectException(InvalidArgumentException::class); + + UserType::fromArray('public', ['feature' => []]); + } + + #[Test] + public function it_rejects_unknown_features(): void + { + $this->expectException(InvalidArgumentException::class); + + (new UserType('public'))->enable('teleport'); + } + + #[Test] + public function types_can_be_registered_in_code(): void + { + UserTypes::register('vendor', fn (UserType $type) => $type->model(User::class)->guard('web')->disable(UserType::REGISTRATION)); + + $this->assertTrue(UserTypes::has('vendor')); + $this->assertFalse(UserTypes::get('vendor')->hasFeature(UserType::REGISTRATION)); + $this->assertEquals('user', UserTypes::default()->name()); + } + + #[Test] + public function home_can_be_a_path_a_route_or_a_closure(): void + { + $type = new UserType('public'); + + $this->assertEquals(url('/'), $type->homeUrl()); + $this->assertEquals(route('dashboard'), $type->home('dashboard')->homeUrl()); + $this->assertEquals(url('/u/1'), $type->home(fn ($user) => url('/u/1'))->homeUrl()); + } +} From e5d016921d77ab66aeaf52cf824206f8f8e93c08 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 16:26:39 +0500 Subject: [PATCH 04/14] Account actions for admins with a policy and permissions through javaabu/permissions Route::userAccountActions() adds unlock, reset two-factor, require password update and log out everywhere for one user type's accounts, behind the auth.* abilities of UserAccountPolicy. Permissions ({action}_{type plural}) are registered per user type. Co-Authored-By: Claude Opus 5.5 --- config/javaabu-auth.php | 8 + src/AuthServiceProvider.php | 52 +++++++ .../Controllers/UserAccountsController.php | 95 ++++++++++++ src/Policies/UserAccountPolicy.php | 76 ++++++++++ src/Routing/UserAccountRoutes.php | 44 ++++++ tests/Feature/OverridesTest.php | 44 ++++++ tests/Feature/RouteOptionsTest.php | 33 ++++ tests/Feature/UserAccountsTest.php | 143 ++++++++++++++++++ tests/Fixtures/CustomLoginController.php | 16 ++ tests/Fixtures/DenyAllAccountPolicy.php | 15 ++ tests/Models/Admin.php | 2 + tests/Models/User.php | 2 + tests/views/custom-login.blade.php | 1 + 13 files changed, 531 insertions(+) create mode 100644 src/Http/Controllers/UserAccountsController.php create mode 100644 src/Policies/UserAccountPolicy.php create mode 100644 src/Routing/UserAccountRoutes.php create mode 100644 tests/Feature/OverridesTest.php create mode 100644 tests/Feature/RouteOptionsTest.php create mode 100644 tests/Feature/UserAccountsTest.php create mode 100644 tests/Fixtures/CustomLoginController.php create mode 100644 tests/Fixtures/DenyAllAccountPolicy.php create mode 100644 tests/views/custom-login.blade.php diff --git a/config/javaabu-auth.php b/config/javaabu-auth.php index f02b258..c92aac6 100644 --- a/config/javaabu-auth.php +++ b/config/javaabu-auth.php @@ -1,6 +1,7 @@ [], + /* + | The policy behind the auth.unlock, auth.reset-two-factor, + | auth.require-password-update and auth.log-out abilities. + */ + + 'policy' => UserAccountPolicy::class, + /* |-------------------------------------------------------------------------- | Activity log diff --git a/src/AuthServiceProvider.php b/src/AuthServiceProvider.php index 5e3363f..291c95a 100644 --- a/src/AuthServiceProvider.php +++ b/src/AuthServiceProvider.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Exceptions\Handler; use Illuminate\Http\Request; use Illuminate\Routing\Router; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Session; @@ -33,12 +34,15 @@ use Javaabu\Auth\Http\Middleware\SetUserType; use Javaabu\Auth\Passkeys\Passkey; use Javaabu\Auth\Passkeys\VerifyPasskey; +use Javaabu\Auth\Policies\UserAccountPolicy; use Javaabu\Auth\Routing\AuthRoutes; +use Javaabu\Auth\Routing\UserAccountRoutes; use Javaabu\Auth\Sessions\MultiAuthDatabaseSessionHandler; use Javaabu\Auth\Support\ActivityLogger; use Javaabu\Auth\TwoFactor\TwoFactorProvider; use Javaabu\Manifest\Concerns\RegistersAssets; use Javaabu\Manifest\Package; +use Javaabu\Permissions\Permissions; use Laravel\Passkeys\Actions\VerifyPasskey as BaseVerifyPasskey; use Laravel\Passkeys\Passkeys; use PragmaRX\Google2FA\Google2FA; @@ -118,6 +122,9 @@ public function boot(): void $this->registerRateLimiters(); $this->registerSessionDriver(); $this->registerGuestRedirects(); + $this->registerAbilities(); + + $this->app->booted(fn () => $this->registerPermissions()); $this->app->make(ActivityLogger::class)->register(); } @@ -178,6 +185,51 @@ protected function registerRouteMacros(): void Route::macro('authRoutes', function (?string $type = null, array $options = []) { AuthRoutes::register($this, $type, $options); }); + + Route::macro('userAccountActions', function (string $type, array $options = []) { + UserAccountRoutes::register($this, $type, $options); + }); + } + + /** + * The abilities of UserAccountPolicy (or javaabu-auth.policy), unless the app defined them + */ + protected function registerAbilities(): void + { + $policy = config('javaabu-auth.policy', UserAccountPolicy::class); + + foreach (UserAccountPolicy::ABILITIES as $ability => $action) { + if (Gate::has($ability)) { + continue; + } + + Gate::define($ability, [$policy, str($action)->camel()->toString()]); + } + } + + /** + * Permissions for managing each user type's accounts, through javaabu/permissions + */ + protected function registerPermissions(): void + { + if (! config('javaabu-auth.register_permissions', true)) { + return; + } + + $managed = config('javaabu-auth.managed_user_types') ?: array_keys(UserTypes::all()); + + foreach ($managed as $name) { + $type = UserTypes::get($name); + $resource = UserAccountPolicy::resource($type); + $permissions = []; + + foreach (UserAccountPolicy::DESCRIPTIONS as $action => $description) { + // English, as javaabu/permissions translates descriptions when it shows them + $permissions[UserAccountPolicy::permission($action, $type)] = strtr($description, [':resource' => str_replace('_', ' ', $resource)]); + } + + Permissions::register($resource, $permissions); + } } protected function registerRateLimiters(): void diff --git a/src/Http/Controllers/UserAccountsController.php b/src/Http/Controllers/UserAccountsController.php new file mode 100644 index 0000000..fad21a7 --- /dev/null +++ b/src/Http/Controllers/UserAccountsController.php @@ -0,0 +1,95 @@ +account($request, $account, 'auth.unlock'); + + if ($model instanceof LocksAfterFailedLogins) { + $model->resetLoginAttempts(); + } + + return $this->respond($request, __('The account has been unlocked.')); + } + + public function resetTwoFactor(Request $request, string $account, DisableTwoFactorAuthentication $disable): Response + { + [$type, $model] = $this->account($request, $account, 'auth.reset-two-factor'); + + $disable($model); + + return $this->respond($request, __('Two-factor authentication has been reset for the account.')); + } + + public function requirePasswordUpdate(Request $request, string $account): Response + { + [$type, $model] = $this->account($request, $account, 'auth.require-password-update'); + + if ($model instanceof RequiresPasswordUpdate) { + $model->requirePasswordUpdate(); + } + + return $this->respond($request, __('The user will have to choose a new password when they next log in.')); + } + + public function logOut(Request $request, string $account): Response + { + [$type, $model] = $this->account($request, $account, 'auth.log-out'); + + $model->setRememberToken(Str::random(60)); + $model->save(); + + if (method_exists($model, 'sessions') && config('session.driver') === 'multi_auth_database') { + $model->sessions()->delete(); + } + + if (method_exists($model, 'tokens')) { + $model->tokens()->delete(); + } + + return $this->respond($request, __('The user has been logged out from every device.')); + } + + /** + * The managed type and its account, after checking the ability + * + * @return array{0: UserType, 1: Model} + */ + protected function account(Request $request, string $account, string $ability): array + { + $type = UserTypes::get($request->route()->getAction(UserAccountRoutes::ROUTE_ACTION)); + $model = $type->newModel()->resolveRouteBinding($account) ?? abort(404); + + Gate::authorize($ability, [$type, $model]); + + return [$type, $model]; + } + + protected function respond(Request $request, string $message): Response + { + if ($request->wantsJson()) { + return new JsonResponse(['message' => $message]); + } + + return back()->with('status', $message); + } +} diff --git a/src/Policies/UserAccountPolicy.php b/src/Policies/UserAccountPolicy.php new file mode 100644 index 0000000..e356b42 --- /dev/null +++ b/src/Policies/UserAccountPolicy.php @@ -0,0 +1,76 @@ + action, the permission being `{action}_{resource}` */ + public const array ABILITIES = [ + 'auth.unlock' => 'unlock', + 'auth.reset-two-factor' => 'reset_two_factor', + 'auth.require-password-update' => 'require_password_update', + 'auth.log-out' => 'log_out', + ]; + + /** English descriptions of the permissions, used as translation keys */ + public const array DESCRIPTIONS = [ + 'unlock' => 'Unlock locked :resource', + 'reset_two_factor' => 'Reset two-factor authentication of :resource', + 'require_password_update' => 'Make :resource choose a new password', + 'log_out' => 'Log out :resource from every device', + ]; + + public function unlock(Model $user, UserType $type, ?Model $account = null): bool + { + return $this->allows($user, 'unlock', $type); + } + + public function resetTwoFactor(Model $user, UserType $type, ?Model $account = null): bool + { + return $this->allows($user, 'reset_two_factor', $type); + } + + public function requirePasswordUpdate(Model $user, UserType $type, ?Model $account = null): bool + { + return $this->allows($user, 'require_password_update', $type); + } + + public function logOut(Model $user, UserType $type, ?Model $account = null): bool + { + return $this->allows($user, 'log_out', $type); + } + + /** + * The resource part of the permission names: the plural of the type, e.g. `users`, `admins` + */ + public static function resource(UserType $type): string + { + return str($type->name())->plural()->snake()->toString(); + } + + public static function permission(string $action, UserType $type): string + { + return "{$action}_" . static::resource($type); + } + + protected function allows(Model $user, string $action, UserType $type): bool + { + return $user->can(static::permission($action, $type)); + } +} diff --git a/src/Routing/UserAccountRoutes.php b/src/Routing/UserAccountRoutes.php new file mode 100644 index 0000000..d1220ca --- /dev/null +++ b/src/Routing/UserAccountRoutes.php @@ -0,0 +1,44 @@ + + * } $options + */ + public static function register(Router $router, string $type, array $options = []): void + { + $user_type = UserTypes::get($type); + $resource = UserAccountPolicy::resource($user_type); + $controller = config('javaabu-auth.controllers.user_accounts'); + $name = rtrim($options['name'] ?? "{$resource}.account", '.'); + + $router->group([ + 'prefix' => $options['prefix'] ?? $resource, + 'as' => "{$name}.", + 'middleware' => (array) ($options['middleware'] ?? ['web']), + self::ROUTE_ACTION => $user_type->name(), + ], function (Router $router) use ($controller) { + $router->post('{account}/unlock', [$controller, 'unlock'])->name('unlock'); + $router->delete('{account}/two-factor', [$controller, 'resetTwoFactor'])->name('reset-two-factor'); + $router->post('{account}/require-password-update', [$controller, 'requirePasswordUpdate'])->name('require-password-update'); + $router->post('{account}/log-out', [$controller, 'logOut'])->name('log-out'); + }); + } +} diff --git a/tests/Feature/OverridesTest.php b/tests/Feature/OverridesTest.php new file mode 100644 index 0000000..d03d3dd --- /dev/null +++ b/tests/Feature/OverridesTest.php @@ -0,0 +1,44 @@ +set('javaabu-auth.controllers.login', CustomLoginController::class); + } + + #[Test] + #[DefineEnvironment('usesCustomLoginController')] + public function controllers_can_be_replaced_from_config(): void + { + $this->get('/login')->assertOk()->assertSee('custom login for user'); + $this->get('/admin/login')->assertOk()->assertSee('custom login for admin'); + } + + #[Test] + public function actions_can_be_replaced_in_the_container(): void + { + $this->app->bind(CreatesNewUsers::class, fn () => new class implements CreatesNewUsers + { + public function create(UserType $type, array $input): Model + { + return User::factory()->create(['name' => 'ކަސްޓަމް', 'email' => $input['email']]); + } + }); + + $this->post('/register', ['email' => 'custom@example.com'])->assertRedirect('/dashboard'); + + $this->assertDatabaseHas('users', ['email' => 'custom@example.com', 'name' => 'ކަސްޓަމް']); + } +} diff --git a/tests/Feature/RouteOptionsTest.php b/tests/Feature/RouteOptionsTest.php new file mode 100644 index 0000000..5980d38 --- /dev/null +++ b/tests/Feature/RouteOptionsTest.php @@ -0,0 +1,33 @@ + ['password_reset', 'passkeys']]); + Route::authRoutes('admin', ['prefix' => 'staff', 'account_prefix' => 'account', 'middleware' => ['web', 'throttle:60,1']]); + } + + #[Test] + public function it_leaves_out_features_listed_in_except(): void + { + $this->assertFalse(Route::has('password.request')); + $this->assertFalse(Route::has('passkey.login')); + $this->assertTrue(Route::has('login')); + $this->assertTrue(Route::has('admin.passkey.login')); + } + + #[Test] + public function it_uses_the_prefixes_and_middleware(): void + { + $this->assertEquals(url('/staff/login'), route('admin.login')); + $this->assertEquals(url('/staff/account/profile'), route('admin.profile.edit')); + $this->assertContains('throttle:60,1', Route::getRoutes()->getByName('admin.login')->gatherMiddleware()); + } +} diff --git a/tests/Feature/UserAccountsTest.php b/tests/Feature/UserAccountsTest.php new file mode 100644 index 0000000..4155283 --- /dev/null +++ b/tests/Feature/UserAccountsTest.php @@ -0,0 +1,143 @@ +set('permissions.guards', ['web_admin']); + } + + protected function defineRoutes($router): void + { + $this->registerAuthRoutes(); + + Route::userAccountActions('user', ['prefix' => 'admin/users', 'middleware' => ['web', 'auth.type:admin']]); + } + + protected function admin(array $permissions = []): Admin + { + $this->artisan('permissions:sync'); + + $admin = Admin::factory()->create(); + + if ($permissions) { + $admin->givePermissionTo($permissions); + } + + return $admin; + } + + #[Test] + public function it_registers_permissions_for_each_user_type(): void + { + $this->assertTrue(Permissions::has('unlock_users')); + $this->assertTrue(Permissions::has('reset_two_factor_admins')); + $this->assertEquals('Unlock locked users', Permissions::description('unlock_users')); + } + + #[Test] + public function it_needs_the_permission_to_unlock_an_account(): void + { + $user = User::factory()->create(['login_attempts' => 10]); + + $this->actingAs($this->admin(), 'web_admin') + ->post("/admin/users/{$user->id}/unlock") + ->assertForbidden(); + + $this->actingAs($this->admin(['unlock_users']), 'web_admin') + ->post("/admin/users/{$user->id}/unlock") + ->assertRedirect(); + + $this->assertNull($user->fresh()->login_attempts); + } + + #[Test] + public function it_resets_two_factor_authentication(): void + { + $user = User::factory()->withTwoFactor()->create(); + + $this->actingAs($this->admin(['reset_two_factor_users']), 'web_admin') + ->deleteJson("/admin/users/{$user->id}/two-factor") + ->assertOk(); + + $this->assertFalse($user->fresh()->hasEnabledTwoFactorAuthentication()); + } + + #[Test] + public function it_makes_a_user_choose_a_new_password(): void + { + $user = User::factory()->create(); + + $this->actingAs($this->admin(['require_password_update_users']), 'web_admin') + ->postJson("/admin/users/{$user->id}/require-password-update") + ->assertOk(); + + $this->assertTrue($user->fresh()->requiresPasswordUpdate()); + } + + #[Test] + public function it_logs_a_user_out_everywhere(): void + { + $user = User::factory()->create(['remember_token' => 'old']); + $user->createToken('phone'); + + $this->actingAs($this->admin(['log_out_users']), 'web_admin') + ->postJson("/admin/users/{$user->id}/log-out") + ->assertOk(); + + $this->assertNotEquals('old', $user->fresh()->remember_token); + $this->assertCount(0, $user->tokens()->get()); + } + + #[Test] + public function the_routes_need_the_admin_guard(): void + { + $user = User::factory()->create(); + + $this->actingAs(User::factory()->create(), 'web') + ->post("/admin/users/{$user->id}/unlock") + ->assertRedirect('/admin/login'); + } + + #[Test] + public function the_policy_can_be_replaced_with_gate_define(): void + { + Gate::define('auth.unlock', [DenyAllAccountPolicy::class, 'unlock']); + + $user = User::factory()->create(); + + $this->actingAs($this->admin(['unlock_users']), 'web_admin') + ->post("/admin/users/{$user->id}/unlock") + ->assertForbidden(); + } + + protected function usesDenyAllPolicy($app): void + { + $app['config']->set('javaabu-auth.policy', DenyAllAccountPolicy::class); + } + + #[Test] + #[DefineEnvironment('usesDenyAllPolicy')] + public function the_policy_can_be_replaced_from_config(): void + { + $user = User::factory()->create(); + + $this->actingAs($this->admin(['unlock_users']), 'web_admin') + ->post("/admin/users/{$user->id}/unlock") + ->assertForbidden(); + } +} diff --git a/tests/Fixtures/CustomLoginController.php b/tests/Fixtures/CustomLoginController.php new file mode 100644 index 0000000..5d1dfb0 --- /dev/null +++ b/tests/Fixtures/CustomLoginController.php @@ -0,0 +1,16 @@ + $this->userType($request)->name()]); + } +} diff --git a/tests/Fixtures/DenyAllAccountPolicy.php b/tests/Fixtures/DenyAllAccountPolicy.php new file mode 100644 index 0000000..c1c0181 --- /dev/null +++ b/tests/Fixtures/DenyAllAccountPolicy.php @@ -0,0 +1,15 @@ + Date: Wed, 4 Nov 2026 16:29:03 +0500 Subject: [PATCH 05/14] Passkeys for every user type, tested end to end with a software authenticator Passkeys (laravel/passkeys) live in one table with a morph to the user, so admins and users with the same id keep separate keys and can't use each other's. WebAuthn failures answer 422. The tests register and sign with a real P-256 key. Co-Authored-By: Claude Opus 5.5 --- src/AuthServiceProvider.php | 3 + src/Http/Controllers/PasskeysController.php | 4 +- src/Passkeys/StorePasskey.php | 26 +++ src/Passkeys/VerifyPasskey.php | 15 ++ src/Routing/AuthRoutes.php | 4 +- tests/Feature/PasskeysTest.php | 204 ++++++++++++++++++++ tests/Support/CborBytes.php | 13 ++ tests/Support/SoftwareAuthenticator.php | 164 ++++++++++++++++ tests/TestCase.php | 3 + 9 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 src/Passkeys/StorePasskey.php create mode 100644 tests/Feature/PasskeysTest.php create mode 100644 tests/Support/CborBytes.php create mode 100644 tests/Support/SoftwareAuthenticator.php diff --git a/src/AuthServiceProvider.php b/src/AuthServiceProvider.php index 291c95a..04d3521 100644 --- a/src/AuthServiceProvider.php +++ b/src/AuthServiceProvider.php @@ -33,6 +33,7 @@ use Javaabu\Auth\Http\Middleware\RequirePassword; use Javaabu\Auth\Http\Middleware\SetUserType; use Javaabu\Auth\Passkeys\Passkey; +use Javaabu\Auth\Passkeys\StorePasskey; use Javaabu\Auth\Passkeys\VerifyPasskey; use Javaabu\Auth\Policies\UserAccountPolicy; use Javaabu\Auth\Routing\AuthRoutes; @@ -43,6 +44,7 @@ use Javaabu\Manifest\Concerns\RegistersAssets; use Javaabu\Manifest\Package; use Javaabu\Permissions\Permissions; +use Laravel\Passkeys\Actions\StorePasskey as BaseStorePasskey; use Laravel\Passkeys\Actions\VerifyPasskey as BaseVerifyPasskey; use Laravel\Passkeys\Passkeys; use PragmaRX\Google2FA\Google2FA; @@ -97,6 +99,7 @@ public function register(): void }); $this->app->bind(BaseVerifyPasskey::class, VerifyPasskey::class); + $this->app->bind(BaseStorePasskey::class, StorePasskey::class); // The package registers passkey routes per user type Passkeys::ignoreRoutes(); diff --git a/src/Http/Controllers/PasskeysController.php b/src/Http/Controllers/PasskeysController.php index 9923523..79874ef 100644 --- a/src/Http/Controllers/PasskeysController.php +++ b/src/Http/Controllers/PasskeysController.php @@ -44,11 +44,11 @@ public function store(PasskeyRegistrationRequest $request, StorePasskey $store): ], 201); } - public function destroy(Request $request, string $passkey, DeletePasskey $delete): Response + public function destroy(Request $request, string $passkey_id, DeletePasskey $delete): Response { $user = $this->user($request); - $model = $user->passkeys()->whereKey($passkey)->firstOrFail(); + $model = $user->passkeys()->whereKey($passkey_id)->firstOrFail(); $delete($user, $model); diff --git a/src/Passkeys/StorePasskey.php b/src/Passkeys/StorePasskey.php new file mode 100644 index 0000000..0c58479 --- /dev/null +++ b/src/Passkeys/StorePasskey.php @@ -0,0 +1,26 @@ +router->middleware([...$confirm, 'throttle:auth.passkeys'])->group(function () use ($passkeys) { $this->router->get('passkeys/options', [$passkeys, 'options'])->name('passkey.registration-options'); $this->router->post('passkeys', [$passkeys, 'store'])->name('passkey.store'); - $this->router->delete('passkeys/{passkey}', [$passkeys, 'destroy'])->name('passkey.destroy'); + $this->router->delete('passkeys/{passkey_id}', [$passkeys, 'destroy'])->name('passkey.destroy'); }); } @@ -215,7 +215,7 @@ protected function accountRoutes(): void $tokens = $this->controller('api_tokens'); $this->router->post('api-tokens', [$tokens, 'store'])->middleware('throttle:auth.api-tokens')->name('api-tokens.store'); - $this->router->delete('api-tokens/{token}', [$tokens, 'destroy'])->name('api-tokens.destroy'); + $this->router->delete('api-tokens/{token_id}', [$tokens, 'destroy'])->name('api-tokens.destroy'); } }); } diff --git a/tests/Feature/PasskeysTest.php b/tests/Feature/PasskeysTest.php new file mode 100644 index 0000000..eb94665 --- /dev/null +++ b/tests/Feature/PasskeysTest.php @@ -0,0 +1,204 @@ +userType($type)) => time()]; + } + + /** + * Add a passkey through the security page endpoints + */ + protected function addPasskey(SoftwareAuthenticator $authenticator, string $prefix = '', string $name = 'ލެޕްޓޮޕް'): void + { + $options = $this->getJson("{$prefix}/settings/passkeys/options")->assertOk()->json('options'); + + $this->postJson("{$prefix}/settings/passkeys", [ + 'name' => $name, + 'credential' => $authenticator->register($options), + ])->assertCreated(); + } + + protected function logInWithPasskey(SoftwareAuthenticator $authenticator, string $prefix = ''): TestResponse + { + $options = $this->getJson("{$prefix}/passkeys/login/options")->assertOk()->json('options'); + + return $this->postJson("{$prefix}/passkeys/login", [ + 'credential' => $authenticator->assert($options), + ]); + } + + #[Test] + public function it_adds_a_passkey_and_logs_in_with_it(): void + { + $user = User::factory()->create(); + $authenticator = new SoftwareAuthenticator; + + $this->actingAs($user, 'web')->withSession($this->confirmed()); + + $this->addPasskey($authenticator); + + $this->assertDatabaseHas('passkeys', [ + 'user_id' => $user->id, + 'user_type' => $user->getMorphClass(), + 'name' => 'ލެޕްޓޮޕް', + 'credential_id' => $authenticator->credentialId(), + ]); + + $this->post('/logout'); + $this->assertGuest('web'); + + $this->logInWithPasskey($authenticator) + ->assertOk() + ->assertJson(['two_factor' => false, 'redirect' => url('/dashboard')]); + + $this->assertAuthenticatedAs($user, 'web'); + } + + #[Test] + public function a_passkey_skips_the_two_factor_code(): void + { + $user = User::factory()->withTwoFactor()->create(); + $authenticator = new SoftwareAuthenticator; + + $this->actingAs($user, 'web')->withSession($this->confirmed()); + $this->addPasskey($authenticator); + $this->post('/logout'); + + $this->logInWithPasskey($authenticator)->assertJson(['two_factor' => false]); + + $this->assertAuthenticatedAs($user, 'web'); + } + + #[Test] + public function a_users_passkey_cannot_log_in_as_an_admin(): void + { + $user = User::factory()->create(); + Admin::factory()->create(['id' => $user->id]); + $authenticator = new SoftwareAuthenticator; + + $this->actingAs($user, 'web')->withSession($this->confirmed()); + $this->addPasskey($authenticator); + $this->post('/logout'); + + $this->logInWithPasskey($authenticator, '/admin')->assertUnprocessable(); + + $this->assertGuest('web_admin'); + $this->assertGuest('web'); + } + + #[Test] + public function the_same_id_on_two_types_keeps_separate_passkeys(): void + { + $user = User::factory()->create(); + $admin = Admin::factory()->create(['id' => $user->id]); + + $this->actingAs($user, 'web')->withSession($this->confirmed()); + $this->addPasskey(new SoftwareAuthenticator, name: 'user key'); + + $this->actingAs($admin, 'web_admin')->withSession($this->confirmed('admin')); + $this->addPasskey(new SoftwareAuthenticator, '/admin', 'admin key'); + + $this->assertEquals(['user key'], $user->passkeys()->pluck('name')->all()); + $this->assertEquals(['admin key'], $admin->passkeys()->pluck('name')->all()); + } + + #[Test] + public function it_confirms_the_password_with_a_passkey(): void + { + $user = User::factory()->create(); + $authenticator = new SoftwareAuthenticator; + + $this->actingAs($user, 'web')->withSession($this->confirmed()); + $this->addPasskey($authenticator); + + $this->flushSession(); + $this->actingAs($user, 'web'); + + $this->get('/settings/security')->assertRedirect('/confirm-password'); + $this->get('/confirm-password')->assertSee('data-passkey-confirm', false); + + $options = $this->getJson('/passkeys/confirm/options')->assertOk()->json('options'); + + $this->postJson('/passkeys/confirm', ['credential' => $authenticator->assert($options)]) + ->assertOk() + ->assertJson(['redirect' => url('/settings/security')]); + + $this->get('/settings/security')->assertOk(); + } + + #[Test] + public function it_rejects_a_forged_signature(): void + { + $user = User::factory()->create(); + $authenticator = new SoftwareAuthenticator; + + $this->actingAs($user, 'web')->withSession($this->confirmed()); + $this->addPasskey($authenticator); + $this->post('/logout'); + + $options = $this->getJson('/passkeys/login/options')->json('options'); + $credential = $authenticator->assert($options); + $credential['response']['signature'] = (new SoftwareAuthenticator)->assert($options)['response']['signature']; + + $this->postJson('/passkeys/login', ['credential' => $credential])->assertUnprocessable(); + + $this->assertGuest('web'); + } + + #[Test] + public function it_removes_only_the_users_own_passkeys(): void + { + $user = User::factory()->create(); + $other = User::factory()->create(); + + $this->actingAs($other, 'web')->withSession($this->confirmed()); + $this->addPasskey(new SoftwareAuthenticator); + $passkey = Passkey::firstOrFail(); + + $this->actingAs($user, 'web')->withSession($this->confirmed()) + ->deleteJson("/settings/passkeys/{$passkey->id}") + ->assertNotFound(); + + $this->actingAs($other, 'web') + ->deleteJson("/settings/passkeys/{$passkey->id}") + ->assertOk(); + + $this->assertDatabaseCount('passkeys', 0); + } + + #[Test] + public function the_security_page_lists_passkeys(): void + { + $user = User::factory()->create(); + + $this->actingAs($user, 'web')->withSession($this->confirmed()); + $this->addPasskey(new SoftwareAuthenticator); + + $this->get('/settings/security') + ->assertOk() + ->assertSee('ލެޕްޓޮޕް') + ->assertSee('data-passkey-list', false); + } + + #[Test] + public function types_without_passkeys_have_no_passkey_routes(): void + { + UserTypes::get('user')->disable('passkeys'); + + $this->assertTrue(UserTypes::get('admin')->hasRoute('passkey.login')); + } +} diff --git a/tests/Support/CborBytes.php b/tests/Support/CborBytes.php new file mode 100644 index 0000000..a3e6758 --- /dev/null +++ b/tests/Support/CborBytes.php @@ -0,0 +1,13 @@ + 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); + + if (! $key) { + throw new RuntimeException('Could not make a P-256 key.'); + } + + $this->key = $key; + $this->credential_id = random_bytes(32); + } + + public function credentialId(): string + { + return static::base64Url($this->credential_id); + } + + /** + * The credential for `navigator.credentials.create()` with the server's options + * + * @param array $options + * @return array + */ + public function register(array $options): array + { + $this->user_handle = $options['user']['id']; + + $client_data = $this->clientData('webauthn.create', $options['challenge']); + + $details = openssl_pkey_get_details($this->key)['ec']; + $cose_key = static::cbor([ + 1 => 2, + 3 => -7, + -1 => 1, + -2 => new CborBytes(str_pad($details['x'], 32, "\0", STR_PAD_LEFT)), + -3 => new CborBytes(str_pad($details['y'], 32, "\0", STR_PAD_LEFT)), + ]); + + $auth_data = hash('sha256', $this->rp_id, true) + . chr(0x45) + . pack('N', $this->sign_count) + . str_repeat("\0", 16) + . pack('n', strlen($this->credential_id)) + . $this->credential_id + . $cose_key; + + $attestation = static::cbor([ + 'fmt' => 'none', + 'attStmt' => [], + 'authData' => new CborBytes($auth_data), + ]); + + return [ + 'id' => $this->credentialId(), + 'rawId' => $this->credentialId(), + 'type' => 'public-key', + 'response' => [ + 'clientDataJSON' => static::base64Url($client_data), + 'attestationObject' => static::base64Url($attestation), + 'transports' => ['internal'], + ], + 'clientExtensionResults' => new stdClass, + ]; + } + + /** + * The credential for `navigator.credentials.get()` with the server's options + * + * @param array $options + * @return array + */ + public function assert(array $options): array + { + $this->sign_count++; + + $client_data = $this->clientData('webauthn.get', $options['challenge']); + $auth_data = hash('sha256', $this->rp_id, true) . chr(0x05) . pack('N', $this->sign_count); + + openssl_sign($auth_data . hash('sha256', $client_data, true), $signature, $this->key, OPENSSL_ALGO_SHA256); + + return [ + 'id' => $this->credentialId(), + 'rawId' => $this->credentialId(), + 'type' => 'public-key', + 'response' => [ + 'clientDataJSON' => static::base64Url($client_data), + 'authenticatorData' => static::base64Url($auth_data), + 'signature' => static::base64Url($signature), + 'userHandle' => $this->user_handle, + ], + 'clientExtensionResults' => new stdClass, + ]; + } + + protected function clientData(string $type, string $challenge): string + { + return json_encode([ + 'type' => $type, + 'challenge' => $challenge, + 'origin' => $this->origin, + 'crossOrigin' => false, + ], JSON_UNESCAPED_SLASHES); + } + + public static function base64Url(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + /** + * A small CBOR encoder: maps, integers, text and byte strings + */ + public static function cbor(mixed $value): string + { + return match (true) { + $value instanceof CborBytes => static::cborHead(2, strlen($value->bytes)) . $value->bytes, + is_int($value) && $value >= 0 => static::cborHead(0, $value), + is_int($value) => static::cborHead(1, -1 - $value), + is_string($value) => static::cborHead(3, strlen($value)) . $value, + is_array($value) => static::cborHead(5, count($value)) . implode('', array_map( + fn ($key, $item) => static::cbor($key) . static::cbor($item), + array_keys($value), + $value, + )), + default => throw new RuntimeException('Unsupported CBOR value.'), + }; + } + + protected static function cborHead(int $major, int $length): string + { + $type = $major << 5; + + return match (true) { + $length < 24 => chr($type | $length), + $length < 256 => chr($type | 24) . chr($length), + $length < 65536 => chr($type | 25) . pack('n', $length), + default => chr($type | 26) . pack('N', $length), + }; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 259ccb0..c1134d0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -48,6 +48,9 @@ protected function defineEnvironment($app): void $config->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); $config->set('app.url', 'http://localhost'); + $config->set('passkeys.user_handle_secret', 'test-secret'); + $config->set('passkeys.relying_party_id', 'localhost'); + $config->set('passkeys.allowed_origins', ['http://localhost']); $config->set('session.serialization', 'php'); $config->set('view.paths', [__DIR__ . '/views', ...$config->get('view.paths', [])]); From 81ddd15d3e613b5682133791125a5392a749edd0 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 16:32:16 +0500 Subject: [PATCH 06/14] API authentication with Sanctum, Passport or both, and auth:install Route::authApiRoutes() issues and revokes Sanctum tokens per user type (with the two-factor code when the user has one). The security page manages personal access tokens with either driver. Passport uses its own per-provider clients; auth:install publishes, makes keys and prints the guards for each user type. Co-Authored-By: Claude Opus 5.5 --- config/javaabu-auth.php | 1 + src/Actions/IssueApiToken.php | 89 ++++++++++ src/AuthServiceProvider.php | 10 ++ src/Commands/InstallCommand.php | 127 ++++++++++++++ .../Controllers/ApiTokenLoginController.php | 44 +++++ src/Http/Controllers/ApiTokensController.php | 50 ++++++ src/Http/Controllers/SecurityController.php | 14 +- src/Routing/AuthApiRoutes.php | 39 +++++ src/Support/ApiTokens.php | 113 +++++++++++++ tests/Feature/InstallCommandTest.php | 58 +++++++ tests/Feature/PassportApiTest.php | 160 ++++++++++++++++++ tests/Feature/SanctumApiTest.php | 149 ++++++++++++++++ tests/Models/Passport/Admin.php | 14 ++ tests/Models/Passport/User.php | 14 ++ 14 files changed, 871 insertions(+), 11 deletions(-) create mode 100644 src/Actions/IssueApiToken.php create mode 100644 src/Commands/InstallCommand.php create mode 100644 src/Http/Controllers/ApiTokenLoginController.php create mode 100644 src/Http/Controllers/ApiTokensController.php create mode 100644 src/Routing/AuthApiRoutes.php create mode 100644 src/Support/ApiTokens.php create mode 100644 tests/Feature/InstallCommandTest.php create mode 100644 tests/Feature/PassportApiTest.php create mode 100644 tests/Feature/SanctumApiTest.php create mode 100644 tests/Models/Passport/Admin.php create mode 100644 tests/Models/Passport/User.php diff --git a/config/javaabu-auth.php b/config/javaabu-auth.php index c92aac6..1bc2aeb 100644 --- a/config/javaabu-auth.php +++ b/config/javaabu-auth.php @@ -211,6 +211,7 @@ 'password' => Controllers\PasswordController::class, 'sessions' => Controllers\BrowserSessionsController::class, 'api_tokens' => Controllers\ApiTokensController::class, + 'api_token_login' => Controllers\ApiTokenLoginController::class, 'user_accounts' => Controllers\UserAccountsController::class, ], diff --git a/src/Actions/IssueApiToken.php b/src/Actions/IssueApiToken.php new file mode 100644 index 0000000..3294792 --- /dev/null +++ b/src/Actions/IssueApiToken.php @@ -0,0 +1,89 @@ +usernameColumn(); + $provider = $type->auth()->getProvider(); + $credentials = [ + $username => mb_strtolower(trim((string) $request->input($username))), + 'password' => (string) $request->input('password'), + ]; + + $user = $provider->retrieveByCredentials([$username => $credentials[$username]]); + + if (! $user || ! $provider->validateCredentials($user, $credentials)) { + event(new Failed($type->guardName(), $user, $credentials)); + + if ($user instanceof LocksAfterFailedLogins) { + $user->incrementLoginAttempts(); + } + + throw ValidationException::withMessages([$username => __('These credentials do not match our records.')]); + } + + $inactive = AccountState::inactiveMessage($user); + + if ($inactive !== null) { + throw ValidationException::withMessages([$username => $inactive]); + } + + if ($this->start_session->needsTwoFactorChallenge($type, $user)) { + $this->checkTwoFactor($request, $user); + } + + if ($user instanceof LocksAfterFailedLogins) { + $user->resetLoginAttempts(); + } + + if (! ApiTokens::supports($user)) { + throw ValidationException::withMessages([$username => __('API tokens are not available for this account.')]); + } + + return ApiTokens::create($user, (string) $request->input('device_name')); + } + + protected function checkTwoFactor(Request $request, TwoFactorAuthenticatable $user): void + { + if ($request->filled('recovery_code')) { + foreach ($user->recoveryCodes() as $code) { + if (hash_equals($code, (string) $request->input('recovery_code'))) { + $user->replaceRecoveryCode($code); + + return; + } + } + + throw ValidationException::withMessages(['recovery_code' => __('The provided two-factor recovery code was invalid.')]); + } + + if (! $request->filled('code')) { + throw ValidationException::withMessages(['code' => __('Enter the code from your authenticator app.')]); + } + + if (! $this->two_factor->verify((string) $user->twoFactorSecret(), (string) $request->input('code'))) { + throw ValidationException::withMessages(['code' => __('The provided two-factor authentication code was invalid.')]); + } + } +} diff --git a/src/AuthServiceProvider.php b/src/AuthServiceProvider.php index 04d3521..e28cc29 100644 --- a/src/AuthServiceProvider.php +++ b/src/AuthServiceProvider.php @@ -19,6 +19,7 @@ use Javaabu\Auth\Actions\ResetUserPassword; use Javaabu\Auth\Actions\UpdateUserPassword; use Javaabu\Auth\Actions\UpdateUserProfileInformation; +use Javaabu\Auth\Commands\InstallCommand; use Javaabu\Auth\Contracts\CreatesNewUsers; use Javaabu\Auth\Contracts\DeletesUsers; use Javaabu\Auth\Contracts\ResetsUserPasswords; @@ -36,6 +37,7 @@ use Javaabu\Auth\Passkeys\StorePasskey; use Javaabu\Auth\Passkeys\VerifyPasskey; use Javaabu\Auth\Policies\UserAccountPolicy; +use Javaabu\Auth\Routing\AuthApiRoutes; use Javaabu\Auth\Routing\AuthRoutes; use Javaabu\Auth\Routing\UserAccountRoutes; use Javaabu\Auth\Sessions\MultiAuthDatabaseSessionHandler; @@ -110,6 +112,10 @@ public function boot(): void { if ($this->app->runningInConsole()) { $this->registerPublishing(); + + $this->commands([ + InstallCommand::class, + ]); } $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); @@ -189,6 +195,10 @@ protected function registerRouteMacros(): void AuthRoutes::register($this, $type, $options); }); + Route::macro('authApiRoutes', function (?string $type = null, array $options = []) { + AuthApiRoutes::register($this, $type, $options); + }); + Route::macro('userAccountActions', function (string $type, array $options = []) { UserAccountRoutes::register($this, $type, $options); }); diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php new file mode 100644 index 0000000..569d5f5 --- /dev/null +++ b/src/Commands/InstallCommand.php @@ -0,0 +1,127 @@ +option('api') ?? ( + $this->input->isInteractive() + ? $this->choice('Which API authentication do you want?', self::API_CHOICES, 0) + : 'none' + ); + + if (! in_array($api, self::API_CHOICES, true)) { + $this->error("Unknown API option `{$api}`. Use none, sanctum, passport or both."); + + return self::FAILURE; + } + + $this->info('Publishing the config and migrations...'); + + $this->callSilently('vendor:publish', ['--tag' => 'auth-config', '--force' => $this->option('force')]); + $this->callSilently('vendor:publish', ['--tag' => 'auth-migrations', '--force' => $this->option('force')]); + + $this->setApiOption($api); + + $ok = true; + + if (in_array($api, ['sanctum', 'both'], true)) { + $ok = $this->installSanctum() && $ok; + } + + if (in_array($api, ['passport', 'both'], true)) { + $ok = $this->installPassport() && $ok; + } + + $this->comment($ok ? 'javaabu/auth is installed.' : 'javaabu/auth is installed, but some steps need you (see above).'); + + return $ok ? self::SUCCESS : self::FAILURE; + } + + protected function installSanctum(): bool + { + if (! class_exists(Sanctum::class)) { + $this->warn('Install Sanctum first: composer require laravel/sanctum, then run this command again.'); + + return false; + } + + $this->info('Publishing the Sanctum migrations...'); + + $this->callSilently('vendor:publish', ['--provider' => 'Laravel\Sanctum\SanctumServiceProvider']); + + $guards = implode("', '", array_map(fn ($type) => $type->guardName(), UserTypes::all())); + + $this->line(' Add Laravel\Sanctum\HasApiTokens to the user models that get API tokens,'); + $this->line(" and list the session guards in config/sanctum.php for SPA logins: 'guard' => ['{$guards}']."); + + return true; + } + + protected function installPassport(): bool + { + if (! class_exists(Passport::class)) { + $this->warn('Install Passport first: composer require laravel/passport, then run this command again.'); + + return false; + } + + $this->info('Publishing the Passport migrations and making the encryption keys...'); + + $this->callSilently('vendor:publish', ['--tag' => 'passport-migrations']); + $this->callSilently('passport:keys', ['--force' => $this->option('force')]); + + $this->line(' Add Laravel\Passport\HasApiTokens and Laravel\Passport\Contracts\OAuthenticatable to the user models,'); + $this->line(' then add a passport guard and a personal access client for each user type:'); + + foreach (UserTypes::all() as $type) { + $provider = config("auth.guards.{$type->guardName()}.provider", $type->passwordBrokerName()); + + $this->line(" 'api_{$type->name()}' => ['driver' => 'passport', 'provider' => '{$provider}'],"); + $this->line(" php artisan passport:client --personal --provider={$provider}"); + } + + return true; + } + + protected function setApiOption(string $api): void + { + $env = $this->laravel->environmentFilePath(); + + if (! File::exists($env)) { + $this->line(" Set AUTH_API={$api} in your .env file."); + + return; + } + + $contents = File::get($env); + $line = "AUTH_API={$api}"; + + $contents = preg_match('/^AUTH_API=.*$/m', $contents) + ? preg_replace('/^AUTH_API=.*$/m', $line, $contents) + : rtrim($contents) . PHP_EOL . $line . PHP_EOL; + + File::put($env, $contents); + + $this->line(" Set {$line} in .env."); + } +} diff --git a/src/Http/Controllers/ApiTokenLoginController.php b/src/Http/Controllers/ApiTokenLoginController.php new file mode 100644 index 0000000..76d5957 --- /dev/null +++ b/src/Http/Controllers/ApiTokenLoginController.php @@ -0,0 +1,44 @@ +userType($request); + + $request->validate([ + $type->usernameColumn() => ['required', 'string', 'max:255'], + 'password' => ['required', 'string'], + 'device_name' => ['required', 'string', 'max:255'], + 'code' => ['nullable', 'string'], + 'recovery_code' => ['nullable', 'string'], + ]); + + return new JsonResponse([ + 'token' => $issue($request, $type), + 'token_type' => 'Bearer', + ], 201); + } + + public function destroy(Request $request): JsonResponse + { + $user = $request->user(); + + if (ApiTokens::driverFor($user) === ApiTokens::SANCTUM) { + $user->currentAccessToken()?->delete(); + } + + return new JsonResponse('', 204); + } +} diff --git a/src/Http/Controllers/ApiTokensController.php b/src/Http/Controllers/ApiTokensController.php new file mode 100644 index 0000000..fbcc7f2 --- /dev/null +++ b/src/Http/Controllers/ApiTokensController.php @@ -0,0 +1,50 @@ +user($request); + + abort_unless(ApiTokens::supports($user), 404); + + $request->validate([ + 'token_name' => ['required', 'string', 'max:255'], + ]); + + $token = ApiTokens::create($user, (string) $request->input('token_name')); + + if ($request->wantsJson()) { + return new JsonResponse(['token' => $token], 201); + } + + return back() + ->with('status', __('API token created.')) + ->with('plain_text_token', $token); + } + + public function destroy(Request $request, string $token_id): Response + { + $user = $this->user($request); + + abort_unless(ApiTokens::supports($user) && ApiTokens::revoke($user, $token_id), 404); + + $message = __('API token deleted.'); + + if ($request->wantsJson()) { + return new JsonResponse(['message' => $message]); + } + + return back()->with('status', $message); + } +} diff --git a/src/Http/Controllers/SecurityController.php b/src/Http/Controllers/SecurityController.php index 6cfbdfe..4b3e384 100644 --- a/src/Http/Controllers/SecurityController.php +++ b/src/Http/Controllers/SecurityController.php @@ -7,6 +7,7 @@ use Illuminate\Http\Request; use Javaabu\Auth\Contracts\TwoFactorAuthenticatable; use Javaabu\Auth\Sessions\Session; +use Javaabu\Auth\Support\ApiTokens; use Javaabu\Auth\UserType; /** @@ -80,19 +81,10 @@ protected function sessions(UserType $type, mixed $user): ?array /** @return array>|null */ protected function apiTokens(UserType $type, mixed $user): ?array { - if (! $type->hasFeature(UserType::API_TOKENS) || ! $type->hasRoute('api-tokens.store') || ! method_exists($user, 'tokens')) { + if (! $type->hasFeature(UserType::API_TOKENS) || ! $type->hasRoute('api-tokens.store') || ! ApiTokens::supports($user)) { return null; } - return $user->tokens() - ->latest() - ->get() - ->map(fn ($token) => [ - 'id' => $token->id, - 'name' => $token->name, - 'last_used_at' => $token->last_used_at?->diffForHumans(), - 'created_at' => $token->created_at?->diffForHumans(), - ]) - ->all(); + return ApiTokens::list($user); } } diff --git a/src/Routing/AuthApiRoutes.php b/src/Routing/AuthApiRoutes.php new file mode 100644 index 0000000..1ae72ad --- /dev/null +++ b/src/Routing/AuthApiRoutes.php @@ -0,0 +1,39 @@ +, + * auth_middleware?: string|array + * } $options + */ + public static function register(Router $router, ?string $type = null, array $options = []): void + { + $user_type = $type ? UserTypes::get($type) : UserTypes::default(); + $controller = config('javaabu-auth.controllers.api_token_login'); + + $router->group([ + 'prefix' => $options['prefix'] ?? 'api', + 'as' => "{$user_type->routeNamePrefix()}api.", + 'middleware' => (array) ($options['middleware'] ?? ['api']), + UserTypeRegistry::ROUTE_ACTION => $user_type->name(), + ], function (Router $router) use ($controller, $options) { + $router->post('tokens', [$controller, 'store'])->middleware('throttle:auth.api-tokens')->name('tokens.store'); + $router->delete('tokens/current', [$controller, 'destroy']) + ->middleware((array) ($options['auth_middleware'] ?? ['auth:sanctum'])) + ->name('tokens.destroy'); + }); + } +} diff --git a/src/Support/ApiTokens.php b/src/Support/ApiTokens.php new file mode 100644 index 0000000..2e6a868 --- /dev/null +++ b/src/Support/ApiTokens.php @@ -0,0 +1,113 @@ + [self::SANCTUM], + 'passport' => [self::PASSPORT], + 'both' => [self::SANCTUM, self::PASSPORT], + default => [], + }; + } + + public static function isEnabled(string $driver): bool + { + return in_array($driver, static::enabledDrivers(), true); + } + + /** + * The driver the model's HasApiTokens trait comes from + */ + public static function driverFor(Model|string $user): ?string + { + $traits = class_uses_recursive($user); + + if (in_array('Laravel\Sanctum\HasApiTokens', $traits, true)) { + return self::SANCTUM; + } + + if (in_array('Laravel\Passport\HasApiTokens', $traits, true)) { + return self::PASSPORT; + } + + return null; + } + + public static function supports(Model $user): bool + { + $driver = static::driverFor($user); + + return $driver !== null && static::isEnabled($driver); + } + + /** + * Create a token and return its plain text, which is shown once + * + * @param array|null $abilities Sanctum abilities or Passport scopes + */ + public static function create(Model $user, string $name, ?array $abilities = null): string + { + $abilities ??= config('javaabu-auth.api_tokens.abilities', ['*']); + + return match (static::driverFor($user)) { + self::SANCTUM => $user->createToken( + $name, + $abilities, + ($minutes = config('javaabu-auth.api_tokens.expiration')) ? now()->addMinutes((int) $minutes) : null, + )->plainTextToken, + self::PASSPORT => $user->createToken($name, $abilities === ['*'] ? [] : $abilities)->accessToken, + default => throw new RuntimeException('Add the HasApiTokens trait of laravel/sanctum or laravel/passport to ' . $user::class . '.'), + }; + } + + /** + * The user's tokens, for the security page + * + * @return array> + */ + public static function list(Model $user): array + { + $query = $user->tokens(); + + if (static::driverFor($user) === self::PASSPORT) { + $query->where('revoked', false); + } + + return $query->latest()->get()->map(fn ($token) => [ + 'id' => (string) $token->id, + 'name' => $token->name, + 'last_used_at' => $token->last_used_at?->diffForHumans(), + 'created_at' => $token->created_at?->diffForHumans(), + ])->all(); + } + + public static function revoke(Model $user, string $id): bool + { + $token = $user->tokens()->whereKey($id)->first(); + + if (! $token) { + return false; + } + + static::driverFor($user) === self::PASSPORT + ? $token->revoke() + : $token->delete(); + + return true; + } +} diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php new file mode 100644 index 0000000..5831cd2 --- /dev/null +++ b/tests/Feature/InstallCommandTest.php @@ -0,0 +1,58 @@ +artisan('auth:install', ['--api' => 'sanctum']) + ->expectsOutputToContain('Laravel\Sanctum\HasApiTokens') + ->assertSuccessful(); + + $this->assertFileExists(config_path('javaabu-auth.php')); + $this->assertNotEmpty(glob(database_path('migrations/*_create_passkeys_table.php'))); + $this->assertNotEmpty(glob(database_path('migrations/*_create_personal_access_tokens_table.php'))); + } + + #[Test] + public function it_installs_with_both_and_prints_a_passport_guard_per_user_type(): void + { + $this->artisan('auth:install', ['--api' => 'both']) + ->expectsOutputToContain("'api_admin' => ['driver' => 'passport', 'provider' => 'admins']") + ->expectsOutputToContain('passport:client --personal --provider=users') + ->assertSuccessful(); + + $this->assertFileExists(storage_path('oauth-private.key')); + } + + #[Test] + public function it_rejects_unknown_api_options(): void + { + $this->artisan('auth:install', ['--api' => 'jwt'])->assertFailed(); + } +} diff --git a/tests/Feature/PassportApiTest.php b/tests/Feature/PassportApiTest.php new file mode 100644 index 0000000..cd95af9 --- /dev/null +++ b/tests/Feature/PassportApiTest.php @@ -0,0 +1,160 @@ +set('javaabu-auth.api', 'passport'); + $config->set('auth.providers.users.model', User::class); + $config->set('auth.providers.admins.model', Admin::class); + $config->set('auth.guards.api', ['driver' => 'passport', 'provider' => 'users']); + $config->set('auth.guards.api_admin', ['driver' => 'passport', 'provider' => 'admins']); + $config->set('javaabu-auth.user_types.user.features.api_tokens', true); + + Passport::loadKeysFrom(static::keyPath()); + } + + protected function defineDatabaseMigrations(): void + { + parent::defineDatabaseMigrations(); + + $this->loadMigrationsFrom(__DIR__ . '/../../vendor/laravel/passport/database/migrations'); + } + + protected function defineRoutes($router): void + { + $this->registerAuthRoutes(); + + Route::middleware(['api', 'auth:api'])->get('api/me', fn () => request()->user()::class); + Route::middleware(['api', 'auth:api_admin'])->get('api/admin/me', fn () => request()->user()::class); + } + + protected static function keyPath(): string + { + if (static::$key_path) { + return static::$key_path; + } + + $path = sys_get_temp_dir() . '/javaabu-auth-passport-' . getmypid(); + + if (! is_dir($path)) { + mkdir($path); + } + + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key, $private); + + file_put_contents("{$path}/oauth-private.key", $private); + file_put_contents("{$path}/oauth-public.key", openssl_pkey_get_details($key)['key']); + chmod("{$path}/oauth-private.key", 0600); + chmod("{$path}/oauth-public.key", 0600); + + return static::$key_path = $path; + } + + protected function personalAccessClients(): void + { + $clients = app(ClientRepository::class); + + $clients->createPersonalAccessGrantClient('Users', 'users'); + $clients->createPersonalAccessGrantClient('Admins', 'admins'); + } + + protected function makeUser(string $class): User|Admin + { + return $class::forceCreate([ + 'name' => 'އަޙްމަދު', + 'email' => fake()->unique()->safeEmail(), + 'password' => 'password', + 'email_verified_at' => now(), + 'status' => UserStatuses::APPROVED, + ]); + } + + #[Test] + public function personal_access_tokens_work_per_provider(): void + { + $this->personalAccessClients(); + + $user = $this->makeUser(User::class); + $admin = $this->makeUser(Admin::class); + + $user_token = ApiTokens::create($user, 'phone'); + $admin_token = ApiTokens::create($admin, 'tablet'); + + $this->withToken($user_token)->getJson('/api/me')->assertOk()->assertSee(User::class); + $this->withToken($admin_token)->getJson('/api/admin/me')->assertOk()->assertSee(Admin::class); + + // A user's token doesn't pass the admin guard + app('auth')->forgetGuards(); + $this->withToken($user_token)->getJson('/api/admin/me')->assertUnauthorized(); + } + + #[Test] + public function client_credentials_tokens_are_issued_by_passport(): void + { + $client = app(ClientRepository::class)->createClientCredentialsGrantClient('Machine'); + + $this->post('/oauth/token', [ + 'grant_type' => 'client_credentials', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + ])->assertOk()->assertJsonStructure(['access_token', 'expires_in', 'token_type']); + } + + #[Test] + public function users_create_and_revoke_passport_tokens_on_the_security_page(): void + { + $this->personalAccessClients(); + + $user = $this->makeUser(User::class); + $session = [RequirePassword::sessionKey(UserTypes::get('user')) => time()]; + + $this->actingAs($user, 'web')->withSession($session) + ->post('/settings/api-tokens', ['token_name' => 'CI']) + ->assertSessionHas('plain_text_token'); + + $this->get('/settings/security')->assertOk()->assertSee('CI'); + + $token = $user->tokens()->firstOrFail(); + + $this->delete("/settings/api-tokens/{$token->id}")->assertSessionHasNoErrors(); + + $this->assertTrue($token->fresh()->revoked); + $this->assertEquals([], ApiTokens::list($user)); + } + + #[Test] + public function the_driver_is_found_from_the_model(): void + { + $this->assertEquals('passport', ApiTokens::driverFor(User::class)); + $this->assertEquals('sanctum', ApiTokens::driverFor(\Javaabu\Auth\Tests\Models\User::class)); + $this->assertEquals(['passport'], ApiTokens::enabledDrivers()); + } +} diff --git a/tests/Feature/SanctumApiTest.php b/tests/Feature/SanctumApiTest.php new file mode 100644 index 0000000..ab4b040 --- /dev/null +++ b/tests/Feature/SanctumApiTest.php @@ -0,0 +1,149 @@ +set('auth.guards.sanctum', ['driver' => 'sanctum', 'provider' => null]); + $app['config']->set('javaabu-auth.api', 'sanctum'); + $app['config']->set('javaabu-auth.user_types.user.features.api_tokens', true); + $app['config']->set('javaabu-auth.user_types.admin.features.api_tokens', true); + } + + protected function defineRoutes($router): void + { + $this->registerAuthRoutes(); + + Route::authApiRoutes('user', ['prefix' => 'api']); + Route::authApiRoutes('admin', ['prefix' => 'api/admin']); + + Route::middleware(['api', 'auth:sanctum'])->get('api/me', fn () => request()->user()::class . ':' . request()->user()->id); + } + + #[Test] + public function it_issues_a_token_for_a_device_and_the_token_works(): void + { + $user = User::factory()->create(['email' => 'ahmed@example.com']); + + $token = $this->postJson('/api/tokens', [ + 'email' => 'ahmed@example.com', + 'password' => 'password', + 'device_name' => 'ފޯނު', + ])->assertCreated()->assertJsonPath('token_type', 'Bearer')->json('token'); + + $this->assertDatabaseHas('personal_access_tokens', ['tokenable_id' => $user->id, 'name' => 'ފޯނު']); + + $this->withToken($token)->getJson('/api/me')->assertOk()->assertSee(User::class . ':' . $user->id); + } + + #[Test] + public function an_admin_token_is_an_admin_token(): void + { + $admin = Admin::factory()->create(['email' => 'admin@example.com']); + + $token = $this->postJson('/api/admin/tokens', [ + 'email' => 'admin@example.com', + 'password' => 'password', + 'device_name' => 'tablet', + ])->assertCreated()->json('token'); + + $this->withToken($token)->getJson('/api/me')->assertSee(Admin::class . ':' . $admin->id); + } + + #[Test] + public function it_rejects_wrong_credentials_and_other_types(): void + { + User::factory()->create(['email' => 'ahmed@example.com']); + + $this->postJson('/api/tokens', ['email' => 'ahmed@example.com', 'password' => 'wrong', 'device_name' => 'x']) + ->assertJsonValidationErrors('email'); + + $this->postJson('/api/admin/tokens', ['email' => 'ahmed@example.com', 'password' => 'password', 'device_name' => 'x']) + ->assertJsonValidationErrors('email'); + } + + #[Test] + public function it_rejects_inactive_accounts(): void + { + User::factory()->banned()->create(['email' => 'ahmed@example.com']); + + $this->postJson('/api/tokens', ['email' => 'ahmed@example.com', 'password' => 'password', 'device_name' => 'x']) + ->assertJsonValidationErrors(['email' => 'Your account has been banned.']); + } + + #[Test] + public function it_needs_the_two_factor_code_when_the_user_has_one(): void + { + User::factory()->withTwoFactor('JBSWY3DPEHPK3PXP')->create(['email' => 'ahmed@example.com']); + + $this->postJson('/api/tokens', ['email' => 'ahmed@example.com', 'password' => 'password', 'device_name' => 'x']) + ->assertJsonValidationErrors('code'); + + $this->postJson('/api/tokens', [ + 'email' => 'ahmed@example.com', + 'password' => 'password', + 'device_name' => 'x', + 'code' => app(TwoFactorProvider::class)->currentCode('JBSWY3DPEHPK3PXP'), + ])->assertCreated(); + } + + #[Test] + public function it_revokes_the_current_token(): void + { + $user = User::factory()->create(); + $token = $user->createToken('phone')->plainTextToken; + + $this->withToken($token)->deleteJson('/api/tokens/current')->assertNoContent(); + + $this->assertCount(0, $user->tokens()->get()); + } + + #[Test] + public function users_manage_their_tokens_on_the_security_page(): void + { + $user = User::factory()->create(); + $session = [RequirePassword::sessionKey(UserTypes::get('user')) => time()]; + + $this->actingAs($user, 'web')->withSession($session) + ->post('/settings/api-tokens', ['token_name' => 'CI']) + ->assertSessionHas('plain_text_token'); + + $token = $user->tokens()->firstOrFail(); + + $this->get('/settings/security')->assertOk()->assertSee('data-api-tokens', false)->assertSee('CI'); + + $this->delete("/settings/api-tokens/{$token->id}")->assertSessionHasNoErrors(); + + $this->assertCount(0, $user->tokens()->get()); + } + + #[Test] + public function users_cannot_delete_someone_elses_token(): void + { + $other = User::factory()->create(); + $token = $other->createToken('phone')->accessToken; + + $this->actingAs(User::factory()->create(), 'web') + ->deleteJson("/settings/api-tokens/{$token->id}") + ->assertNotFound(); + } +} diff --git a/tests/Models/Passport/Admin.php b/tests/Models/Passport/Admin.php new file mode 100644 index 0000000..d263cdf --- /dev/null +++ b/tests/Models/Passport/Admin.php @@ -0,0 +1,14 @@ + Date: Wed, 4 Nov 2026 16:41:54 +0500 Subject: [PATCH 07/14] Inertia React pages for every auth page, Dhivehi translations and RTL checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The React pages (auth::Login, …, auth::Settings/Security) ship through javaabu/manifest with the same props as the Blade pages, and a shared WebAuthn client for passkeys. lang/dv.json covers every string. Tests check both stacks, the translations, logical direction utilities only, and that the package works without its optional packages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 53 + lang/dv.json | 157 + lang/en.json | 157 + package.json | 32 + pnpm-lock.yaml | 4256 +++++++++++++++++ resources/js/Pages/AccountInactive.tsx | 25 + resources/js/Pages/ConfirmPassword.tsx | 31 + resources/js/Pages/ForgotPassword.tsx | 27 + resources/js/Pages/Login.tsx | 65 + resources/js/Pages/Register.tsx | 27 + resources/js/Pages/ResetPassword.tsx | 20 + resources/js/Pages/Settings/Profile.tsx | 53 + resources/js/Pages/Settings/Security.tsx | 34 + resources/js/Pages/TwoFactorChallenge.tsx | 33 + resources/js/Pages/UpdateRequiredPassword.tsx | 20 + resources/js/Pages/VerifyEmail.tsx | 26 + resources/js/components/AccountLayout.tsx | 43 + resources/js/components/ApiTokensSettings.tsx | 55 + resources/js/components/AuthCard.tsx | 19 + resources/js/components/PasskeyButton.tsx | 74 + resources/js/components/PasskeysSettings.tsx | 80 + resources/js/components/SessionsSettings.tsx | 29 + resources/js/components/StatusAlert.tsx | 13 + resources/js/components/TwoFactorSettings.tsx | 75 + resources/js/index.ts | 12 + resources/js/lib/confirm.ts | 11 + resources/js/lib/strings.ts | 12 + resources/js/types.ts | 94 + .../settings/partials/api-tokens.blade.php | 2 +- .../settings/partials/passkeys.blade.php | 2 +- src/Http/Controllers/SecurityController.php | 11 +- src/Support/PageData.php | 3 +- src/Support/Strings.php | 93 +- tests/Feature/ActivityLogTest.php | 51 + tests/Feature/InstallCommandTest.php | 2 + tests/Feature/PassportApiTest.php | 5 +- tests/Feature/SanctumApiTest.php | 27 +- tests/Feature/UserAccountsTest.php | 2 - tests/Http/BladePagesTest.php | 75 + tests/Http/InertiaPagesTest.php | 139 + tests/Models/Admin.php | 2 - tests/Models/Sanctum/Admin.php | 20 + tests/Models/Sanctum/User.php | 20 + tests/Models/User.php | 2 - tests/TestCase.php | 2 +- tests/Unit/TranslationsTest.php | 110 + tests/js/fixtures.ts | 43 + tests/js/pages.test.tsx | 93 + tests/js/setup.ts | 14 + tests/js/strings.dv.json | 89 + tests/js/webauthn.test.ts | 90 + tsconfig.json | 34 + vitest.config.ts | 10 + 53 files changed, 6457 insertions(+), 17 deletions(-) create mode 100644 lang/dv.json create mode 100644 lang/en.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 resources/js/Pages/AccountInactive.tsx create mode 100644 resources/js/Pages/ConfirmPassword.tsx create mode 100644 resources/js/Pages/ForgotPassword.tsx create mode 100644 resources/js/Pages/Login.tsx create mode 100644 resources/js/Pages/Register.tsx create mode 100644 resources/js/Pages/ResetPassword.tsx create mode 100644 resources/js/Pages/Settings/Profile.tsx create mode 100644 resources/js/Pages/Settings/Security.tsx create mode 100644 resources/js/Pages/TwoFactorChallenge.tsx create mode 100644 resources/js/Pages/UpdateRequiredPassword.tsx create mode 100644 resources/js/Pages/VerifyEmail.tsx create mode 100644 resources/js/components/AccountLayout.tsx create mode 100644 resources/js/components/ApiTokensSettings.tsx create mode 100644 resources/js/components/AuthCard.tsx create mode 100644 resources/js/components/PasskeyButton.tsx create mode 100644 resources/js/components/PasskeysSettings.tsx create mode 100644 resources/js/components/SessionsSettings.tsx create mode 100644 resources/js/components/StatusAlert.tsx create mode 100644 resources/js/components/TwoFactorSettings.tsx create mode 100644 resources/js/index.ts create mode 100644 resources/js/lib/confirm.ts create mode 100644 resources/js/lib/strings.ts create mode 100644 resources/js/types.ts create mode 100644 tests/Feature/ActivityLogTest.php create mode 100644 tests/Http/BladePagesTest.php create mode 100644 tests/Http/InertiaPagesTest.php create mode 100644 tests/Models/Sanctum/Admin.php create mode 100644 tests/Models/Sanctum/User.php create mode 100644 tests/Unit/TranslationsTest.php create mode 100644 tests/js/fixtures.ts create mode 100644 tests/js/pages.test.tsx create mode 100644 tests/js/setup.ts create mode 100644 tests/js/strings.dv.json create mode 100644 tests/js/webauthn.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c94701e..a32ff80 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,6 +34,59 @@ jobs: - name: Check code style run: vendor/bin/pint --test + js: + runs-on: ubuntu-latest + + name: React pages (typecheck, tests) + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false + + # The default store is inside a node_modules folder, which breaks the declaration + # build of @javaabu/forms (installed from git and built on install): Javaabu/react-forms#20 + - name: Install dependencies + run: pnpm install --frozen-lockfile --store-dir "$RUNNER_TEMP/pnpm-store" + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + without-optional: + runs-on: ubuntu-latest + + name: Without the optional packages + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: curl, mbstring, zip, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies without Inertia, Sanctum, Passport and activitylog + run: | + composer remove --dev inertiajs/inertia-laravel laravel/sanctum laravel/passport spatie/laravel-activitylog --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit --exclude-group optional + tests: runs-on: ubuntu-latest diff --git a/lang/dv.json b/lang/dv.json new file mode 100644 index 0000000..3a6a4bf --- /dev/null +++ b/lang/dv.json @@ -0,0 +1,157 @@ +{ + "A new verification link has been sent to your email address.": "ތިބާގެ އީމެއިލް އެޑްރެސްއަށް އައު ތަސްދީޤުކުރާ ލިންކެއް ފޮނުވިއްޖެ.", + "API token created.": "އޭޕީއައި ޓޯކަން އުފެއްދިއްޖެ.", + "API token deleted.": "އޭޕީއައި ޓޯކަން ފުހެލެވިއްޖެ.", + "API tokens": "އޭޕީއައި ޓޯކަންތައް", + "API tokens are not available for this account.": "މި އެކައުންޓަށް އޭޕީއައި ޓޯކަން ނުލިބޭނެ.", + "Account": "އެކައުންޓް", + "Add a passkey": "ޕާސްކީއެއް އިތުރުކުރޭ", + "Add a second step to your login with a code from an authenticator app.": "އޮތެންޓިކޭޓަރ އެޕަކުން ލިބޭ ކޯޑަކުން ލޮގިންވުމަށް ދެވަނަ ފިޔަވަޅެއް އިތުރުކުރޭ.", + "Added :time": ":time އިތުރުކުރި", + "Already have an account?": "ކުރިން އެކައުންޓެއް ހަދާފައިވޭތޯ؟", + "Approved": "ހުއްދަ ދީފައި", + "Are you sure you want to delete your account? This cannot be undone.": "ތިބާގެ އެކައުންޓް ފުހެލަން ބޭނުންތޯ؟ މިކަން އަނބުރާ ނުހެދޭނެ.", + "Back to log in": "ލޮގިން ސަފްޙާއަށް އެނބުރި", + "Banned": "މަނާކޮށްފައި", + "Browser sessions": "ބްރައުޒަރ ސެޝަންތައް", + "Choose a new password": "އައު ޕާސްވޯޑެއް ހަމަޖައްސާ", + "Code": "ކޯޑު", + "Confirm": "ކަށަވަރުކުރޭ", + "Confirm password": "ޕާސްވޯޑް ކަށަވަރުކުރޭ", + "Confirm with a passkey": "ޕާސްކީއިން ކަށަވަރުކުރޭ", + "Confirm your password": "ތިބާގެ ޕާސްވޯޑް ކަށަވަރުކުރޭ", + "Continue": "ކުރިއަށް", + "Copy your new API token now. It will not be shown again.": "އައު އޭޕީއައި ޓޯކަން މިހާރު ކޮޕީކުރޭ. މިއީ އަނެއްކާ ދައްކާނެ އެއްޗެއް ނޫން.", + "Create account": "އެކައުންޓް ހަދާ", + "Create an account": "އެކައުންޓެއް ހަދާ", + "Create token": "ޓޯކަން އުފައްދާ", + "Current password": "މިހާރުގެ ޕާސްވޯޑް", + "Delete": "ފުހެލާ", + "Delete account": "އެކައުންޓް ފުހެލާ", + "Delete this token?": "މި ޓޯކަން ފުހެލަންވީތޯ؟", + "Don't have an account?": "އެކައުންޓެއް ނެތްތޯ؟", + "Email address": "އީމެއިލް އެޑްރެސް", + "Email password reset link": "ޕާސްވޯޑް ރީސެޓްކުރާ ލިންކް އީމެއިލްކުރޭ", + "Enter the code from your authenticator app.": "އޮތެންޓިކޭޓަރ އެޕުން ލިބޭ ކޯޑު ޖައްސަވާ.", + "Enter your email address and we will email you a link to choose a new password.": "ތިބާގެ އީމެއިލް އެޑްރެސް ޖައްސަވާ. އައު ޕާސްވޯޑެއް ހަމަޖެއްސުމަށް ލިންކެއް އީމެއިލްކޮށްދޭނަން.", + "Forgot your password?": "ޕާސްވޯޑް ހަނދާން ނެތީތޯ؟", + "If an account with that email exists, we have emailed a password reset link.": "އެ އީމެއިލް އެޑްރެސްގައި އެކައުންޓެއް ހުރިނަމަ، ޕާސްވޯޑް ރީސެޓްކުރާ ލިންކެއް އީމެއިލްކޮށްފީމު.", + "If you did not create an account, no further action is required.": "ތިބާ އެކައުންޓެއް ހަދާފައި ނުވާނަމަ، އިތުރަށް އެއްވެސް ކަމެއް ކުރަން ނުޖެހޭ.", + "If you did not request a password reset, no further action is required.": "ޕާސްވޯޑް ރީސެޓްކުރަން ތިބާ އެދިފައި ނުވާނަމަ، އިތުރަށް އެއްވެސް ކަމެއް ކުރަން ނުޖެހޭ.", + "Last active :time": ":time ފަހުން ބޭނުންކުރި", + "Last used :time": ":time ފަހުން ބޭނުންކުރި", + "Log in": "ލޮގިން ކުރޭ", + "Log in to your account": "ތިބާގެ އެކައުންޓަށް ލޮގިން ކުރޭ", + "Log in with a passkey": "ޕާސްކީއިން ލޮގިން ކުރޭ", + "Log in with your fingerprint, face or screen lock instead of a password.": "ޕާސްވޯޑެއްގެ ބަދަލުގައި އިނގިލީގެ ނިޝާނު، މޫނު ނުވަތަ ސްކްރީން ލޮކުން ލޮގިން ކުރޭ.", + "Log out": "ލޮގްއައުޓް ކުރޭ", + "Log out admins from every device": "އެޑްމިނުން ހުރިހާ ޑިވައިސްއަކުން ލޮގްއައުޓް ކުރުން", + "Log out other browser sessions": "އެހެން ބްރައުޒަރ ސެޝަންތަކުން ލޮގްއައުޓް ކުރޭ", + "Log out users from every device": "ޔޫޒަރުން ހުރިހާ ޑިވައިސްއަކުން ލޮގްއައުޓް ކުރުން", + "Make admins choose a new password": "އެޑްމިނުންނަށް އައު ޕާސްވޯޑެއް ހަމަޖައްސަން މަޖުބޫރުކުރުން", + "Make users choose a new password": "ޔޫޒަރުންނަށް އައު ޕާސްވޯޑެއް ހަމަޖައްސަން މަޖުބޫރުކުރުން", + "Name": "ނަން", + "Never used": "އެއްފަހަރުވެސް ބޭނުން ނުކުރޭ", + "New password": "އައު ޕާސްވޯޑް", + "New recovery codes have been generated.": "އައު ރިކަވަރީ ކޯޑުތައް އުފެއްދިއްޖެ.", + "Off": "ނިއްވާފައި", + "Once your account is deleted, all of its data is permanently deleted. Enter your password to confirm.": "އެކައުންޓް ފުހެލުމުން، އޭގެ ހުރިހާ މަޢުލޫމާތެއް ދާއިމީކޮށް ފުހެލެވޭނެ. ކަށަވަރުކުރުމަށް ތިބާގެ ޕާސްވޯޑް ޖައްސަވާ.", + "Optional": "އިޚްތިޔާރީ", + "Passkey": "ޕާސްކީ", + "Passkey added.": "ޕާސްކީ އިތުރުކުރެވިއްޖެ.", + "Passkey name": "ޕާސްކީގެ ނަން", + "Passkey not recognized. It may have been removed from your account.": "މި ޕާސްކީ ނުވަޒަނެއް. ތިބާގެ އެކައުންޓުން އެ ފުހެލިފައި ވެދާނެ.", + "Passkey removed.": "ޕާސްކީ ފުހެލެވިއްޖެ.", + "Passkeys": "ޕާސްކީތައް", + "Password": "ޕާސްވޯޑް", + "Password confirmation required.": "ޕާސްވޯޑް ކަށަވަރުކުރަން ޖެހޭ.", + "Pending": "މަޑުކުރަމުން", + "Please click the button below to verify your email address.": "ތިބާގެ އީމެއިލް އެޑްރެސް ތަސްދީޤުކުރުމަށް ތިރީގައިވާ ބަޓަނަށް ފިތާލައްވާ.", + "Please wait before retrying.": "އަލުން މަސައްކަތް ކުރުމުގެ ކުރިން މަޑުކުރައްވާ.", + "Profile": "ޕްރޮފައިލް", + "Profile information": "ޕްރޮފައިލްގެ މަޢުލޫމާތު", + "Recovery code": "ރިކަވަރީ ކޯޑު", + "Regenerate recovery codes": "އައު ރިކަވަރީ ކޯޑުތައް އުފައްދާ", + "Remember me": "ހަނދާންބަހައްޓާ", + "Remove": "ފުހެލާ", + "Remove this passkey?": "މި ޕާސްކީ ފުހެލަންވީތޯ؟", + "Required": "ލާޒިމް", + "Resend verification email": "ތަސްދީޤުކުރާ އީމެއިލް އަލުން ފޮނުވާ", + "Reset Password": "ޕާސްވޯޑް ރީސެޓްކުރޭ", + "Reset Password Notification": "ޕާސްވޯޑް ރީސެޓްކުރުމުގެ ނޯޓިފިކޭޝަން", + "Reset password": "ޕާސްވޯޑް ރީސެޓްކުރޭ", + "Reset two-factor authentication of admins": "އެޑްމިނުންގެ ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ރީސެޓްކުރުން", + "Reset two-factor authentication of users": "ޔޫޒަރުންގެ ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ރީސެޓްކުރުން", + "Reset your password": "ތިބާގެ ޕާސްވޯޑް ރީސެޓްކުރޭ", + "Save": "ސޭވްކުރޭ", + "Save password": "ޕާސްވޯޑް ސޭވްކުރޭ", + "Scan the QR code with your authenticator app, then enter the code it shows.": "އޮތެންޓިކޭޓަރ އެޕުން ކިއުއާރް ކޯޑު ސްކޭންކޮށް، އެޕުން ދައްކާ ކޯޑު ޖައްސަވާ.", + "Security": "ސެކިއުރިޓީ", + "Set up two-factor authentication to continue.": "ކުރިއަށް ދިއުމަށް ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ހަމަޖައްސަވާ.", + "Setup key": "ސެޓަޕް ކީ", + "Sign up": "ސައިން އަޕް ކުރޭ", + "Store these recovery codes somewhere safe. Each can be used once if you lose your authenticator app.": "މި ރިކަވަރީ ކޯޑުތައް ރައްކާތެރި ތަނެއްގައި ބަހައްޓަވާ. އޮތެންޓިކޭޓަރ އެޕް ގެއްލިއްޖެނަމަ، ކޮންމެ ކޯޑެއް އެއްފަހަރު ބޭނުންކުރެވޭނެ.", + "The account has been unlocked.": "އެކައުންޓް އަނބުރާ ހުޅުވިއްޖެ.", + "The browsers and devices where you are logged in.": "ތިބާ ލޮގިންވެފައިވާ ބްރައުޒަރތަކާއި ޑިވައިސްތައް.", + "The provided password is incorrect.": "ޖެއްސި ޕާސްވޯޑް ރަނގަޅެއް ނޫން.", + "The provided two-factor authentication code was invalid.": "ޖެއްސި ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ކޯޑު ރަނގަޅެއް ނޫން.", + "The provided two-factor recovery code was invalid.": "ޖެއްސި ރިކަވަރީ ކޯޑު ރަނގަޅެއް ނޫން.", + "The same gmail :attribute is already taken.": "މި :attribute ގެ ޖީމެއިލް އެޑްރެސްގެ އެކައުންޓެއް މިހާރުވެސް އެބައިން.", + "The user has been logged out from every device.": "ޔޫޒަރު ހުރިހާ ޑިވައިސްއަކުން ލޮގްއައުޓް ކުރެވިއްޖެ.", + "The user will have to choose a new password when they next log in.": "ޔޫޒަރު އަނެއްކާ ލޮގިންވާއިރު، އައު ޕާސްވޯޑެއް ހަމަޖައްސަން ޖެހޭނެ.", + "These credentials do not match our records.": "މި މަޢުލޫމާތާ ގުޅޭ އެކައުންޓެއް ނުފެނުނު.", + "This browser does not support passkeys.": "މި ބްރައުޒަރުގައި ޕާސްކީ ބޭނުން ނުކުރެވޭނެ.", + "This device": "މި ޑިވައިސް", + "This is a secure area. Please confirm your password before continuing.": "މިއީ ރައްކާތެރި ބައެއް. ކުރިއަށް ދިއުމުގެ ކުރިން ތިބާގެ ޕާސްވޯޑް ކަށަވަރުކުރައްވާ.", + "This password reset link is invalid or has expired.": "މި ޕާސްވޯޑް ރީސެޓްކުރާ ލިންކް ރަނގަޅެއް ނޫން، ނުވަތަ މުއްދަތު ހަމަވެއްޖެ.", + "This password reset link will expire in :count minutes.": "މި ލިންކުގެ މުއްދަތު :count މިނިޓުން ހަމަވާނެ.", + "This verification link is invalid.": "މި ތަސްދީޤުކުރާ ލިންކް ރަނގަޅެއް ނޫން.", + "Token name": "ޓޯކަނުގެ ނަން", + "Too many attempts. Please try again in :seconds seconds.": "ގިނަފަހަރު މަސައްކަތްކޮށްފި. :seconds ސިކުންތު ފަހުން އަލުން މަސައްކަތްކުރައްވާ.", + "Too many login attempts. Please try again in :seconds seconds.": "ގިނަފަހަރު ލޮގިންވާން މަސައްކަތްކޮށްފި. :seconds ސިކުންތު ފަހުން އަލުން މަސައްކަތްކުރައްވާ.", + "Turn off two-factor authentication": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ނިއްވާ", + "Turn on two-factor authentication": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ހުޅުވާ", + "Two-factor authentication": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން", + "Two-factor authentication has been reset for the account.": "އެކައުންޓުގެ ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ރީސެޓްކުރެވިއްޖެ.", + "Two-factor authentication has not been enabled.": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ހުޅުވާފައެއް ނުވޭ.", + "Two-factor authentication is now off.": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން މިހާރު ނިއްވާލެވިއްޖެ.", + "Two-factor authentication is now on.": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން މިހާރު ހުޅުވިއްޖެ.", + "Two-factor authentication is on.": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ހުޅުވާފައި.", + "Two-factor authentication is required for your account.": "ތިބާގެ އެކައުންޓަށް ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ލާޒިމް.", + "Unable to register passkey. Please try again.": "ޕާސްކީ އިތުރެއް ނުކުރެވުނު. އަލުން މަސައްކަތްކުރައްވާ.", + "Unable to sign in with this account.": "މި އެކައުންޓުން ލޮގިނެއް ނުވެވުނު.", + "Unable to verify passkey. Please try again.": "ޕާސްކީ ކަށަވަރެއް ނުކުރެވުނު. އަލުން މަސައްކަތްކުރައްވާ.", + "Unknown": "ނޭނގޭ", + "Unlock locked admins": "ލޮކްވެފައިވާ އެޑްމިނުންގެ އެކައުންޓް ހުޅުވުން", + "Unlock locked users": "ލޮކްވެފައިވާ ޔޫޒަރުންގެ އެކައުންޓް ހުޅުވުން", + "Update password": "ޕާސްވޯޑް ބަދަލުކުރޭ", + "Use a code from your app instead": "އޭގެ ބަދަލުގައި އެޕުން ލިބޭ ކޯޑު ބޭނުންކުރޭ", + "Use a recovery code instead": "އޭގެ ބަދަލުގައި ރިކަވަރީ ކޯޑެއް ބޭނުންކުރޭ", + "Verify Email Address": "އީމެއިލް އެޑްރެސް ތަސްދީޤުކުރޭ", + "Verify your email address": "ތިބާގެ އީމެއިލް އެޑްރެސް ތަސްދީޤުކުރޭ", + "We could not add the passkey. Please try again.": "ޕާސްކީ އިތުރެއް ނުކުރެވުނު. އަލުން މަސައްކަތްކުރައްވާ.", + "We could not confirm with a passkey. Please try again.": "ޕާސްކީއިން ކަށަވަރެއް ނުކުރެވުނު. އަލުން މަސައްކަތްކުރައްވާ.", + "We could not log you in with a passkey. Please try again.": "ޕާސްކީއިން ލޮގިނެއް ނުވެވުނު. އަލުން މަސައްކަތްކުރައްވާ.", + "We have emailed a verification link to :email. Click the link to verify your email address.": ":email އަށް ތަސްދީޤުކުރާ ލިންކެއް އީމެއިލްކޮށްފީމު. އީމެއިލް އެޑްރެސް ތަސްދީޤުކުރުމަށް އެ ލިންކަށް ފިތާލައްވާ.", + "You are already signed in.": "ތިބާ މިހާރުވެސް ލޮގިންވެފައި.", + "You are receiving this email because we received a password reset request for your account.": "ތިބާގެ އެކައުންޓުގެ ޕާސްވޯޑް ރީސެޓްކުރަން އެދިފައިވާތީ މި އީމެއިލް ފޮނުވީ.", + "You have been logged out of your other browser sessions.": "އެހެން ބްރައުޒަރ ސެޝަންތަކުން ލޮގްއައުޓް ކުރެވިއްޖެ.", + "You have no passkeys yet.": "އަދި އެއްވެސް ޕާސްކީއެއް ނެތް.", + "You need to choose a new password before you continue.": "ކުރިއަށް ދިއުމުގެ ކުރިން އައު ޕާސްވޯޑެއް ހަމަޖައްސަން ޖެހޭ.", + "You need to choose a new password.": "އައު ޕާސްވޯޑެއް ހަމަޖައްސަން ޖެހޭ.", + "You need to set up two-factor authentication.": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ހަމަޖައްސަން ޖެހޭ.", + "Your account has been banned.": "ތިބާގެ އެކައުންޓް މަނާކޮށްފައި.", + "Your account has been deleted.": "ތިބާގެ އެކައުންޓް ފުހެލެވިއްޖެ.", + "Your account has been locked due to too many failed logins. Reset your password to unlock it.": "ގިނަފަހަރު ލޮގިން ނުވެވުމުން ތިބާގެ އެކައުންޓް ލޮކްކުރެވިއްޖެ. އަނބުރާ ހުޅުވުމަށް ޕާސްވޯޑް ރީސެޓްކުރައްވާ.", + "Your account is approved.": "ތިބާގެ އެކައުންޓަށް ހުއްދަ ލިބިއްޖެ.", + "Your account is not active": "ތިބާގެ އެކައުންޓް ހަރަކާތްތެރިއެއް ނޫން", + "Your account needs to be approved before you can access it.": "އެކައުންޓް ބޭނުންކުރެވޭނީ އެކައުންޓަށް ހުއްދަ ލިބުމުންނެވެ.", + "Your account needs two-factor authentication. Set it up to continue.": "ތިބާގެ އެކައުންޓަށް ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ލާޒިމް. ކުރިއަށް ދިއުމަށް އެ ހަމަޖައްސަވާ.", + "Your email address has been verified.": "ތިބާގެ އީމެއިލް އެޑްރެސް ތަސްދީޤުކުރެވިއްޖެ.", + "Your email address is not verified.": "ތިބާގެ އީމެއިލް އެޑްރެސް އަދި ތަސްދީޤުކޮށްފައެއް ނުވޭ.", + "Your login has expired. Please log in again.": "ލޮގިންގެ މުއްދަތު ހަމަވެއްޖެ. އަލުން ލޮގިން ކުރައްވާ.", + "Your password has been reset.": "ތިބާގެ ޕާސްވޯޑް ރީސެޓްކުރެވިއްޖެ.", + "Your password has been updated.": "ތިބާގެ ޕާސްވޯޑް ބަދަލުކުރެވިއްޖެ.", + "Your profile has been updated.": "ތިބާގެ ޕްރޮފައިލް ބަދަލުކުރެވިއްޖެ.", + "e.g. Work laptop": "މިސާލަކަށް: އޮފީހުގެ ލެޕްޓޮޕް" +} diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..0d04b7f --- /dev/null +++ b/lang/en.json @@ -0,0 +1,157 @@ +{ + "A new verification link has been sent to your email address.": "A new verification link has been sent to your email address.", + "API token created.": "API token created.", + "API token deleted.": "API token deleted.", + "API tokens": "API tokens", + "API tokens are not available for this account.": "API tokens are not available for this account.", + "Account": "Account", + "Add a passkey": "Add a passkey", + "Add a second step to your login with a code from an authenticator app.": "Add a second step to your login with a code from an authenticator app.", + "Added :time": "Added :time", + "Already have an account?": "Already have an account?", + "Approved": "Approved", + "Are you sure you want to delete your account? This cannot be undone.": "Are you sure you want to delete your account? This cannot be undone.", + "Back to log in": "Back to log in", + "Banned": "Banned", + "Browser sessions": "Browser sessions", + "Choose a new password": "Choose a new password", + "Code": "Code", + "Confirm": "Confirm", + "Confirm password": "Confirm password", + "Confirm with a passkey": "Confirm with a passkey", + "Confirm your password": "Confirm your password", + "Continue": "Continue", + "Copy your new API token now. It will not be shown again.": "Copy your new API token now. It will not be shown again.", + "Create account": "Create account", + "Create an account": "Create an account", + "Create token": "Create token", + "Current password": "Current password", + "Delete": "Delete", + "Delete account": "Delete account", + "Delete this token?": "Delete this token?", + "Don't have an account?": "Don't have an account?", + "Email address": "Email address", + "Email password reset link": "Email password reset link", + "Enter the code from your authenticator app.": "Enter the code from your authenticator app.", + "Enter your email address and we will email you a link to choose a new password.": "Enter your email address and we will email you a link to choose a new password.", + "Forgot your password?": "Forgot your password?", + "If an account with that email exists, we have emailed a password reset link.": "If an account with that email exists, we have emailed a password reset link.", + "If you did not create an account, no further action is required.": "If you did not create an account, no further action is required.", + "If you did not request a password reset, no further action is required.": "If you did not request a password reset, no further action is required.", + "Last active :time": "Last active :time", + "Last used :time": "Last used :time", + "Log in": "Log in", + "Log in to your account": "Log in to your account", + "Log in with a passkey": "Log in with a passkey", + "Log in with your fingerprint, face or screen lock instead of a password.": "Log in with your fingerprint, face or screen lock instead of a password.", + "Log out": "Log out", + "Log out admins from every device": "Log out admins from every device", + "Log out other browser sessions": "Log out other browser sessions", + "Log out users from every device": "Log out users from every device", + "Make admins choose a new password": "Make admins choose a new password", + "Make users choose a new password": "Make users choose a new password", + "Name": "Name", + "Never used": "Never used", + "New password": "New password", + "New recovery codes have been generated.": "New recovery codes have been generated.", + "Off": "Off", + "Once your account is deleted, all of its data is permanently deleted. Enter your password to confirm.": "Once your account is deleted, all of its data is permanently deleted. Enter your password to confirm.", + "Optional": "Optional", + "Passkey": "Passkey", + "Passkey added.": "Passkey added.", + "Passkey name": "Passkey name", + "Passkey not recognized. It may have been removed from your account.": "Passkey not recognized. It may have been removed from your account.", + "Passkey removed.": "Passkey removed.", + "Passkeys": "Passkeys", + "Password": "Password", + "Password confirmation required.": "Password confirmation required.", + "Pending": "Pending", + "Please click the button below to verify your email address.": "Please click the button below to verify your email address.", + "Please wait before retrying.": "Please wait before retrying.", + "Profile": "Profile", + "Profile information": "Profile information", + "Recovery code": "Recovery code", + "Regenerate recovery codes": "Regenerate recovery codes", + "Remember me": "Remember me", + "Remove": "Remove", + "Remove this passkey?": "Remove this passkey?", + "Required": "Required", + "Resend verification email": "Resend verification email", + "Reset Password": "Reset Password", + "Reset Password Notification": "Reset Password Notification", + "Reset password": "Reset password", + "Reset two-factor authentication of admins": "Reset two-factor authentication of admins", + "Reset two-factor authentication of users": "Reset two-factor authentication of users", + "Reset your password": "Reset your password", + "Save": "Save", + "Save password": "Save password", + "Scan the QR code with your authenticator app, then enter the code it shows.": "Scan the QR code with your authenticator app, then enter the code it shows.", + "Security": "Security", + "Set up two-factor authentication to continue.": "Set up two-factor authentication to continue.", + "Setup key": "Setup key", + "Sign up": "Sign up", + "Store these recovery codes somewhere safe. Each can be used once if you lose your authenticator app.": "Store these recovery codes somewhere safe. Each can be used once if you lose your authenticator app.", + "The account has been unlocked.": "The account has been unlocked.", + "The browsers and devices where you are logged in.": "The browsers and devices where you are logged in.", + "The provided password is incorrect.": "The provided password is incorrect.", + "The provided two-factor authentication code was invalid.": "The provided two-factor authentication code was invalid.", + "The provided two-factor recovery code was invalid.": "The provided two-factor recovery code was invalid.", + "The same gmail :attribute is already taken.": "The same gmail :attribute is already taken.", + "The user has been logged out from every device.": "The user has been logged out from every device.", + "The user will have to choose a new password when they next log in.": "The user will have to choose a new password when they next log in.", + "These credentials do not match our records.": "These credentials do not match our records.", + "This browser does not support passkeys.": "This browser does not support passkeys.", + "This device": "This device", + "This is a secure area. Please confirm your password before continuing.": "This is a secure area. Please confirm your password before continuing.", + "This password reset link is invalid or has expired.": "This password reset link is invalid or has expired.", + "This password reset link will expire in :count minutes.": "This password reset link will expire in :count minutes.", + "This verification link is invalid.": "This verification link is invalid.", + "Token name": "Token name", + "Too many attempts. Please try again in :seconds seconds.": "Too many attempts. Please try again in :seconds seconds.", + "Too many login attempts. Please try again in :seconds seconds.": "Too many login attempts. Please try again in :seconds seconds.", + "Turn off two-factor authentication": "Turn off two-factor authentication", + "Turn on two-factor authentication": "Turn on two-factor authentication", + "Two-factor authentication": "Two-factor authentication", + "Two-factor authentication has been reset for the account.": "Two-factor authentication has been reset for the account.", + "Two-factor authentication has not been enabled.": "Two-factor authentication has not been enabled.", + "Two-factor authentication is now off.": "Two-factor authentication is now off.", + "Two-factor authentication is now on.": "Two-factor authentication is now on.", + "Two-factor authentication is on.": "Two-factor authentication is on.", + "Two-factor authentication is required for your account.": "Two-factor authentication is required for your account.", + "Unable to register passkey. Please try again.": "Unable to register passkey. Please try again.", + "Unable to sign in with this account.": "Unable to sign in with this account.", + "Unable to verify passkey. Please try again.": "Unable to verify passkey. Please try again.", + "Unknown": "Unknown", + "Unlock locked admins": "Unlock locked admins", + "Unlock locked users": "Unlock locked users", + "Update password": "Update password", + "Use a code from your app instead": "Use a code from your app instead", + "Use a recovery code instead": "Use a recovery code instead", + "Verify Email Address": "Verify Email Address", + "Verify your email address": "Verify your email address", + "We could not add the passkey. Please try again.": "We could not add the passkey. Please try again.", + "We could not confirm with a passkey. Please try again.": "We could not confirm with a passkey. Please try again.", + "We could not log you in with a passkey. Please try again.": "We could not log you in with a passkey. Please try again.", + "We have emailed a verification link to :email. Click the link to verify your email address.": "We have emailed a verification link to :email. Click the link to verify your email address.", + "You are already signed in.": "You are already signed in.", + "You are receiving this email because we received a password reset request for your account.": "You are receiving this email because we received a password reset request for your account.", + "You have been logged out of your other browser sessions.": "You have been logged out of your other browser sessions.", + "You have no passkeys yet.": "You have no passkeys yet.", + "You need to choose a new password before you continue.": "You need to choose a new password before you continue.", + "You need to choose a new password.": "You need to choose a new password.", + "You need to set up two-factor authentication.": "You need to set up two-factor authentication.", + "Your account has been banned.": "Your account has been banned.", + "Your account has been deleted.": "Your account has been deleted.", + "Your account has been locked due to too many failed logins. Reset your password to unlock it.": "Your account has been locked due to too many failed logins. Reset your password to unlock it.", + "Your account is approved.": "Your account is approved.", + "Your account is not active": "Your account is not active", + "Your account needs to be approved before you can access it.": "Your account needs to be approved before you can access it.", + "Your account needs two-factor authentication. Set it up to continue.": "Your account needs two-factor authentication. Set it up to continue.", + "Your email address has been verified.": "Your email address has been verified.", + "Your email address is not verified.": "Your email address is not verified.", + "Your login has expired. Please log in again.": "Your login has expired. Please log in again.", + "Your password has been reset.": "Your password has been reset.", + "Your password has been updated.": "Your password has been updated.", + "Your profile has been updated.": "Your profile has been updated.", + "e.g. Work laptop": "e.g. Work laptop" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8137ae1 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "javaabu-auth-dev", + "private": true, + "description": "Development tooling for the Inertia React pages of javaabu/auth. Apps don't install this; they get the pages through javaabu/manifest.", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "check": "pnpm typecheck && pnpm test" + }, + "devDependencies": { + "@inertiajs/react": "^2.0.0", + "@javaabu/forms": "github:Javaabu/react-forms#next-gen", + "@javaabu/onu": "^0.2.1", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.6.0", + "vitest": "^3.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@javaabu/forms", + "@javaabu/i18n", + "esbuild" + ] + }, + "packageManager": "pnpm@10.28.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..66880e0 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4256 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@inertiajs/react': + specifier: ^2.0.0 + version: 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/forms': + specifier: github:Javaabu/react-forms#next-gen + version: https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/onu': + specifier: ^0.2.1 + version: 0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4) + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@types/react': + specifier: ^19.0.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.3.0(@types/react@19.3.0) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + react: + specifier: ^19.0.0 + version: 19.3.1 + react-dom: + specifier: ^19.0.0 + version: 19.3.1(react@19.3.1) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(jsdom@26.1.0) + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@dnd-kit/abstract@0.5.3': + resolution: {integrity: sha512-KZQ2vftkO9BHupVzlUqB5d7JETL7ITlvslx3hrj4aObwjliAzuzV/n5wCpQllNd0Hxvg7FaQtLekJ8dplqKwbA==} + + '@dnd-kit/collision@0.5.3': + resolution: {integrity: sha512-DBo8czkI72xnsHRWOMTn/GAyYgSWy9bmde85mrZ7ShqLhcbIbcu4PVJ3ZU9MBikePCSAVOoUTZE2gS40jogrEw==} + + '@dnd-kit/dom@0.5.3': + resolution: {integrity: sha512-JJe+Qu/JmofGzHes3np91sQYwp5spvLcknjRkq+VO59JTqSnQNPXlhHXavRXTwQFabaaRyRzXyH/faAvubCNGw==} + + '@dnd-kit/geometry@0.5.3': + resolution: {integrity: sha512-U+zscNjoHfJ1xUV+XDGF89jPCoILLivWSb/U9mfM7h83f6o3X9AS9uIwG46bXUpCqPvlI/9JcOlyga5uvfyjgw==} + + '@dnd-kit/helpers@0.5.3': + resolution: {integrity: sha512-fErbbjwKMJEdr7e+AMsXaZ6aQY4xPYBMK+nqybB34tE6/lQDLetZHsgLDKNVePsYIZCq/7uWMtlFjqysscWg5A==} + + '@dnd-kit/react@0.5.3': + resolution: {integrity: sha512-BGn6bakgcRSoKMRQP8cRIGu59e5lGC3/sL0zVWmDMo/9CMPSxgjCcQltDn5TDTwAARnEbuwNEksor4rLtH/yEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.5.3': + resolution: {integrity: sha512-NJTQkBglAT/SXLc+IOLiKwNvsTrWmPG5AO+7Gn0F0n8XyVaf4VCbTCuPuU48sDBfcpg23O0tfGkfZNiZbYTvMw==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@inertiajs/core@2.3.29': + resolution: {integrity: sha512-Cx3zibnKdsmEnXpsWhwmE9i4k5NFtK+4gtceL3Kb6RwYC1J0wi5y/EesNMkpdJLsAT2h4ugUvu9Ed9T1EUl0/A==} + + '@inertiajs/react@2.3.29': + resolution: {integrity: sha512-09SWBPxv8UrJdOWtQrEp1ro6jv1qJ8yOxH8eL+na9K/wbh7k9R0ALvAO3ofY52Cwuh7HMuSPgtRzk/q8E1k0Fg==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@internationalized/date@3.12.4': + resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} + + '@internationalized/number@3.6.9': + resolution: {integrity: sha512-GgWy2caO/+hV9sT/oCTWGHvbjmJMPT9kkXzoKRLnkEq7adghhR7UprhvY9sXrKfrp3NcDfhF6QQHXR1KfQbB8g==} + + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21': + resolution: {tarball: https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + '@inertiajs/react': ^2.0.0 || ^3.0.0 + '@javaabu/onu': ^0.2.1 + '@testing-library/react': ^16.0.0 + react: ^18.3.0 || ^19.0.0 + react-dom: ^18.3.0 || ^19.0.0 + peerDependenciesMeta: + '@inertiajs/react': + optional: true + '@javaabu/onu': + optional: true + '@testing-library/react': + optional: true + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba': + resolution: {tarball: https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + vue: ^3.3.0 + peerDependenciesMeta: + react: + optional: true + vue: + optional: true + + '@javaabu/onu@0.2.1': + resolution: {integrity: sha512-GE4takAQwNQqD9ZmQooH7KMBrLUoiR4UzJsDCGr6cLYigoLF22tjRD3CzrdVGVl+SAhz2jjwOozxzyvAyTNmxA==} + peerDependencies: + '@deck.gl/aggregation-layers': ^9.4.0 + '@deck.gl/core': ^9.4.0 + '@deck.gl/google-maps': ^9.4.0 + '@deck.gl/layers': ^9.4.0 + '@fontsource-variable/geist': ^5.3.0 + '@fontsource-variable/inter': ^5.3.0 + '@fontsource-variable/jetbrains-mono': ^5.3.0 + '@fontsource-variable/noto-sans-thaana': ^5.3.0 + '@fontsource-variable/source-serif-4': ^5.3.0 + '@fortawesome/fontawesome-svg-core': ^7.3.1 + '@fortawesome/react-fontawesome': ^3.5.0 + '@fullcalendar/core': ^6.1.21 + '@fullcalendar/daygrid': ^6.1.21 + '@fullcalendar/interaction': ^6.1.21 + '@fullcalendar/list': ^6.1.21 + '@fullcalendar/react': ^6.1.21 + '@fullcalendar/timegrid': ^6.1.21 + '@googlemaps/markerclusterer': ^2.6.2 + '@iconify/react': ^6.0.2 + '@tanstack/react-query': ^5.104.1 + '@tiptap/core': ^3.31.4 + '@tiptap/extension-image': ^3.31.4 + '@tiptap/extension-placeholder': ^3.31.4 + '@tiptap/extension-table': ^3.31.4 + '@tiptap/extension-text-align': ^3.31.4 + '@tiptap/pm': ^3.31.4 + '@tiptap/react': ^3.31.4 + '@tiptap/starter-kit': ^3.31.4 + '@tiptap/static-renderer': ^3.31.4 + '@tiptap/suggestion': ^3.31.4 + '@turf/turf': ^7.4.0 + '@vis.gl/react-google-maps': ^1.10.1 + barcode-detector: ^3.2.2 + media-chrome: ^4.19.3 + react: ^19.0.0 + react-dom: ^19.0.0 + react-easy-crop: ^6.2.4 + react-hook-form: ^7.89.0 + recharts: ^3.10.1 + shiki: ^4.5.0 + signature_pad: ^5.1.4 + tailwindcss: ^4.0.0 + terra-draw: ^1.37.0 + terra-draw-google-maps-adapter: ^1.7.0 + write-excel-file: ^4.1.1 + yet-another-react-lightbox: ^3.32.2 + peerDependenciesMeta: + '@deck.gl/aggregation-layers': + optional: true + '@deck.gl/core': + optional: true + '@deck.gl/google-maps': + optional: true + '@deck.gl/layers': + optional: true + '@fontsource-variable/geist': + optional: true + '@fontsource-variable/inter': + optional: true + '@fontsource-variable/jetbrains-mono': + optional: true + '@fontsource-variable/noto-sans-thaana': + optional: true + '@fontsource-variable/source-serif-4': + optional: true + '@fortawesome/fontawesome-svg-core': + optional: true + '@fortawesome/react-fontawesome': + optional: true + '@fullcalendar/core': + optional: true + '@fullcalendar/daygrid': + optional: true + '@fullcalendar/interaction': + optional: true + '@fullcalendar/list': + optional: true + '@fullcalendar/react': + optional: true + '@fullcalendar/timegrid': + optional: true + '@googlemaps/markerclusterer': + optional: true + '@iconify/react': + optional: true + '@tiptap/core': + optional: true + '@tiptap/extension-image': + optional: true + '@tiptap/extension-placeholder': + optional: true + '@tiptap/extension-table': + optional: true + '@tiptap/extension-text-align': + optional: true + '@tiptap/pm': + optional: true + '@tiptap/react': + optional: true + '@tiptap/starter-kit': + optional: true + '@tiptap/static-renderer': + optional: true + '@tiptap/suggestion': + optional: true + '@turf/turf': + optional: true + '@vis.gl/react-google-maps': + optional: true + barcode-detector: + optional: true + media-chrome: + optional: true + react-easy-crop: + optional: true + recharts: + optional: true + shiki: + optional: true + signature_pad: + optional: true + terra-draw: + optional: true + terra-draw-google-maps-adapter: + optional: true + write-excel-file: + optional: true + yet-another-react-lightbox: + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@preact/signals-core@1.14.5': + resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==} + + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.2.0': + resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==} + + '@radix-ui/react-accessible-icon@1.2.0': + resolution: {integrity: sha512-BB4+HHkFl3HipEAhm/mLhrpXV+2kRX7MvY4AaImUcQVeWQt5KhV1JEByNs8NGfJOcr95RcQGijt9+OrCo68xGA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.3.1': + resolution: {integrity: sha512-lCZ6sbGS/7WO0j84yx4srIgOf0MRJ/sZ13RonADXxvKBym8xrxNCq5f6DibGcr38sIlwPk8/52uYdXeOPsbEDw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.2.1': + resolution: {integrity: sha512-MWC4N7RY+qsbNrFNo7yQ9ZbdoZEW+B5DtpWxJfUnVCqcQxxqqg3qis3RMPTDbCNaTIdHJSn5XDkl9ZdVWXknLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.17': + resolution: {integrity: sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.2.0': + resolution: {integrity: sha512-gHEU+Lx8DlT/FYIjZQvvBJrFSwUngXTjh7FeL9QvJaNALDLez6pgDj2axCUihBjtPBy82LN0gNb25+sTcSXNmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.3.1': + resolution: {integrity: sha512-ssZOkfQNkFpYVmCPQlr978xSaU7moSrGXdwg4j2Xs2WlGl/1KsSZtfpK+WnLnx0Rwll1BAh6oUrAy/uR31SBvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.4.1': + resolution: {integrity: sha512-KWtwhh+98ML3seZ9vO9yRY2OwaQG90dCiDAxjpSNY2AaZ3Hik3B0dsk6m0ILywo/no3A9/SozadyPMNweVT8yw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.2.1': + resolution: {integrity: sha512-fSzLb/qy1wuZSxQBcxpW0hNXYHDMWiIXDGyJGdA4+FSLknOWZsNRrTCUavxb4PI8b8lvHo/CysO6NVi5zLpo5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.2.0': + resolution: {integrity: sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.4.1': + resolution: {integrity: sha512-Pddn3Z8lio3/ChxyXaEP3jHTTo1LTHCWwF8+mG1chQlWijP+ttVHBKwezrQTiV6Mj670ojvumqvuGh+mdv33iQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.3.0': + resolution: {integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.3.1': + resolution: {integrity: sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.2.0': + resolution: {integrity: sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.2.1': + resolution: {integrity: sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.2.1': + resolution: {integrity: sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.2.0': + resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.3.1': + resolution: {integrity: sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.3.0': + resolution: {integrity: sha512-KnmrCM27m7Jo1R8S6cFDPrEeq20Jfn5dqde0SZrJr2lVXlj6/QZSUCyFoYh1d8ti0uUrMNK5Lx6dUDuINT+f4Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.2.1': + resolution: {integrity: sha512-4QooHxgmpCKRjj1Pft+hETKidYcrbPKcVwMGtk2Oa5DcWY2IH1fHuQt9LG6q5hUQBBV2MhQxSvtx6B/C1xfiTw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.17': + resolution: {integrity: sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.2.1': + resolution: {integrity: sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.2.1': + resolution: {integrity: sha512-bRTYtS9JITV2dUYqYZb7+N5SooQBkZrNRejfmyrSKe3Y57aXhKgx//kBUP0kY7pEpsWR/U1AHsAJRzjwCrK3PA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.4.1': + resolution: {integrity: sha512-XPPZjkeyCSMNNvt0FyDv/PUauG7Ow4GwHQ/XZSwW36F/TS7rR4HFTsXH8ewLnXTOLn3x96/pmTPNQIVoTX/86g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.2.1': + resolution: {integrity: sha512-KtHdcgnaAK6rohQ/MMJoGYcbgSRJsIuGBdHrFeTB1/9SaKlDs14oSOniW6BL6M43HqvgCO3u4WuVdFLkuc6sqw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.2.1': + resolution: {integrity: sha512-vu4+ZoJAaFjdI7l1lDSZRs+4dEv83Jmu7lyzajPrAeQzX74y84n0rUNWbWSgN9XB5NE8IY86Z02kGH6oRrzR+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.3.1': + resolution: {integrity: sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.4.0': + resolution: {integrity: sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.2.0': + resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.11': + resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.2.0': + resolution: {integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.2.1': + resolution: {integrity: sha512-yQBiM6J3YM8Ums1orkEFpbznK47geKxa0igikzcaS1xHgdba3DdAQBsGbCTt5BVfR8zhEvgxQ0poGcINaD3lvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.5.1': + resolution: {integrity: sha512-QbyVT1ddnjqB9H+CUYKkNWdU9fIK3QHCYtjf7mS6ekSqiuXkWtWn5UZW9mhA8qtl3vrVyIsmR9gePstAtKrqkw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.2.1': + resolution: {integrity: sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.4.0': + resolution: {integrity: sha512-CFrLqHNejWq5FhKAcj9V/WPGRsol76HBQto81NdOBVFEOqIEkJkKIJt0Z824A/9hHLCtoiR/Kw3euRgzXP2pxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.4.1': + resolution: {integrity: sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.2.0': + resolution: {integrity: sha512-1jUAap0wZEqVP05Ou7kJgRxe/W+Qftng+rUNWGa0qkKbv9r5pWTG3E7a3ELKo2eJoHKMECnzuYrZpSyO1YgcYQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.6.1': + resolution: {integrity: sha512-IRNRR90moPqxfwCJ0XbMJIxo70W0/1ZExT/sNCxEHMaPx0SF9HoQrcPEC75+H28cFKa0b+KH63anfLYJmwKacw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.5.0': + resolution: {integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.4.1': + resolution: {integrity: sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.2.1': + resolution: {integrity: sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.3.1': + resolution: {integrity: sha512-iQvL0J7XK4HtJNGkkWMyYrboNseI7vtotFeUkIno8YNxVA9oM7B6//fh0Mc6TSKI96+zioLWi98lQLso88yJmQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.2.1': + resolution: {integrity: sha512-O8Ldx5ao4p4KoW+DO8/PmkOe5mdKNi3F2SseqjVj88wvDGDHe60cUmO0Zm/uHsKrvNgQGV8Efpuh95Tht/h4vw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.2.1': + resolution: {integrity: sha512-+3MUq+WyiBplXYqQ0EIYlq8R4sLQ0T5NbOK7DMdDLCKO93pe2uE0DUJTWWfTBprpJJXxfoF3eMW0xpakVZrYsg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.2.1': + resolution: {integrity: sha512-Cm8m7JTEeUdbfA6T9xgyu8HfOpyvSx8pB/wj0DlEuWaJe9FilIs4Az9wci/lU9RbthV/UkltszMw5WnnD+m/Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.4.1': + resolution: {integrity: sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.5': + resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.3.0': + resolution: {integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.6': + resolution: {integrity: sha512-9cRullnQRv+kqnRmFlmh0WC3kAPuGmHiQ9VwsXdrYk//sBhgCKEttcpPEPgIQbyx6TGUmooVEHYTyohwz/lSJg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.5': + resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.13': + resolution: {integrity: sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@react-types/shared@3.37.0': + resolution: {integrity: sha512-VNlZuifu4tHdxWPfHvoMfKsHA9kVTCGkduv5vKVTdinm8wHFJNhyVB5RzIMiRFj0q3RKoCbP68u2zaoBSiFueg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@rollup/rollup-android-arm-eabi@4.67.5': + resolution: {integrity: sha512-FdWPeulkH+FH+rBzyKBD59tuVN4tRKvmVilSJm0OcnoYsQTpd9UhD78OaPcsYqo2fX6hCwAG0YIjrDLoQ3pR3g==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.67.5': + resolution: {integrity: sha512-bmkDuBGnF+Ji8atWlXcNjxIC52ShCbdXPbW7Nz0lRoCFi1lrvsO0XobJY36kTj7duc1vOmGJeQ9vtVuGjnzihA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-android-x64@4.67.5': + resolution: {integrity: sha512-6KvVfnqPTqmbQ5YobMl9HYiSymAsCt8HlNeriDshwJQcveXwT+vIMW6wGL1RcJbXH9+r3BAJvjab5+rIgtSEqA==} + cpu: [x64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.67.5': + resolution: {integrity: sha512-SNamgdrY69oPdBrQV9fKGo6PSmKcVhyn0D0jnDmcqgRJvrxQGGBhtekOHTUJ7TCgkW0nE8XMH9v2CwXmjNIzVA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.67.5': + resolution: {integrity: sha512-HNdl34thiJtHDSglogjmAlQ296L8cc8zA7ODH65nnmY7E+jPP55WEbh8J5583rWFLMjxyYywxZuwPOwRvEi5tA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.67.5': + resolution: {integrity: sha512-WvZYtANNswfHzSNHaJAoG6Ipfc4SyZIDYuIjFdHv07xXxhHl+Xx8o+bNDWsxpOADFT2mKXcXVrfOG4OlWTLo8Q==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.67.5': + resolution: {integrity: sha512-EUU+Wgf9NyHekceC+DxAdm42f6bq2ezH/7DEb/JnCb8SAUhG2xph5BiVukxplWal2m5pP8CzJZE+FLwRopywjQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.67.5': + resolution: {integrity: sha512-dgU1CvUuE2IkaJBAeuAbxpDdMHAdZBT5oth5u22DtuoMg4gXXYr2+Sm30RigPJG1F2VEoiGJUziGLuIh9+jPxA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.67.5': + resolution: {integrity: sha512-rdgJcJig2VngYlcTCRQw2BA8kLWM6QwDFe9uLFo81lya0jPC1r8wZu92sAvTUIv48GWlkRVi6/fpsa5CoFSKoQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.67.5': + resolution: {integrity: sha512-+jtCiQnrEFHWAUAcUpP7wppb1p6HKOgBP3mXx75cEYpDF2Y5MEqBky2HNz+osFuVx5uX44gnMTmbe5RJauzn6A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.67.5': + resolution: {integrity: sha512-heflX7ENhL8eHmLcUxDEHCSK8CKLyPprysb2JGoSTh5Ip7nHrzIRc+2USyxul3nxi98H8pGxJIEDEpURlS1X5A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.67.5': + resolution: {integrity: sha512-rRtNU0zBfRHkJ8pscHN9qRtUYLq8Rao4EX7aVWvKcFmJUANkCCXFRU7ovNBEMhpEONyUEB/3DRVDXwDDguSfHA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.67.5': + resolution: {integrity: sha512-/6auyi9yj8MD/Q+8P+f2ay57jQ7a0j7Yp1JK86pecH6ZnRViQZ5GOjoRSjGArFeTSHFznRbM2QPQcBgDPyNMfQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.67.5': + resolution: {integrity: sha512-Hsc0vlt+vPvqzUkHKffWKf3YE/yexrY+yeFf7IsUvDC6PV7YGCsA/gJvPa+WJdgxGJnwuoO1HmLkbahaLKabYQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.67.5': + resolution: {integrity: sha512-Jx5TMMSPJwHY7F/JwPdS6nuBwny2ECRK+EsAJV5HZrfiddeSS6ZrohkLkFZm3rjqBUuQ4FPX5gGMtakUuQO6fQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.67.5': + resolution: {integrity: sha512-fGWcq2lo/XCqAkBLOokg66D/z+MUouryB8i+QdU9RR6O5knG7rucc0WFPqWYL1Wk/wuiJouDfmyBxmZKRM9bag==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.67.5': + resolution: {integrity: sha512-JwO/Ys3jybc0UW3V5XO2CMnp0RhvoczDDQTgAEE1IctT+HRY3W5parIIWLUpmAK4gHSeeYJofm2iQPFA62K6ag==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.67.5': + resolution: {integrity: sha512-vVD4iqel6J6A1xAJlb4mxEO9i1wOJ4/SkcKoDKAuK/wqEBPOA7/P5dDyZvNx46ocHwOUwzo3WjhTdKqET9kEAg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.67.5': + resolution: {integrity: sha512-HmEfNh5Cdd/NHxgqwMzKnjlvicQQA+D+w+vQXDJSV0x/aHiQSjVwh1Th5RoA7QsMieIaxibt/5iJq68BraTSUg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.67.5': + resolution: {integrity: sha512-NfmALpbe009nYzZ2QqlSW6nzbGddXpqk3nwualrIr+4QflQCTGMzt3QKK20yARiAnSV9fJdM5WNwIYUf31x3uA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.67.5': + resolution: {integrity: sha512-ngcE7Udpy/iJ3lexVhYcbpUV2OJIakRGus3snc6K52BnAAJetDBG1Ku5Eh3CECtnW3mpBYWg5VCD4tTaOXKOVQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.67.5': + resolution: {integrity: sha512-DZ01A2EdTYX8xVeGoGiWkJBRWDh+/BOflwGPVsBay43PDxcq8ez0xuovZnPnk1xjE3CdI0pdZXxsJp/Xjmf7uQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.67.5': + resolution: {integrity: sha512-nJKiMKDO140DUCEcQZl+f4HiGPMBxrW2webjuWJ2E3qPeoruHf2zByml4TP52lA1zDi6L4I92D/5stxeA/6pig==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.67.5': + resolution: {integrity: sha512-ykLkBCf+Kls24QmqM3IRf1T4sGMh0rXPyymtj+hXlWKPJnXbXraHqUUsKtOIxqLasjT1i7DhfQGAaZYeiHbSOw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.67.5': + resolution: {integrity: sha512-NiFPUFw8cHhkmBlL1k1eaz+5AXXm89rztj+DQdDuQ7VGPM0+ADFhezQ2KcBMF50UEFzGUr0+DVjVND743XwKWQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.67.5': + resolution: {integrity: sha512-Khj/vrKCpdbiAWYbJAeSy4D5iep+qwW/86lNoKvdleJYGxgbRE7h87+5d5T9D7gt8NcqhNFpreRgIg8T4bLzKQ==} + cpu: [x64] + os: [win32] + + '@sindresorhus/slugify@3.0.1': + resolution: {integrity: sha512-N1oPvN6sytRKZCskv0L+vSyvnUYi9JplPXZ90lT6EBsijjHrUdoXttb0Lna6pw1TWMe8vlajQnLDtU9KYHqtDg==} + engines: {node: '>=20'} + + '@sindresorhus/transliterate@2.3.1': + resolution: {integrity: sha512-gVaaGtKYMYAMmI8buULVH3A2TXVJ98QiwGwI7ddrWGuGidGC2uRt4FHs22+8iROJ0QTzju9CuMjlVsrvpqsdhA==} + engines: {node: '>=20'} + + '@swc/helpers@0.5.24': + resolution: {integrity: sha512-6tcXo4kA8HgGO7AVmawB57RYi/piBPYVHVcO6dXjX+SZw8GoHmi262sp4v2Ha2kNP9Cx84qei27yT+fjS1lxgg==} + + '@tanstack/query-core@5.105.3': + resolution: {integrity: sha512-v1rbNy7MB/4Akth7CQH+CerV13K7Ok9hAusEsfz4sCjDdu3/kOSJPoWfoB+YjdEX1eBFBWKNAGE5nqZCgdlHDw==} + + '@tanstack/react-query@5.105.3': + resolution: {integrity: sha512-dj5hGWZmwoc9NG4enoXuYbiYwb5FxgJYPVv06eUgicMTfEMTRXn08Xv9D/r1V8TfjAv+BpxdvZFzJ1h3hSWZcQ==} + peerDependencies: + '@types/react': ^18 || ^19 + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@9.3.1': + resolution: {integrity: sha512-E6Ya5TnY6EaGyZYIUqig/29Lr/gJSAubQkLJFqiWkUl5nRCr0Xr9hvJot+NiRTtG+TzGxHTiqUW3bnLtQxlHug==} + engines: {node: '>=20'} + peerDependencies: + react: '>=18' + + '@tanstack/react-virtual@3.14.26': + resolution: {integrity: sha512-HUDlfsrYH6ko5xH56L62Mcl/uzT+AGVNMHFuDNqDJMdZWc1AQa2oeVKNUvZ/RWBZQ4TYtyQerFrNvLnp39h65A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + + '@tanstack/table-core@9.3.1': + resolution: {integrity: sha512-zQvlxSNi2qGM3L4jh85Z6hqMfdIR5qOfD9KHOU32nuKFJGN0RlJ7UvV1lsPb1fPVhpFYI0VP3jJGxLdLDpTKjQ==} + engines: {node: '>=20'} + + '@tanstack/virtual-core@3.19.11': + resolution: {integrity: sha512-/5iHb0RHBvj8Di59FDs4ESNm45jls33aZAi4zccVXZjc/6ZvwQP6nzEYeT6weXjTf9E6/vrp2JhYWRyO+ZP13A==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.27': + resolution: {integrity: sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.21.0: + resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + input-otp@1.5.0: + resolution: {integrity: sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + laravel-precognition@1.0.2: + resolution: {integrity: sha512-0H08JDdMWONrL/N314fvsO3FATJwGGlFKGkMF3nNmizVFJaWs17816iM+sX7Rp8d5hUjYCx6WLfsehSKfaTxjg==} + + libphonenumber-js@1.13.15: + resolution: {integrity: sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lucide-react@1.68.0: + resolution: {integrity: sha512-9i7NKWm2QlOprMhcwkhBOlIt9zB7cp2GIyFY+oMue0EsprolUFxva5rkdBbyfs4k1eejpa0JRnRgVOGiart+Xg==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.17.1: + resolution: {integrity: sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==} + engines: {node: '>=0.6'} + + radix-ui@1.8.1: + resolution: {integrity: sha512-HPfpVGizO9xANlsFSauruLhRe/3+iOt3vPX0MXYle/UQi3gorQrSYtBcdiv0BZYewcl2rvL59qrIuLFMGAmUrw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + react-aria-components@1.22.1: + resolution: {integrity: sha512-UTHaYHOaM71bOCjGPF50DcLywlrwAodE63IP64LB4kO+OizatQEL8TesVEOM8+TkBeSgHoflJ45388IlJfa7aw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-aria@3.53.1: + resolution: {integrity: sha512-dikisyOjJd3D2fFrIDWmVWzqiLz5B86GzZv1zxs7xI4dVc0JIz/1Wa52I20gYvVL2WXeFXlygYsle/QcOMhtcg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-day-picker@10.0.2: + resolution: {integrity: sha512-QetiKtZWE9Uaw/pM/SYNv6WPYOTRnSJ2puL0GgwkmUFMIwiY0dtjH1W4B7DOVXVNQM0ZOClGpH/heOfBSzWeiA==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react-hook-form@7.91.0: + resolution: {integrity: sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-phone-number-input@3.4.18: + resolution: {integrity: sha512-EmPhJFSxQB7CTmLAlQfGAQQPF+WmJECU08gjyUt50zd2iESb4ETsm6z+BRfUEjH+TXct4EWvJvub+OztJxb/Cg==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-stately@3.51.0: + resolution: {integrity: sha512-YJcmkAXu2XOQP97hO/qz/cC3qv9kni3oVhwBlxWr358BHePJUpY15XwL3YSRMKxYt4EkslPqAz11JRirMfANGw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + rollup@4.67.5: + resolution: {integrity: sha512-3Ve5VH1hacCVutdpbmu/htg4kz+JCOIWgy4Q3hjHPYGHeHeIElNHJ0LcJ3rCfIsd5G7qOl5k5mwpzGMj9kdHPA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonner@2.0.8: + resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + + tailwindcss@4.3.4: + resolution: {integrity: sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tslib@2.9.1: + resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.7: + resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.22.1: + resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.10': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@date-fns/tz@1.5.0': {} + + '@dnd-kit/abstract@0.5.3': + dependencies: + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/collision@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/dom@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/collision': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/geometry@0.5.3': + dependencies: + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/helpers@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/react@0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/state': 0.5.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + tslib: 2.9.1 + + '@dnd-kit/state@0.5.3': + dependencies: + '@preact/signals-core': 1.14.5 + tslib: 2.9.1 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@floating-ui/utils@0.2.12': {} + + '@inertiajs/core@2.3.29': + dependencies: + '@types/lodash-es': 4.17.12 + axios: 1.21.0 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + qs: 6.17.1 + transitivePeerDependencies: + - debug + - supports-color + + '@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@inertiajs/core': 2.3.29 + '@types/lodash-es': 4.17.12 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - debug + - supports-color + + '@internationalized/date@3.12.4': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/number@3.6.9': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/string@3.2.10': + dependencies: + '@swc/helpers': 0.5.24 + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@javaabu/i18n': https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@inertiajs/react': 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/onu': 0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4) + '@testing-library/react': 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + transitivePeerDependencies: + - vue + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1)': + optionalDependencies: + react: 19.3.1 + + '@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4)': + dependencies: + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/helpers': 0.5.3 + '@dnd-kit/react': 0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@internationalized/date': 3.12.4 + '@sindresorhus/slugify': 3.0.1 + '@tanstack/react-query': 5.105.3(@types/react@19.3.0)(react@19.3.1) + '@tanstack/react-table': 9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/react-virtual': 3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + date-fns: 4.4.0 + embla-carousel-react: 8.6.0(react@19.3.1) + input-otp: 1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + lucide-react: 1.68.0(@types/react@19.3.0)(react@19.3.1) + radix-ui: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-aria-components: 1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-day-picker: 10.0.2(@types/react@19.3.0)(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-hook-form: 7.91.0(@types/react@19.3.0)(react@19.3.1) + react-phone-number-input: 3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + sonner: 2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + tailwind-merge: 3.7.0 + tailwindcss: 4.3.4 + tw-animate-css: 1.4.0 + uqr: 0.1.3 + vaul: 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@preact/signals-core@1.14.5': {} + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.2.0': {} + + '@radix-ui/react-accessible-icon@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-accordion@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-alert-dialog@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-arrow@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-aspect-ratio@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-avatar@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-checkbox@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collapsible@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collection@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-context-menu@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-context@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dialog@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-direction@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dismissable-layer@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-dropdown-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-focus-guards@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-focus-scope@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-form@0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-hover-card@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-id@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-label@2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menubar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-navigation-menu@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-one-time-password-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-password-toggle-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popover@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popper@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-portal@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-presence@1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-primitive@2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-progress@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-radio-group@1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-roving-focus@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-scroll-area@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-select@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-separator@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slider@1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slot@1.5.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-switch@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tabs@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toast@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle-group@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toolbar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tooltip@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-use-callback-ref@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-controllable-state@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-escape-keydown@1.1.6(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-previous@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-rect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-size@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-visually-hidden@1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/rect@1.1.3': {} + + '@react-types/shared@3.37.0(react@19.3.1)': + dependencies: + react: 19.3.1 + + '@rollup/rollup-android-arm-eabi@4.67.5': + optional: true + + '@rollup/rollup-android-arm64@4.67.5': + optional: true + + '@rollup/rollup-android-x64@4.67.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.67.5': + optional: true + + '@rollup/rollup-darwin-x64@4.67.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.67.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.67.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.67.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.67.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.67.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.67.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.67.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.67.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.67.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.67.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.67.5': + optional: true + + '@sindresorhus/slugify@3.0.1': + dependencies: + '@sindresorhus/transliterate': 2.3.1 + escape-string-regexp: 5.0.0 + + '@sindresorhus/transliterate@2.3.1': {} + + '@swc/helpers@0.5.24': {} + + '@tanstack/query-core@5.105.3': {} + + '@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@tanstack/query-core': 5.105.3 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@tanstack/react-store@0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/store': 0.11.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + '@tanstack/react-table@9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/react-store': 0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/table-core': 9.3.1 + react: 19.3.1 + transitivePeerDependencies: + - react-dom + + '@tanstack/react-virtual@3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/virtual-core': 3.19.11 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@tanstack/store@0.11.2': {} + + '@tanstack/table-core@9.3.1': + dependencies: + '@tanstack/store': 0.11.2 + + '@tanstack/virtual-core@3.19.11': {} + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.10 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@testing-library/dom': 10.4.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.10': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.27 + + '@types/lodash@4.17.27': {} + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.7)': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.7 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.9.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + axios@1.21.0: + dependencies: + follow-redirects: 1.16.2 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + classnames@2.5.1: {} + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + country-flag-icons@1.6.20: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + date-fns@4.4.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + embla-carousel-react@8.6.0(react@19.3.1): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.3.1 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@5.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + follow-redirects@1.16.2: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + input-format@0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + input-otp@1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.22.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + laravel-precognition@1.0.2: + dependencies: + axios: 1.21.0 + lodash-es: 4.18.1 + transitivePeerDependencies: + - debug + - supports-color + + libphonenumber-js@1.13.15: {} + + lodash-es@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lucide-react@1.68.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + nwsapi@2.2.28: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + qs@6.17.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + radix-ui@1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-accessible-icon': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-accordion': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-alert-dialog': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-aspect-ratio': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-avatar': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-checkbox': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context-menu': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dropdown-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-form': 0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-hover-card': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menubar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-navigation-menu': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-one-time-password-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-password-toggle-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popover': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-progress': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-radio-group': 1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-scroll-area': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-select': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slider': 1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-switch': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tabs': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toast': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toolbar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tooltip': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.6(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + react-aria-components@1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + client-only: 0.0.1 + react: 19.3.1 + react-aria: 3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-aria@3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-phone-number-input@3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.20 + input-format: 0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + libphonenumber-js: 1.13.15 + prop-types: 15.8.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.3.1) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.3.1) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + react-stately@3.51.0(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + react: 19.3.1 + use-sync-external-store: 1.7.0(react@19.3.1) + + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + get-nonce: 1.0.1 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react@19.3.1: {} + + rollup@4.67.5: + dependencies: + '@types/estree': 1.0.10 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.67.5 + '@rollup/rollup-android-arm64': 4.67.5 + '@rollup/rollup-android-x64': 4.67.5 + '@rollup/rollup-darwin-arm64': 4.67.5 + '@rollup/rollup-darwin-x64': 4.67.5 + '@rollup/rollup-freebsd-arm64': 4.67.5 + '@rollup/rollup-freebsd-x64': 4.67.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.67.5 + '@rollup/rollup-linux-arm-musleabihf': 4.67.5 + '@rollup/rollup-linux-arm64-gnu': 4.67.5 + '@rollup/rollup-linux-arm64-musl': 4.67.5 + '@rollup/rollup-linux-loong64-gnu': 4.67.5 + '@rollup/rollup-linux-loong64-musl': 4.67.5 + '@rollup/rollup-linux-ppc64-gnu': 4.67.5 + '@rollup/rollup-linux-ppc64-musl': 4.67.5 + '@rollup/rollup-linux-riscv64-gnu': 4.67.5 + '@rollup/rollup-linux-riscv64-musl': 4.67.5 + '@rollup/rollup-linux-s390x-gnu': 4.67.5 + '@rollup/rollup-linux-x64-gnu': 4.67.5 + '@rollup/rollup-linux-x64-musl': 4.67.5 + '@rollup/rollup-openbsd-x64': 4.67.5 + '@rollup/rollup-openharmony-arm64': 4.67.5 + '@rollup/rollup-win32-arm64-msvc': 4.67.5 + '@rollup/rollup-win32-ia32-msvc': 4.67.5 + '@rollup/rollup-win32-x64-gnu': 4.67.5 + '@rollup/rollup-win32-x64-msvc': 4.67.5 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.28.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + source-map-js@1.2.2: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + symbol-tree@3.2.4: {} + + tailwind-merge@3.7.0: {} + + tailwindcss@4.3.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tslib@2.9.1: {} + + tw-animate-css@1.4.0: {} + + typescript@5.9.3: {} + + uqr@0.1.3: {} + + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sync-external-store@1.7.0(react@19.3.1): + dependencies: + react: 19.3.1 + + vaul@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.7 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.7: + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.32 + rollup: 4.67.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.7(jsdom@26.1.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.7) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.7 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.22.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/resources/js/Pages/AccountInactive.tsx b/resources/js/Pages/AccountInactive.tsx new file mode 100644 index 0000000..df179d6 --- /dev/null +++ b/resources/js/Pages/AccountInactive.tsx @@ -0,0 +1,25 @@ +import { Alert, Form, LinkButton, Submit } from "@javaabu/forms"; +import { AuthCard } from "../components/AuthCard"; +import { t } from "../lib/strings"; +import type { AccountInactivePageProps } from "../types"; + +/** auth::AccountInactive */ +export default function AccountInactive({ urls, strings, message }: AccountInactivePageProps) { + return ( + + {message} + +
+ {urls["password.request"] ? ( + + {t(strings, "reset_your_password")} + + ) : null} + +
+ {t(strings, "log_out")} +
+
+
+ ); +} diff --git a/resources/js/Pages/ConfirmPassword.tsx b/resources/js/Pages/ConfirmPassword.tsx new file mode 100644 index 0000000..c12086c --- /dev/null +++ b/resources/js/Pages/ConfirmPassword.tsx @@ -0,0 +1,31 @@ +import { Form, Password, Submit } from "@javaabu/forms"; +import { AuthCard } from "../components/AuthCard"; +import { PasskeyButton } from "../components/PasskeyButton"; +import { t } from "../lib/strings"; +import type { ConfirmPasswordPageProps } from "../types"; + +/** auth::ConfirmPassword */ +export default function ConfirmPassword({ urls, strings, can_use_passkeys }: ConfirmPasswordPageProps) { + return ( + +

{t(strings, "confirm_password_help")}

+ +
+ + {t(strings, "confirm")} + + + {can_use_passkeys && urls["passkey.confirm-options"] && urls["passkey.confirm"] ? ( + + ) : null} +
+ ); +} diff --git a/resources/js/Pages/ForgotPassword.tsx b/resources/js/Pages/ForgotPassword.tsx new file mode 100644 index 0000000..4fa0b66 --- /dev/null +++ b/resources/js/Pages/ForgotPassword.tsx @@ -0,0 +1,27 @@ +import { Email, Form, Submit } from "@javaabu/forms"; +import { AuthCard } from "../components/AuthCard"; +import { StatusAlert } from "../components/StatusAlert"; +import { t } from "../lib/strings"; +import type { AuthPageProps } from "../types"; + +/** auth::ForgotPassword */ +export default function ForgotPassword({ auth_type, urls, strings, status }: AuthPageProps) { + return ( + +

{t(strings, "forgot_password_help")}

+ + + +
+ + {t(strings, "email_reset_link")} + + +

+ + {t(strings, "back_to_login")} + +

+
+ ); +} diff --git a/resources/js/Pages/Login.tsx b/resources/js/Pages/Login.tsx new file mode 100644 index 0000000..c40e17f --- /dev/null +++ b/resources/js/Pages/Login.tsx @@ -0,0 +1,65 @@ +import { Checkbox, Email, Form, LinkButton, Password, Submit } from "@javaabu/forms"; +import { useRef } from "react"; +import { AuthCard } from "../components/AuthCard"; +import { PasskeyButton } from "../components/PasskeyButton"; +import { StatusAlert } from "../components/StatusAlert"; +import { t } from "../lib/strings"; +import type { LoginPageProps } from "../types"; + +/** auth::Login */ +export default function Login({ auth_type, urls, strings, status, can_reset_password, can_register, can_use_passkeys }: LoginPageProps) { + const form = useRef(null); + const remember = () => ({ remember: form.current?.querySelector('input[name="remember"]')?.checked ?? false }); + + return ( + + + +
+
+ + + +
+ + {can_reset_password ? ( + + {t(strings, "forgot_password")} + + ) : null} +
+ + {t(strings, "log_in")} + +
+ + {can_use_passkeys && urls["passkey.login-options"] && urls["passkey.login"] ? ( + + ) : null} + + {auth_type.login_methods.map((method) => ( + + {method.label} + + ))} + + {can_register ? ( +

+ {t(strings, "no_account")}{" "} + + {t(strings, "sign_up")} + +

+ ) : null} +
+ ); +} diff --git a/resources/js/Pages/Register.tsx b/resources/js/Pages/Register.tsx new file mode 100644 index 0000000..26e5669 --- /dev/null +++ b/resources/js/Pages/Register.tsx @@ -0,0 +1,27 @@ +import { Email, Form, Password, Submit, Text } from "@javaabu/forms"; +import { AuthCard } from "../components/AuthCard"; +import { t } from "../lib/strings"; +import type { AuthPageProps } from "../types"; + +/** auth::Register */ +export default function Register({ auth_type, urls, strings }: AuthPageProps) { + return ( + +
+ + + + + + {t(strings, "create_account")} + +

+ {t(strings, "have_account")}{" "} + + {t(strings, "log_in")} + +

+ +
+ ); +} diff --git a/resources/js/Pages/ResetPassword.tsx b/resources/js/Pages/ResetPassword.tsx new file mode 100644 index 0000000..18581dd --- /dev/null +++ b/resources/js/Pages/ResetPassword.tsx @@ -0,0 +1,20 @@ +import { Email, Form, Hidden, Password, Submit } from "@javaabu/forms"; +import { AuthCard } from "../components/AuthCard"; +import { t } from "../lib/strings"; +import type { ResetPasswordPageProps } from "../types"; + +/** auth::ResetPassword */ +export default function ResetPassword({ auth_type, urls, strings, token, email }: ResetPasswordPageProps) { + return ( + +
+ + + + + + {t(strings, "reset_password")} + +
+ ); +} diff --git a/resources/js/Pages/Settings/Profile.tsx b/resources/js/Pages/Settings/Profile.tsx new file mode 100644 index 0000000..03f4710 --- /dev/null +++ b/resources/js/Pages/Settings/Profile.tsx @@ -0,0 +1,53 @@ +import { Alert, Card, Email, Form, Password, Submit, Text } from "@javaabu/forms"; +import { AccountLayout } from "../../components/AccountLayout"; +import { StatusAlert } from "../../components/StatusAlert"; +import { confirmSubmit } from "../../lib/confirm"; +import { t } from "../../lib/strings"; +import type { ProfilePageProps } from "../../types"; + +/** auth::Settings/Profile */ +export default function Profile({ auth_type, urls, strings, status, profile, must_verify_email, can_delete_account }: ProfilePageProps) { + return ( + + + + +
+ + + + {must_verify_email ? {t(strings, "email_unverified")} : null} + +
+ {t(strings, "save")} +
+ + + {must_verify_email && urls["verification.send"] ? ( +
+ + {t(strings, "resend_verification")} + +
+ ) : null} +
+ + {can_delete_account && urls["profile.destroy"] ? ( + +
+

{t(strings, "delete_account_help")}

+ +
+ {t(strings, "delete_account")} +
+ +
+ ) : null} +
+ ); +} diff --git a/resources/js/Pages/Settings/Security.tsx b/resources/js/Pages/Settings/Security.tsx new file mode 100644 index 0000000..3da81a3 --- /dev/null +++ b/resources/js/Pages/Settings/Security.tsx @@ -0,0 +1,34 @@ +import { Card, Form, Password, Submit } from "@javaabu/forms"; +import { AccountLayout } from "../../components/AccountLayout"; +import { ApiTokensSettings } from "../../components/ApiTokensSettings"; +import { PasskeysSettings } from "../../components/PasskeysSettings"; +import { SessionsSettings } from "../../components/SessionsSettings"; +import { StatusAlert } from "../../components/StatusAlert"; +import { TwoFactorSettings } from "../../components/TwoFactorSettings"; +import { t } from "../../lib/strings"; +import type { SecurityPageProps } from "../../types"; + +/** auth::Settings/Security */ +export default function Security({ urls, strings, status, two_factor, passkeys, sessions, api_tokens, plain_text_token }: SecurityPageProps) { + return ( + + + + +
+ + + +
+ {t(strings, "save_password")} +
+ +
+ + {two_factor ? : null} + {passkeys ? : null} + {sessions ? : null} + {api_tokens ? : null} +
+ ); +} diff --git a/resources/js/Pages/TwoFactorChallenge.tsx b/resources/js/Pages/TwoFactorChallenge.tsx new file mode 100644 index 0000000..3f1fa03 --- /dev/null +++ b/resources/js/Pages/TwoFactorChallenge.tsx @@ -0,0 +1,33 @@ +import { Button, Form, Submit, Text } from "@javaabu/forms"; +import { useState } from "react"; +import { AuthCard } from "../components/AuthCard"; +import { t } from "../lib/strings"; +import type { AuthPageProps } from "../types"; + +/** auth::TwoFactorChallenge */ +export default function TwoFactorChallenge({ urls, strings }: AuthPageProps) { + const [recovery, setRecovery] = useState(false); + + return ( + +
+ {recovery ? ( +
+ + {t(strings, "continue")} + + ) : ( +
+

{t(strings, "two_factor_help")}

+ + {t(strings, "continue")} + + )} + + +
+
+ ); +} diff --git a/resources/js/Pages/UpdateRequiredPassword.tsx b/resources/js/Pages/UpdateRequiredPassword.tsx new file mode 100644 index 0000000..9111739 --- /dev/null +++ b/resources/js/Pages/UpdateRequiredPassword.tsx @@ -0,0 +1,20 @@ +import { Form, Password, Submit } from "@javaabu/forms"; +import { AuthCard } from "../components/AuthCard"; +import { t } from "../lib/strings"; +import type { AuthPageProps } from "../types"; + +/** auth::UpdateRequiredPassword */ +export default function UpdateRequiredPassword({ urls, strings }: AuthPageProps) { + return ( + +

{t(strings, "password_update_required")}

+ +
+ + + + {t(strings, "save_password")} + +
+ ); +} diff --git a/resources/js/Pages/VerifyEmail.tsx b/resources/js/Pages/VerifyEmail.tsx new file mode 100644 index 0000000..18102ce --- /dev/null +++ b/resources/js/Pages/VerifyEmail.tsx @@ -0,0 +1,26 @@ +import { Form, Submit } from "@javaabu/forms"; +import { AuthCard } from "../components/AuthCard"; +import { StatusAlert } from "../components/StatusAlert"; +import { t } from "../lib/strings"; +import type { VerifyEmailPageProps } from "../types"; + +/** auth::VerifyEmail */ +export default function VerifyEmail({ urls, strings, status, email }: VerifyEmailPageProps) { + return ( + +

{t(strings, "verify_email_help", { email })}

+ + + +
+
+ {t(strings, "resend_verification")} +
+ +
+ {t(strings, "log_out")} +
+
+
+ ); +} diff --git a/resources/js/components/AccountLayout.tsx b/resources/js/components/AccountLayout.tsx new file mode 100644 index 0000000..e80461b --- /dev/null +++ b/resources/js/components/AccountLayout.tsx @@ -0,0 +1,43 @@ +import { Form, LinkButton, Submit } from "@javaabu/forms"; +import type { ReactNode } from "react"; +import { t } from "../lib/strings"; +import type { AuthPageProps } from "../types"; + +export interface AccountLayoutProps extends Pick { + title: string; + page: string; + children: ReactNode; +} + +/** The heading and account navigation of the profile and security pages */ +export function AccountLayout({ title, page, urls, strings, children }: AccountLayoutProps) { + return ( +
+
+

{title}

+ + +
+ + {children} +
+ ); +} diff --git a/resources/js/components/ApiTokensSettings.tsx b/resources/js/components/ApiTokensSettings.tsx new file mode 100644 index 0000000..518adcf --- /dev/null +++ b/resources/js/components/ApiTokensSettings.tsx @@ -0,0 +1,55 @@ +import { Alert, Card, Form, Submit, Text } from "@javaabu/forms"; +import { confirmSubmit } from "../lib/confirm"; +import { t } from "../lib/strings"; +import type { ApiTokenData, AuthStrings, AuthUrls } from "../types"; + +export interface ApiTokensSettingsProps { + tokens: ApiTokenData[]; + plainTextToken?: string | null; + urls: AuthUrls; + strings: AuthStrings; +} + +export function ApiTokensSettings({ tokens, plainTextToken, urls, strings }: ApiTokensSettingsProps) { + return ( + +
+ {plainTextToken ? ( + + {t(strings, "api_token_copy")} + + {plainTextToken} + + + ) : null} + +
+ + {t(strings, "create_token")} + + + {tokens.length ? ( +
    + {tokens.map((token) => ( +
  • +
    + + {token.name} + + + {token.last_used_at ? t(strings, "last_used_time", { time: token.last_used_at }) : t(strings, "never_used")} + +
    +
    + + {t(strings, "delete")} + +
    +
  • + ))} +
+ ) : null} +
+
+ ); +} diff --git a/resources/js/components/AuthCard.tsx b/resources/js/components/AuthCard.tsx new file mode 100644 index 0000000..893799d --- /dev/null +++ b/resources/js/components/AuthCard.tsx @@ -0,0 +1,19 @@ +import { Card } from "@javaabu/forms"; +import type { ReactNode } from "react"; + +export interface AuthCardProps { + title: string; + page: string; + children: ReactNode; +} + +/** The card the guest pages sit in */ +export function AuthCard({ title, page, children }: AuthCardProps) { + return ( +
+ +
{children}
+
+
+ ); +} diff --git a/resources/js/components/PasskeyButton.tsx b/resources/js/components/PasskeyButton.tsx new file mode 100644 index 0000000..9fcc9bc --- /dev/null +++ b/resources/js/components/PasskeyButton.tsx @@ -0,0 +1,74 @@ +import { Button } from "@javaabu/forms"; +import { useState } from "react"; +import { errorMessage, passkeysSupported, registerPasskey, verifyPasskey, type PasskeyResult } from "../lib/webauthn"; + +export interface PasskeyButtonProps { + /** `verify` to log in or confirm, `register` to add a passkey */ + mode: "verify" | "register"; + optionsUrl: string; + url: string; + label: string; + unsupported: string; + failed: string; + /** Extra POST fields, e.g. `{ remember }` or `{ name }` */ + data?: () => Record; + /** Called with the server's JSON answer; defaults to following its `redirect` */ + onSuccess?: (body: unknown) => void; + color?: "primary" | "secondary"; + className?: string; +} + +/** A button that runs a passkey ceremony against the package's endpoints */ +export function PasskeyButton({ mode, optionsUrl, url, label, unsupported, failed, data, onSuccess, color = "secondary", className }: PasskeyButtonProps) { + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function run() { + setError(null); + + if (!passkeysSupported()) { + setError(unsupported); + + return; + } + + setBusy(true); + + try { + const options = { optionsUrl, url, data: data?.() ?? {} }; + const result: PasskeyResult = mode === "register" ? await registerPasskey(options) : await verifyPasskey(options); + + if (!result.ok) { + setError(errorMessage(result, failed)); + + return; + } + + if (onSuccess) { + onSuccess(result.body); + + return; + } + + const redirect = (result.body as { redirect?: string } | null)?.redirect; + window.location.assign(redirect ?? window.location.href); + } catch { + setError(failed); + } finally { + setBusy(false); + } + } + + return ( +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/resources/js/components/PasskeysSettings.tsx b/resources/js/components/PasskeysSettings.tsx new file mode 100644 index 0000000..56a4e84 --- /dev/null +++ b/resources/js/components/PasskeysSettings.tsx @@ -0,0 +1,80 @@ +import { Card, Form, Submit } from "@javaabu/forms"; +import { useState } from "react"; +import { confirmSubmit } from "../lib/confirm"; +import { t } from "../lib/strings"; +import type { AuthStrings, AuthUrls, PasskeyData } from "../types"; +import { PasskeyButton } from "./PasskeyButton"; + +export interface PasskeysSettingsProps { + passkeys: PasskeyData[]; + urls: AuthUrls; + strings: AuthStrings; + /** After a passkey is added; defaults to reloading the page */ + onAdded?: () => void; +} + +/** The user's passkeys, with adding and removing */ +export function PasskeysSettings({ passkeys, urls, strings, onAdded }: PasskeysSettingsProps) { + const [name, setName] = useState(""); + + return ( + +
+

{t(strings, "passkeys_help")}

+ + {passkeys.length ? ( +
    + {passkeys.map((passkey) => ( +
  • +
    + + {passkey.name} + + + {passkey.authenticator ?? t(strings, "passkey")} · {t(strings, "added_time", { time: passkey.created_at ?? "" })} + {passkey.last_used_at ? ` · ${t(strings, "last_used_time", { time: passkey.last_used_at })}` : null} + +
    +
    + + {t(strings, "remove")} + +
    +
  • + ))} +
+ ) : ( +

{t(strings, "no_passkeys")}

+ )} + + {urls["passkey.registration-options"] && urls["passkey.store"] ? ( +
+ + ({ name: name.trim() || t(strings, "passkey") })} + onSuccess={onAdded ?? (() => window.location.reload())} + /> +
+ ) : null} +
+
+ ); +} diff --git a/resources/js/components/SessionsSettings.tsx b/resources/js/components/SessionsSettings.tsx new file mode 100644 index 0000000..cb5a660 --- /dev/null +++ b/resources/js/components/SessionsSettings.tsx @@ -0,0 +1,29 @@ +import { Card, Form, Submit } from "@javaabu/forms"; +import { t } from "../lib/strings"; +import type { AuthStrings, AuthUrls, SessionData } from "../types"; + +export function SessionsSettings({ sessions, urls, strings }: { sessions: SessionData[]; urls: AuthUrls; strings: AuthStrings }) { + return ( + +
+

{t(strings, "browser_sessions_help")}

+
    + {sessions.map((session) => ( +
  • + + {session.platform ?? t(strings, "unknown")} · {session.browser ?? t(strings, "unknown")} + + + {session.ip_address} ·{" "} + {session.is_current_device ? t(strings, "this_device") : t(strings, "last_active_time", { time: session.last_active })} + +
  • + ))} +
+
+ {t(strings, "log_out_other_sessions")} +
+
+
+ ); +} diff --git a/resources/js/components/StatusAlert.tsx b/resources/js/components/StatusAlert.tsx new file mode 100644 index 0000000..afad68d --- /dev/null +++ b/resources/js/components/StatusAlert.tsx @@ -0,0 +1,13 @@ +import { Alert } from "@javaabu/forms"; + +export function StatusAlert({ status }: { status?: string | null }) { + if (!status) { + return null; + } + + return ( + + {status} + + ); +} diff --git a/resources/js/components/TwoFactorSettings.tsx b/resources/js/components/TwoFactorSettings.tsx new file mode 100644 index 0000000..a7ce508 --- /dev/null +++ b/resources/js/components/TwoFactorSettings.tsx @@ -0,0 +1,75 @@ +import { Alert, Card, Form, Submit, Text } from "@javaabu/forms"; +import { t } from "../lib/strings"; +import type { AuthStrings, AuthUrls, TwoFactorState } from "../types"; + +export interface TwoFactorSettingsProps { + state: TwoFactorState; + urls: AuthUrls; + strings: AuthStrings; +} + +/** Turning two-factor authentication on (QR code, confirm), recovery codes, and off */ +export function TwoFactorSettings({ state, urls, strings }: TwoFactorSettingsProps) { + return ( + +
+ {state.enabled ? {t(strings, "two_factor_on")} : null} + + {state.pending ? ( + <> +

{t(strings, "two_factor_scan")}

+
+ {/* The server renders the QR code as SVG */} +
+

+ {t(strings, "setup_key")}:{" "} + + {state.secret_key} + +

+
+
+ +
+ {t(strings, "confirm")} +
+ + + ) : null} + + {!state.enabled && !state.pending ? ( + <> +

{state.required ? t(strings, "two_factor_required") : t(strings, "two_factor_offer")}

+
+ {t(strings, "two_factor_turn_on")} +
+ + ) : null} + + {state.recovery_codes ? ( +
+

{t(strings, "recovery_codes_help")}

+
    + {state.recovery_codes.map((code) => ( +
  • {code}
  • + ))} +
+
+ ) : null} + + {state.enabled ? ( +
+
+ {t(strings, "regenerate_recovery_codes")} +
+ {!state.required ? ( +
+ {t(strings, "two_factor_turn_off")} +
+ ) : null} +
+ ) : null} +
+ + ); +} diff --git a/resources/js/index.ts b/resources/js/index.ts new file mode 100644 index 0000000..ec0121c --- /dev/null +++ b/resources/js/index.ts @@ -0,0 +1,12 @@ +// What apps import as @javaabu/auth (through javaabu/manifest) +export { AccountLayout, type AccountLayoutProps } from "./components/AccountLayout"; +export { ApiTokensSettings, type ApiTokensSettingsProps } from "./components/ApiTokensSettings"; +export { AuthCard, type AuthCardProps } from "./components/AuthCard"; +export { PasskeyButton, type PasskeyButtonProps } from "./components/PasskeyButton"; +export { PasskeysSettings, type PasskeysSettingsProps } from "./components/PasskeysSettings"; +export { SessionsSettings } from "./components/SessionsSettings"; +export { StatusAlert } from "./components/StatusAlert"; +export { TwoFactorSettings, type TwoFactorSettingsProps } from "./components/TwoFactorSettings"; +export { t } from "./lib/strings"; +export * from "./lib/webauthn"; +export type * from "./types"; diff --git a/resources/js/lib/confirm.ts b/resources/js/lib/confirm.ts new file mode 100644 index 0000000..6eaa45d --- /dev/null +++ b/resources/js/lib/confirm.ts @@ -0,0 +1,11 @@ +import type { FormEvent } from "react"; + +/** A capture handler that stops a form submitting unless the user confirms */ +export function confirmSubmit(message: string) { + return (event: FormEvent) => { + if (!window.confirm(message)) { + event.preventDefault(); + event.stopPropagation(); + } + }; +} diff --git a/resources/js/lib/strings.ts b/resources/js/lib/strings.ts new file mode 100644 index 0000000..3ac3e04 --- /dev/null +++ b/resources/js/lib/strings.ts @@ -0,0 +1,12 @@ +import type { AuthStrings } from "../types"; + +/** A translated string, with :placeholders replaced */ +export function t(strings: AuthStrings, key: string, replace: Record = {}): string { + let text = strings[key] ?? key; + + for (const [name, value] of Object.entries(replace)) { + text = text.replaceAll(`:${name}`, value); + } + + return text; +} diff --git a/resources/js/types.ts b/resources/js/types.ts new file mode 100644 index 0000000..47d5997 --- /dev/null +++ b/resources/js/types.ts @@ -0,0 +1,94 @@ +/** Props every auth page gets from the server (Javaabu\Auth\Support\PageData) */ +export interface AuthType { + name: string; + features: Record; + two_factor: "off" | "optional" | "required"; + username: string; + login_methods: { key: string; label: string; url: string }[]; +} + +/** The URLs of the user type's routes, by route name without the type prefix: `login.store`, `profile.edit`, … */ +export type AuthUrls = Record & { home: string }; + +/** The translated strings, keyed as in Javaabu\Auth\Support\Strings */ +export type AuthStrings = Record; + +export interface AuthPageProps { + auth_type: AuthType; + urls: AuthUrls; + strings: AuthStrings; + status?: string | null; +} + +export interface LoginPageProps extends AuthPageProps { + can_reset_password: boolean; + can_register: boolean; + can_use_passkeys: boolean; +} + +export interface ResetPasswordPageProps extends AuthPageProps { + token: string; + email: string; +} + +export interface VerifyEmailPageProps extends AuthPageProps { + email: string; +} + +export interface ConfirmPasswordPageProps extends AuthPageProps { + can_use_passkeys: boolean; +} + +export interface AccountInactivePageProps extends AuthPageProps { + message: string; +} + +export interface ProfilePageProps extends AuthPageProps { + profile: Record; + must_verify_email: boolean; + can_delete_account: boolean; +} + +export interface TwoFactorState { + enabled: boolean; + pending: boolean; + required: boolean; + qr_code: string | null; + secret_key: string | null; + recovery_codes: string[] | null; +} + +export interface PasskeyData { + id: number; + name: string; + authenticator: string | null; + created_at: string | null; + last_used_at: string | null; + destroy_url: string; +} + +export interface SessionData { + id: string; + ip_address: string | null; + platform: string | null; + browser: string | null; + is_desktop: boolean; + is_current_device: boolean; + last_active: string; +} + +export interface ApiTokenData { + id: string; + name: string; + last_used_at: string | null; + created_at: string | null; + destroy_url: string; +} + +export interface SecurityPageProps extends AuthPageProps { + two_factor: TwoFactorState | null; + passkeys: PasskeyData[] | null; + sessions: SessionData[] | null; + api_tokens: ApiTokenData[] | null; + plain_text_token?: string | null; +} diff --git a/resources/views/settings/partials/api-tokens.blade.php b/resources/views/settings/partials/api-tokens.blade.php index 3ce150a..636c7e2 100644 --- a/resources/views/settings/partials/api-tokens.blade.php +++ b/resources/views/settings/partials/api-tokens.blade.php @@ -23,7 +23,7 @@
- {{ __('Delete') }} diff --git a/resources/views/settings/partials/passkeys.blade.php b/resources/views/settings/partials/passkeys.blade.php index 4c3cea4..ba54ad8 100644 --- a/resources/views/settings/partials/passkeys.blade.php +++ b/resources/views/settings/partials/passkeys.blade.php @@ -23,7 +23,7 @@ - {{ __('Remove') }} diff --git a/src/Http/Controllers/SecurityController.php b/src/Http/Controllers/SecurityController.php index 4b3e384..429f882 100644 --- a/src/Http/Controllers/SecurityController.php +++ b/src/Http/Controllers/SecurityController.php @@ -25,6 +25,7 @@ public function edit(Request $request): View|Responsable 'passkeys' => $this->passkeys($type, $user), 'sessions' => $this->sessions($type, $user), 'api_tokens' => $this->apiTokens($type, $user), + 'plain_text_token' => session('plain_text_token'), ], 'account'); } @@ -60,7 +61,10 @@ protected function passkeys(UserType $type, mixed $user): ?array return $user->passkeys() ->latest() ->get() - ->map(fn ($passkey) => method_exists($passkey, 'toDisplayArray') ? $passkey->toDisplayArray() : ['id' => $passkey->id, 'name' => $passkey->name]) + ->map(fn ($passkey) => [ + ...(method_exists($passkey, 'toDisplayArray') ? $passkey->toDisplayArray() : ['id' => $passkey->id, 'name' => $passkey->name]), + 'destroy_url' => $type->route('passkey.destroy', ['passkey_id' => $passkey->id]), + ]) ->all(); } @@ -85,6 +89,9 @@ protected function apiTokens(UserType $type, mixed $user): ?array return null; } - return ApiTokens::list($user); + return array_map(fn (array $token) => [ + ...$token, + 'destroy_url' => $type->route('api-tokens.destroy', ['token_id' => $token['id']]), + ], ApiTokens::list($user)); } } diff --git a/src/Support/PageData.php b/src/Support/PageData.php index 1bfeac8..12ee2c4 100644 --- a/src/Support/PageData.php +++ b/src/Support/PageData.php @@ -81,7 +81,8 @@ public function strings(): array $strings = []; foreach (Strings::ALL as $key => $english) { - $strings[$key] = __($english); + // Placeholders stay for the page to fill + $strings[$key] = __($english, []); } return $strings; diff --git a/src/Support/Strings.php b/src/Support/Strings.php index 0dc47d9..59c2a70 100644 --- a/src/Support/Strings.php +++ b/src/Support/Strings.php @@ -3,9 +3,98 @@ namespace Javaabu\Auth\Support; /** - * The English strings of the React pages, sent translated as the `strings` prop + * The English strings of the React pages, sent translated as the `strings` prop. + * They are the same strings the Blade pages use, so one lang/dv.json covers both. */ class Strings { - public const array ALL = []; + public const array ALL = [ + 'account' => 'Account', + 'account_inactive_title' => 'Your account is not active', + 'add_passkey' => 'Add a passkey', + 'added_time' => 'Added :time', + 'api_token_copy' => 'Copy your new API token now. It will not be shown again.', + 'api_tokens' => 'API tokens', + 'back_to_login' => 'Back to log in', + 'browser_sessions' => 'Browser sessions', + 'browser_sessions_help' => 'The browsers and devices where you are logged in.', + 'choose_new_password' => 'Choose a new password', + 'code' => 'Code', + 'confirm' => 'Confirm', + 'confirm_password' => 'Confirm password', + 'confirm_password_help' => 'This is a secure area. Please confirm your password before continuing.', + 'confirm_password_title' => 'Confirm your password', + 'confirm_with_passkey' => 'Confirm with a passkey', + 'continue' => 'Continue', + 'create_account' => 'Create account', + 'create_token' => 'Create token', + 'current_password' => 'Current password', + 'delete' => 'Delete', + 'delete_account' => 'Delete account', + 'delete_account_confirm' => 'Are you sure you want to delete your account? This cannot be undone.', + 'delete_account_help' => 'Once your account is deleted, all of its data is permanently deleted. Enter your password to confirm.', + 'delete_token_confirm' => 'Delete this token?', + 'email' => 'Email address', + 'email_reset_link' => 'Email password reset link', + 'email_unverified' => 'Your email address is not verified.', + 'forgot_password' => 'Forgot your password?', + 'forgot_password_help' => 'Enter your email address and we will email you a link to choose a new password.', + 'have_account' => 'Already have an account?', + 'last_active_time' => 'Last active :time', + 'last_used_time' => 'Last used :time', + 'log_in' => 'Log in', + 'log_in_title' => 'Log in to your account', + 'log_in_with_passkey' => 'Log in with a passkey', + 'log_out' => 'Log out', + 'log_out_other_sessions' => 'Log out other browser sessions', + 'name' => 'Name', + 'never_used' => 'Never used', + 'new_password' => 'New password', + 'no_account' => "Don't have an account?", + 'no_passkeys' => 'You have no passkeys yet.', + 'passkey' => 'Passkey', + 'passkey_add_failed' => 'We could not add the passkey. Please try again.', + 'passkey_confirm_failed' => 'We could not confirm with a passkey. Please try again.', + 'passkey_login_failed' => 'We could not log you in with a passkey. Please try again.', + 'passkey_name' => 'Passkey name', + 'passkey_name_placeholder' => 'e.g. Work laptop', + 'passkeys' => 'Passkeys', + 'passkeys_help' => 'Log in with your fingerprint, face or screen lock instead of a password.', + 'passkeys_unsupported' => 'This browser does not support passkeys.', + 'password' => 'Password', + 'password_update_required' => 'You need to choose a new password before you continue.', + 'profile' => 'Profile', + 'profile_information' => 'Profile information', + 'recovery_code' => 'Recovery code', + 'recovery_codes_help' => 'Store these recovery codes somewhere safe. Each can be used once if you lose your authenticator app.', + 'regenerate_recovery_codes' => 'Regenerate recovery codes', + 'register_title' => 'Create an account', + 'remember_me' => 'Remember me', + 'remove' => 'Remove', + 'remove_passkey_confirm' => 'Remove this passkey?', + 'resend_verification' => 'Resend verification email', + 'reset_password' => 'Reset password', + 'reset_your_password' => 'Reset your password', + 'save' => 'Save', + 'save_password' => 'Save password', + 'security' => 'Security', + 'setup_key' => 'Setup key', + 'sign_up' => 'Sign up', + 'this_device' => 'This device', + 'token_name' => 'Token name', + 'two_factor_help' => 'Enter the code from your authenticator app.', + 'two_factor_offer' => 'Add a second step to your login with a code from an authenticator app.', + 'two_factor_on' => 'Two-factor authentication is on.', + 'two_factor_required' => 'Your account needs two-factor authentication. Set it up to continue.', + 'two_factor_scan' => 'Scan the QR code with your authenticator app, then enter the code it shows.', + 'two_factor_title' => 'Two-factor authentication', + 'two_factor_turn_off' => 'Turn off two-factor authentication', + 'two_factor_turn_on' => 'Turn on two-factor authentication', + 'unknown' => 'Unknown', + 'update_password' => 'Update password', + 'use_authenticator_code' => 'Use a code from your app instead', + 'use_recovery_code' => 'Use a recovery code instead', + 'verify_email_help' => 'We have emailed a verification link to :email. Click the link to verify your email address.', + 'verify_email_title' => 'Verify your email address', + ]; } diff --git a/tests/Feature/ActivityLogTest.php b/tests/Feature/ActivityLogTest.php new file mode 100644 index 0000000..18aae56 --- /dev/null +++ b/tests/Feature/ActivityLogTest.php @@ -0,0 +1,51 @@ +assertFalse(ActivityLogger::isAvailable()); + + return; + } + + $user = User::factory()->create(['email' => 'ahmed@example.com']); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'wrong']); + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']); + + $this->assertDatabaseHas('activity_log', ['description' => 'failed_login', 'causer_id' => $user->id, 'causer_type' => $user->getMorphClass()]); + $this->assertDatabaseHas('activity_log', ['description' => 'login', 'causer_id' => $user->id]); + } + + #[Test] + public function it_logs_nothing_when_turned_off(): void + { + config()->set('javaabu-auth.log_activity', false); + + User::factory()->create(['email' => 'ahmed@example.com']); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']); + + $this->assertEquals(0, DB::table('activity_log')->count()); + } + + #[Test] + public function logins_work_without_activitylog(): void + { + User::factory()->create(['email' => 'ahmed@example.com']); + + $this->post('/login', ['email' => 'ahmed@example.com', 'password' => 'password']) + ->assertRedirect('/dashboard'); + } +} diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 5831cd2..0447172 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -6,8 +6,10 @@ use Javaabu\Auth\Tests\TestCase; use Laravel\Passport\PassportServiceProvider; use Laravel\Sanctum\SanctumServiceProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; +#[Group('optional')] class InstallCommandTest extends TestCase { protected function getPackageProviders($app): array diff --git a/tests/Feature/PassportApiTest.php b/tests/Feature/PassportApiTest.php index cd95af9..688fd69 100644 --- a/tests/Feature/PassportApiTest.php +++ b/tests/Feature/PassportApiTest.php @@ -13,8 +13,10 @@ use Laravel\Passport\ClientRepository; use Laravel\Passport\Passport; use Laravel\Passport\PassportServiceProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; +#[Group('optional')] class PassportApiTest extends TestCase { protected static ?string $key_path = null; @@ -154,7 +156,8 @@ public function users_create_and_revoke_passport_tokens_on_the_security_page(): public function the_driver_is_found_from_the_model(): void { $this->assertEquals('passport', ApiTokens::driverFor(User::class)); - $this->assertEquals('sanctum', ApiTokens::driverFor(\Javaabu\Auth\Tests\Models\User::class)); + $this->assertNull(ApiTokens::driverFor(\Javaabu\Auth\Tests\Models\User::class)); + $this->assertEquals('sanctum', ApiTokens::driverFor(\Javaabu\Auth\Tests\Models\Sanctum\User::class)); $this->assertEquals(['passport'], ApiTokens::enabledDrivers()); } } diff --git a/tests/Feature/SanctumApiTest.php b/tests/Feature/SanctumApiTest.php index ab4b040..2461d07 100644 --- a/tests/Feature/SanctumApiTest.php +++ b/tests/Feature/SanctumApiTest.php @@ -4,14 +4,16 @@ use Illuminate\Support\Facades\Route; use Javaabu\Auth\Http\Middleware\RequirePassword; -use Javaabu\Auth\Tests\Models\Admin; -use Javaabu\Auth\Tests\Models\User; +use Javaabu\Auth\Tests\Models\Sanctum\Admin; +use Javaabu\Auth\Tests\Models\Sanctum\User; use Javaabu\Auth\Tests\TestCase; use Javaabu\Auth\TwoFactor\TwoFactorProvider; use Javaabu\Auth\UserTypes; use Laravel\Sanctum\SanctumServiceProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; +#[Group('optional')] class SanctumApiTest extends TestCase { protected function getPackageProviders($app): array @@ -23,6 +25,8 @@ protected function defineEnvironment($app): void { parent::defineEnvironment($app); + $app['config']->set('auth.providers.users.model', User::class); + $app['config']->set('auth.providers.admins.model', Admin::class); $app['config']->set('auth.guards.sanctum', ['driver' => 'sanctum', 'provider' => null]); $app['config']->set('javaabu-auth.api', 'sanctum'); $app['config']->set('javaabu-auth.user_types.user.features.api_tokens', true); @@ -136,6 +140,25 @@ public function users_manage_their_tokens_on_the_security_page(): void $this->assertCount(0, $user->tokens()->get()); } + #[Test] + public function admins_log_users_out_of_their_api_tokens_too(): void + { + $this->app['config']->set('permissions.guards', ['web_admin']); + Route::userAccountActions('user', ['prefix' => 'admin/users', 'middleware' => ['web', 'auth.type:admin']]); + $this->artisan('permissions:sync'); + + $user = User::factory()->create(); + $user->createToken('phone'); + $admin = Admin::factory()->create(); + $admin->givePermissionTo('log_out_users'); + + $this->actingAs($admin, 'web_admin') + ->postJson("/admin/users/{$user->id}/log-out") + ->assertOk(); + + $this->assertCount(0, $user->tokens()->get()); + } + #[Test] public function users_cannot_delete_someone_elses_token(): void { diff --git a/tests/Feature/UserAccountsTest.php b/tests/Feature/UserAccountsTest.php index 4155283..6062c4e 100644 --- a/tests/Feature/UserAccountsTest.php +++ b/tests/Feature/UserAccountsTest.php @@ -93,14 +93,12 @@ public function it_makes_a_user_choose_a_new_password(): void public function it_logs_a_user_out_everywhere(): void { $user = User::factory()->create(['remember_token' => 'old']); - $user->createToken('phone'); $this->actingAs($this->admin(['log_out_users']), 'web_admin') ->postJson("/admin/users/{$user->id}/log-out") ->assertOk(); $this->assertNotEquals('old', $user->fresh()->remember_token); - $this->assertCount(0, $user->tokens()->get()); } #[Test] diff --git a/tests/Http/BladePagesTest.php b/tests/Http/BladePagesTest.php new file mode 100644 index 0000000..1911fce --- /dev/null +++ b/tests/Http/BladePagesTest.php @@ -0,0 +1,75 @@ +app->setLocale('dv'); + + $this->get('/login') + ->assertOk() + ->assertSee('ތިބާގެ އެކައުންޓަށް ލޮގިން ކުރޭ') + ->assertSee('dir="ltr"', false); + + $this->get('/register')->assertOk()->assertSee('އެކައުންޓެއް ހަދާ'); + $this->get('/forgot-password')->assertOk()->assertSee('ޕާސްވޯޑް ހަނދާން ނެތީތޯ؟'); + } + + #[Test] + public function the_validation_messages_are_in_dhivehi(): void + { + $this->app->setLocale('dv'); + + User::factory()->create(['email' => 'ahmed@example.com']); + + $this->from('/login') + ->post('/login', ['email' => 'ahmed@example.com', 'password' => 'wrong']) + ->assertSessionHasErrors(['email' => 'މި މަޢުލޫމާތާ ގުޅޭ އެކައުންޓެއް ނުފެނުނު.']); + } + + #[Test] + public function the_package_layouts_render(): void + { + config()->set('javaabu-auth.layouts', ['auth' => 'auth::layouts.auth', 'account' => 'auth::layouts.account']); + + $this->app->setLocale('dv'); + + $this->get('/login') + ->assertOk() + ->assertSee('', false); + + $session = [RequirePassword::sessionKey(UserTypes::get('user')) => time()]; + + $this->actingAs(User::factory()->create(), 'web')->withSession($session) + ->get('/settings/security') + ->assertOk() + ->assertSee('data-account-nav', false) + ->assertSee(route('logout'), false); + } + + #[Test] + public function the_pages_load_their_scripts_through_manifest(): void + { + $session = [RequirePassword::sessionKey(UserTypes::get('user')) => time()]; + + $this->get('/login')->assertOk(); + + Manifest::assertAssetRequired('auth::passkeys'); + + $this->actingAs(User::factory()->create(), 'web')->withSession($session) + ->get('/settings/profile') + ->assertOk(); + + Manifest::assertAssetRequired('auth::auth'); + } +} diff --git a/tests/Http/InertiaPagesTest.php b/tests/Http/InertiaPagesTest.php new file mode 100644 index 0000000..f29fbd6 --- /dev/null +++ b/tests/Http/InertiaPagesTest.php @@ -0,0 +1,139 @@ +set('javaabu-auth.views', 'inertia'); + $app['config']->set('inertia.testing.ensure_pages_exist', false); + $app['config']->set('inertia.root_view', 'app'); + } + + /** @return array */ + public static function guestPages(): array + { + return [ + 'login' => ['/login', 'auth::Login'], + 'register' => ['/register', 'auth::Register'], + 'forgot password' => ['/forgot-password', 'auth::ForgotPassword'], + 'reset password' => ['/reset-password/token?email=a@example.com', 'auth::ResetPassword'], + 'admin login' => ['/admin/login', 'auth::Login'], + ]; + } + + #[Test] + #[DataProvider('guestPages')] + public function it_renders_the_guest_pages_as_inertia_pages(string $url, string $component): void + { + $this->get($url) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component($component, false) + ->has('auth_type.name') + ->where('urls', fn ($urls) => isset($urls['login.store'], $urls['home'])) + ->has('strings.log_in')); + } + + #[Test] + public function every_page_file_exists(): void + { + $pages = ['Login', 'Register', 'ForgotPassword', 'ResetPassword', 'VerifyEmail', 'ConfirmPassword', 'TwoFactorChallenge', 'AccountInactive', 'UpdateRequiredPassword', 'Settings/Profile', 'Settings/Security']; + + foreach ($pages as $page) { + $this->assertFileExists(__DIR__ . "/../../resources/js/Pages/{$page}.tsx"); + } + } + + #[Test] + public function the_login_page_props_match_the_type(): void + { + $this->get('/admin/login') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('auth_type.name', 'admin') + ->where('urls', fn ($urls) => $urls['login.store'] === url('/admin/login')) + ->where('can_register', false) + ->where('can_use_passkeys', true) + ->etc()); + } + + #[Test] + public function the_strings_are_translated(): void + { + $this->app->setLocale('dv'); + + $this->get('/login') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('strings.log_in', __('Log in')) + ->has('strings', count(Strings::ALL)) + ->etc()); + + $this->assertNotEquals('Log in', __('Log in')); + } + + #[Test] + public function it_renders_the_account_pages(): void + { + $user = User::factory()->withTwoFactor()->create(['name' => 'ޒިޔާ']); + $session = [RequirePassword::sessionKey(UserTypes::get('user')) => time()]; + + $this->actingAs($user, 'web')->withSession($session) + ->get('/settings/profile') + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('auth::Settings/Profile', false) + ->where('profile.name', 'ޒިޔާ') + ->where('can_delete_account', true) + ->etc()); + + $this->get('/settings/security') + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('auth::Settings/Security', false) + ->where('two_factor.enabled', true) + ->where('passkeys', []) + ->where('sessions', null) + ->where('api_tokens', null) + ->etc()); + } + + #[Test] + public function a_type_can_use_blade_while_the_default_is_inertia(): void + { + UserTypes::get('admin')->views('blade'); + + $this->get('/admin/login')->assertOk()->assertSee('data-auth-page="login"', false); + $this->get('/login')->assertInertia(fn (AssertableInertia $page) => $page->component('auth::Login', false)); + } + + #[Test] + public function signed_in_pages_render_for_admins(): void + { + $this->actingAs(Admin::factory()->unverified()->create(), 'web_admin') + ->get('/admin/verify-email') + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('auth::VerifyEmail', false) + ->has('email') + ->where('urls', fn ($urls) => $urls['verification.send'] === url('/admin/email/verification-notification')) + ->etc()); + } +} diff --git a/tests/Models/Admin.php b/tests/Models/Admin.php index 2b3c49b..c748d34 100644 --- a/tests/Models/Admin.php +++ b/tests/Models/Admin.php @@ -6,11 +6,9 @@ use Javaabu\Auth\Models\User as BaseUser; use Javaabu\Auth\Tests\Factories\AdminFactory; use Javaabu\Permissions\Traits\HasRoles; -use Laravel\Sanctum\HasApiTokens; class Admin extends BaseUser { - use HasApiTokens; use HasFactory; use HasRoles; diff --git a/tests/Models/Sanctum/Admin.php b/tests/Models/Sanctum/Admin.php new file mode 100644 index 0000000..5be9d5e --- /dev/null +++ b/tests/Models/Sanctum/Admin.php @@ -0,0 +1,20 @@ + */ + protected function sourceStrings(): array + { + $files = [ + ...glob(__DIR__ . '/../../src/{,*/,*/*/,*/*/*/}*.php', GLOB_BRACE), + ...glob(__DIR__ . '/../../resources/views/{,*/,*/*/}*.blade.php', GLOB_BRACE), + ]; + + $strings = array_values(Strings::ALL); + + foreach ($files as $file) { + preg_match_all("/__\(\s*'((?:\\\\'|[^'])+)'/", file_get_contents($file), $single); + preg_match_all('/__\(\s*"((?:\\\\"|[^"])+)"/', file_get_contents($file), $double); + + foreach ([...$single[1], ...$double[1]] as $string) { + $strings[] = str_replace("\\'", "'", $string); + } + } + + return array_values(array_unique(array_filter($strings, fn ($string) => ! str_starts_with($string, '$')))); + } + + #[Test] + public function every_string_has_a_dhivehi_translation(): void + { + $dv = json_decode(file_get_contents(__DIR__ . '/../../lang/dv.json'), true); + + $missing = array_values(array_filter($this->sourceStrings(), fn ($string) => ! isset($dv[$string]))); + + $this->assertEquals([], $missing, 'Add these to lang/dv.json'); + } + + #[Test] + public function the_dhivehi_translations_are_in_thaana_and_keep_placeholders(): void + { + $dv = json_decode(file_get_contents(__DIR__ . '/../../lang/dv.json'), true); + + foreach ($dv as $english => $dhivehi) { + $this->assertMatchesRegularExpression('/\p{Thaana}/u', $dhivehi, "Not in Thaana: {$english}"); + + preg_match_all('/:[a-z_]+/', $english, $placeholders); + + foreach ($placeholders[0] as $placeholder) { + $this->assertStringContainsString($placeholder, $dhivehi, "Lost {$placeholder}: {$english}"); + } + } + } + + #[Test] + public function the_english_file_has_the_same_keys(): void + { + $dv = json_decode(file_get_contents(__DIR__ . '/../../lang/dv.json'), true); + $en = json_decode(file_get_contents(__DIR__ . '/../../lang/en.json'), true); + + $this->assertEquals(array_keys($dv), array_keys($en)); + } + + #[Test] + public function permission_descriptions_are_translated_for_the_default_types(): void + { + $dv = json_decode(file_get_contents(__DIR__ . '/../../lang/dv.json'), true); + + foreach (['users', 'admins'] as $resource) { + foreach (UserAccountPolicy::DESCRIPTIONS as $description) { + $this->assertArrayHasKey(strtr($description, [':resource' => $resource]), $dv); + } + } + } + + #[Test] + public function views_and_pages_use_logical_direction_utilities_only(): void + { + $files = [ + ...glob(__DIR__ . '/../../resources/views/{,*/,*/*/}*.blade.php', GLOB_BRACE), + ...glob(__DIR__ . '/../../resources/js/{,*/,*/*/}*.{ts,tsx}', GLOB_BRACE), + ]; + + $physical = '/(?assertDoesNotMatchRegularExpression($physical, $class, basename($file) . ": {$class}"); + } + } + } + + #[Test] + public function the_js_test_strings_match_the_dhivehi_strings(): void + { + $dv = json_decode(file_get_contents(__DIR__ . '/../../lang/dv.json'), true); + $fixture = json_decode(file_get_contents(__DIR__ . '/../js/strings.dv.json'), true); + + $expected = array_map(fn (string $english) => $dv[$english] ?? $english, Strings::ALL); + + $this->assertEquals($expected, $fixture, 'Regenerate tests/js/strings.dv.json (see AGENTS.md)'); + } +} diff --git a/tests/js/fixtures.ts b/tests/js/fixtures.ts new file mode 100644 index 0000000..87b36e1 --- /dev/null +++ b/tests/js/fixtures.ts @@ -0,0 +1,43 @@ +import strings from "./strings.dv.json"; +import type { AuthPageProps, AuthType } from "../../resources/js/types"; + +export { strings }; + +export const authType: AuthType = { + name: "user", + features: { registration: true, passkeys: true }, + two_factor: "optional", + username: "email", + login_methods: [], +}; + +export const urls = { + home: "/dashboard", + login: "/login", + "login.store": "/login", + register: "/register", + "register.store": "/register", + "password.request": "/forgot-password", + "password.email": "/forgot-password", + "password.update": "/reset-password", + "passkey.login-options": "/passkeys/login/options", + "passkey.login": "/passkeys/login", + "two-factor.login.store": "/two-factor-challenge", + logout: "/logout", + "profile.edit": "/settings/profile", + "profile.update": "/settings/profile", + "profile.destroy": "/settings/profile", + "security.edit": "/settings/security", + "user-password.update": "/settings/password", + "two-factor.enable": "/settings/two-factor", + "two-factor.confirm": "/settings/two-factor/confirm", + "two-factor.disable": "/settings/two-factor", + "two-factor.regenerate-recovery-codes": "/settings/two-factor/recovery-codes", + "passkey.registration-options": "/settings/passkeys/options", + "passkey.store": "/settings/passkeys", + "sessions.destroy": "/settings/sessions", + "api-tokens.store": "/settings/api-tokens", + "verification.send": "/email/verification-notification", +}; + +export const page: AuthPageProps = { auth_type: authType, urls, strings, status: null }; diff --git a/tests/js/pages.test.tsx b/tests/js/pages.test.tsx new file mode 100644 index 0000000..793a48c --- /dev/null +++ b/tests/js/pages.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import Login from "../../resources/js/Pages/Login"; +import Security from "../../resources/js/Pages/Settings/Security"; +import TwoFactorChallenge from "../../resources/js/Pages/TwoFactorChallenge"; +import { page, strings } from "./fixtures"; + +describe("Login", () => { + it("renders the form, the passkey button and the links in Dhivehi", () => { + render(); + + expect(screen.getByText(strings.log_in_title)).toBeTruthy(); + expect(document.querySelector('form[action="/login"] input[name="email"]')).toBeTruthy(); + expect(document.querySelector('input[name="email"]')?.getAttribute("dir")).toBe("ltr"); + expect(screen.getByText(strings.log_in_with_passkey)).toBeTruthy(); + expect(screen.getByText(strings.sign_up).getAttribute("href")).toBe("/register"); + }); + + it("shows an error when the browser has no passkeys", () => { + render(); + + fireEvent.click(screen.getByText(strings.log_in_with_passkey)); + + expect(screen.getByRole("alert").textContent).toBe(strings.passkeys_unsupported); + expect(screen.queryByText(strings.sign_up)).toBeNull(); + }); + + it("links to alternative login methods", () => { + render( + , + ); + + expect(document.querySelector('[data-login-method="mobile"]')?.getAttribute("href")).toBe("/login/mobile"); + }); +}); + +describe("TwoFactorChallenge", () => { + it("switches between the code and a recovery code", () => { + render(); + + expect(document.querySelector('input[name="code"]')).toBeTruthy(); + + fireEvent.click(screen.getByText(strings.use_recovery_code)); + + expect(document.querySelector('input[name="recovery_code"]')).toBeTruthy(); + expect(document.querySelector('input[name="code"]')).toBeNull(); + }); +}); + +describe("Security", () => { + it("shows the QR code while two-factor authentication is pending, and the passkeys", () => { + render( + ", secret_key: "JBSWY3DP", recovery_codes: ["aaa-bbb"] }} + passkeys={[{ id: 1, name: "ލެޕްޓޮޕް", authenticator: "iCloud Keychain", created_at: "1 day ago", last_used_at: null, destroy_url: "/settings/passkeys/1" }]} + sessions={null} + api_tokens={null} + />, + ); + + expect(document.querySelector("[data-qr]")).toBeTruthy(); + expect(screen.getByText("JBSWY3DP")).toBeTruthy(); + expect(screen.getByText("aaa-bbb")).toBeTruthy(); + expect(screen.getByText("ލެޕްޓޮޕް")).toBeTruthy(); + expect(document.querySelector('form[action="/settings/passkeys/1"]')).toBeTruthy(); + expect(document.querySelector("[data-sessions]")).toBeNull(); + }); + + it("hides turning off two-factor authentication when it is required", () => { + render( + , + ); + + expect(screen.queryByText(strings.two_factor_turn_off)).toBeNull(); + expect(screen.getByText(strings.regenerate_recovery_codes)).toBeTruthy(); + expect(screen.getByText(strings.this_device, { exact: false })).toBeTruthy(); + expect(screen.getByText("1|secret")).toBeTruthy(); + }); +}); diff --git a/tests/js/setup.ts b/tests/js/setup.ts new file mode 100644 index 0000000..3244451 --- /dev/null +++ b/tests/js/setup.ts @@ -0,0 +1,14 @@ +// jsdom has no matchMedia; Onu's components use it for reduced motion +if (!window.matchMedia) { + window.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList; +} diff --git a/tests/js/strings.dv.json b/tests/js/strings.dv.json new file mode 100644 index 0000000..2610d91 --- /dev/null +++ b/tests/js/strings.dv.json @@ -0,0 +1,89 @@ +{ + "account": "އެކައުންޓް", + "account_inactive_title": "ތިބާގެ އެކައުންޓް ހަރަކާތްތެރިއެއް ނޫން", + "add_passkey": "ޕާސްކީއެއް އިތުރުކުރޭ", + "added_time": ":time އިތުރުކުރި", + "api_token_copy": "އައު އޭޕީއައި ޓޯކަން މިހާރު ކޮޕީކުރޭ. މިއީ އަނެއްކާ ދައްކާނެ އެއްޗެއް ނޫން.", + "api_tokens": "އޭޕީއައި ޓޯކަންތައް", + "back_to_login": "ލޮގިން ސަފްޙާއަށް އެނބުރި", + "browser_sessions": "ބްރައުޒަރ ސެޝަންތައް", + "browser_sessions_help": "ތިބާ ލޮގިންވެފައިވާ ބްރައުޒަރތަކާއި ޑިވައިސްތައް.", + "choose_new_password": "އައު ޕާސްވޯޑެއް ހަމަޖައްސާ", + "code": "ކޯޑު", + "confirm": "ކަށަވަރުކުރޭ", + "confirm_password": "ޕާސްވޯޑް ކަށަވަރުކުރޭ", + "confirm_password_help": "މިއީ ރައްކާތެރި ބައެއް. ކުރިއަށް ދިއުމުގެ ކުރިން ތިބާގެ ޕާސްވޯޑް ކަށަވަރުކުރައްވާ.", + "confirm_password_title": "ތިބާގެ ޕާސްވޯޑް ކަށަވަރުކުރޭ", + "confirm_with_passkey": "ޕާސްކީއިން ކަށަވަރުކުރޭ", + "continue": "ކުރިއަށް", + "create_account": "އެކައުންޓް ހަދާ", + "create_token": "ޓޯކަން އުފައްދާ", + "current_password": "މިހާރުގެ ޕާސްވޯޑް", + "delete": "ފުހެލާ", + "delete_account": "އެކައުންޓް ފުހެލާ", + "delete_account_confirm": "ތިބާގެ އެކައުންޓް ފުހެލަން ބޭނުންތޯ؟ މިކަން އަނބުރާ ނުހެދޭނެ.", + "delete_account_help": "އެކައުންޓް ފުހެލުމުން، އޭގެ ހުރިހާ މަޢުލޫމާތެއް ދާއިމީކޮށް ފުހެލެވޭނެ. ކަށަވަރުކުރުމަށް ތިބާގެ ޕާސްވޯޑް ޖައްސަވާ.", + "delete_token_confirm": "މި ޓޯކަން ފުހެލަންވީތޯ؟", + "email": "އީމެއިލް އެޑްރެސް", + "email_reset_link": "ޕާސްވޯޑް ރީސެޓްކުރާ ލިންކް އީމެއިލްކުރޭ", + "email_unverified": "ތިބާގެ އީމެއިލް އެޑްރެސް އަދި ތަސްދީޤުކޮށްފައެއް ނުވޭ.", + "forgot_password": "ޕާސްވޯޑް ހަނދާން ނެތީތޯ؟", + "forgot_password_help": "ތިބާގެ އީމެއިލް އެޑްރެސް ޖައްސަވާ. އައު ޕާސްވޯޑެއް ހަމަޖެއްސުމަށް ލިންކެއް އީމެއިލްކޮށްދޭނަން.", + "have_account": "ކުރިން އެކައުންޓެއް ހަދާފައިވޭތޯ؟", + "last_active_time": ":time ފަހުން ބޭނުންކުރި", + "last_used_time": ":time ފަހުން ބޭނުންކުރި", + "log_in": "ލޮގިން ކުރޭ", + "log_in_title": "ތިބާގެ އެކައުންޓަށް ލޮގިން ކުރޭ", + "log_in_with_passkey": "ޕާސްކީއިން ލޮގިން ކުރޭ", + "log_out": "ލޮގްއައުޓް ކުރޭ", + "log_out_other_sessions": "އެހެން ބްރައުޒަރ ސެޝަންތަކުން ލޮގްއައުޓް ކުރޭ", + "name": "ނަން", + "never_used": "އެއްފަހަރުވެސް ބޭނުން ނުކުރޭ", + "new_password": "އައު ޕާސްވޯޑް", + "no_account": "އެކައުންޓެއް ނެތްތޯ؟", + "no_passkeys": "އަދި އެއްވެސް ޕާސްކީއެއް ނެތް.", + "passkey": "ޕާސްކީ", + "passkey_add_failed": "ޕާސްކީ އިތުރެއް ނުކުރެވުނު. އަލުން މަސައްކަތްކުރައްވާ.", + "passkey_confirm_failed": "ޕާސްކީއިން ކަށަވަރެއް ނުކުރެވުނު. އަލުން މަސައްކަތްކުރައްވާ.", + "passkey_login_failed": "ޕާސްކީއިން ލޮގިނެއް ނުވެވުނު. އަލުން މަސައްކަތްކުރައްވާ.", + "passkey_name": "ޕާސްކީގެ ނަން", + "passkey_name_placeholder": "މިސާލަކަށް: އޮފީހުގެ ލެޕްޓޮޕް", + "passkeys": "ޕާސްކީތައް", + "passkeys_help": "ޕާސްވޯޑެއްގެ ބަދަލުގައި އިނގިލީގެ ނިޝާނު، މޫނު ނުވަތަ ސްކްރީން ލޮކުން ލޮގިން ކުރޭ.", + "passkeys_unsupported": "މި ބްރައުޒަރުގައި ޕާސްކީ ބޭނުން ނުކުރެވޭނެ.", + "password": "ޕާސްވޯޑް", + "password_update_required": "ކުރިއަށް ދިއުމުގެ ކުރިން އައު ޕާސްވޯޑެއް ހަމަޖައްސަން ޖެހޭ.", + "profile": "ޕްރޮފައިލް", + "profile_information": "ޕްރޮފައިލްގެ މަޢުލޫމާތު", + "recovery_code": "ރިކަވަރީ ކޯޑު", + "recovery_codes_help": "މި ރިކަވަރީ ކޯޑުތައް ރައްކާތެރި ތަނެއްގައި ބަހައްޓަވާ. އޮތެންޓިކޭޓަރ އެޕް ގެއްލިއްޖެނަމަ، ކޮންމެ ކޯޑެއް އެއްފަހަރު ބޭނުންކުރެވޭނެ.", + "regenerate_recovery_codes": "އައު ރިކަވަރީ ކޯޑުތައް އުފައްދާ", + "register_title": "އެކައުންޓެއް ހަދާ", + "remember_me": "ހަނދާންބަހައްޓާ", + "remove": "ފުހެލާ", + "remove_passkey_confirm": "މި ޕާސްކީ ފުހެލަންވީތޯ؟", + "resend_verification": "ތަސްދީޤުކުރާ އީމެއިލް އަލުން ފޮނުވާ", + "reset_password": "ޕާސްވޯޑް ރީސެޓްކުރޭ", + "reset_your_password": "ތިބާގެ ޕާސްވޯޑް ރީސެޓްކުރޭ", + "save": "ސޭވްކުރޭ", + "save_password": "ޕާސްވޯޑް ސޭވްކުރޭ", + "security": "ސެކިއުރިޓީ", + "setup_key": "ސެޓަޕް ކީ", + "sign_up": "ސައިން އަޕް ކުރޭ", + "this_device": "މި ޑިވައިސް", + "token_name": "ޓޯކަނުގެ ނަން", + "two_factor_help": "އޮތެންޓިކޭޓަރ އެޕުން ލިބޭ ކޯޑު ޖައްސަވާ.", + "two_factor_offer": "އޮތެންޓިކޭޓަރ އެޕަކުން ލިބޭ ކޯޑަކުން ލޮގިންވުމަށް ދެވަނަ ފިޔަވަޅެއް އިތުރުކުރޭ.", + "two_factor_on": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ހުޅުވާފައި.", + "two_factor_required": "ތިބާގެ އެކައުންޓަށް ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ލާޒިމް. ކުރިއަށް ދިއުމަށް އެ ހަމަޖައްސަވާ.", + "two_factor_scan": "އޮތެންޓިކޭޓަރ އެޕުން ކިއުއާރް ކޯޑު ސްކޭންކޮށް، އެޕުން ދައްކާ ކޯޑު ޖައްސަވާ.", + "two_factor_title": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން", + "two_factor_turn_off": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ނިއްވާ", + "two_factor_turn_on": "ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ހުޅުވާ", + "unknown": "ނޭނގޭ", + "update_password": "ޕާސްވޯޑް ބަދަލުކުރޭ", + "use_authenticator_code": "އޭގެ ބަދަލުގައި އެޕުން ލިބޭ ކޯޑު ބޭނުންކުރޭ", + "use_recovery_code": "އޭގެ ބަދަލުގައި ރިކަވަރީ ކޯޑެއް ބޭނުންކުރޭ", + "verify_email_help": ":email އަށް ތަސްދީޤުކުރާ ލިންކެއް އީމެއިލްކޮށްފީމު. އީމެއިލް އެޑްރެސް ތަސްދީޤުކުރުމަށް އެ ލިންކަށް ފިތާލައްވާ.", + "verify_email_title": "ތިބާގެ އީމެއިލް އެޑްރެސް ތަސްދީޤުކުރޭ" +} diff --git a/tests/js/webauthn.test.ts b/tests/js/webauthn.test.ts new file mode 100644 index 0000000..65290dd --- /dev/null +++ b/tests/js/webauthn.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { base64UrlToBuffer, bufferToBase64Url, credentialToJson, errorMessage, passkeysSupported, verifyPasskey } from "../../resources/js/lib/webauthn"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("base64url", () => { + it("round-trips bytes", () => { + const bytes = new Uint8Array([0, 1, 250, 251, 252, 253, 254, 255]); + const encoded = bufferToBase64Url(bytes); + + expect(encoded).not.toMatch(/[+/=]/); + expect(new Uint8Array(base64UrlToBuffer(encoded))).toEqual(bytes); + }); +}); + +describe("credentialToJson", () => { + it("encodes an assertion the way laravel/passkeys reads it", () => { + const buffer = (values: number[]) => new Uint8Array(values).buffer; + const credential = { + id: "abc", + rawId: buffer([1, 2, 3]), + type: "public-key", + authenticatorAttachment: "platform", + getClientExtensionResults: () => ({}), + response: { + clientDataJSON: buffer([4]), + authenticatorData: buffer([5]), + signature: buffer([6]), + userHandle: buffer([7]), + }, + } as unknown as PublicKeyCredential; + + expect(credentialToJson(credential)).toEqual({ + id: "abc", + rawId: "AQID", + type: "public-key", + authenticatorAttachment: "platform", + clientExtensionResults: {}, + response: { clientDataJSON: "BA", authenticatorData: "BQ", signature: "Bg", userHandle: "Bw" }, + }); + }); +}); + +describe("verifyPasskey", () => { + it("gets options, asks the browser and posts the credential with the CSRF token", async () => { + document.head.innerHTML = ''; + + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ options: { challenge: "AQID", allowCredentials: [] } }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ redirect: "/dashboard" }))); + vi.stubGlobal("fetch", fetch); + + const get = vi.fn().mockResolvedValue({ + id: "abc", + rawId: new Uint8Array([1]).buffer, + type: "public-key", + getClientExtensionResults: () => ({}), + response: { clientDataJSON: new Uint8Array([2]).buffer, authenticatorData: new Uint8Array([3]).buffer, signature: new Uint8Array([4]).buffer, userHandle: null }, + }); + vi.stubGlobal("navigator", { credentials: { get } }); + + const result = await verifyPasskey({ optionsUrl: "/passkeys/login/options", url: "/passkeys/login", data: { remember: true } }); + + expect(result).toEqual({ ok: true, status: 200, body: { redirect: "/dashboard" } }); + expect(new Uint8Array(get.mock.calls[0]![0].publicKey.challenge)).toEqual(new Uint8Array([1, 2, 3])); + + const [url, init] = fetch.mock.calls[1]!; + expect(url).toBe("/passkeys/login"); + expect(init.method).toBe("POST"); + expect(init.headers["X-CSRF-TOKEN"]).toBe("token-123"); + expect(JSON.parse(init.body)).toMatchObject({ remember: true, credential: { id: "abc", rawId: "AQ" } }); + }); +}); + +describe("errorMessage", () => { + it("prefers the first validation error", () => { + expect(errorMessage({ ok: false, status: 422, body: { message: "x", errors: { credential: ["ޕާސްކީ ނުވަޒަނެއް."] } } }, "fallback")).toBe("ޕާސްކީ ނުވަޒަނެއް."); + expect(errorMessage({ ok: false, status: 500, body: null }, "fallback")).toBe("fallback"); + }); +}); + +describe("passkeysSupported", () => { + it("is false without PublicKeyCredential", () => { + expect(passkeysSupported()).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..06de328 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "types": [ + "vitest/globals" + ], + "paths": { + "@javaabu/auth": [ + "./resources/js" + ], + "@javaabu/auth/*": [ + "./resources/js/*" + ] + }, + "resolveJsonModule": true + }, + "include": [ + "resources/js", + "tests/js" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..df33abf --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["tests/js/**/*.test.{ts,tsx}"], + setupFiles: ["tests/js/setup.ts"], + }, +}); From a92d09a9399689bb696d63efe012283feeefa95d Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 16:52:04 +0500 Subject: [PATCH 08/14] Docs for people and agents, llms.txt, Laravel Boost guideline and skill Docusaurus pages for every flow, option and public class, Blade and React side by side, an upgrade guide from v2, AGENTS.md and the changelog. Records last_login_at on login. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 54 +++++++ CHANGELOG.md | 22 ++- CLAUDE.md | 1 + docs/advanced-usage/_category_.json | 11 ++ docs/advanced-usage/actions.md | 85 +++++++++++ .../alternative-login-methods.md | 78 ++++++++++ docs/advanced-usage/api-reference.md | 91 +++++++++++ docs/advanced-usage/configuration.md | 38 +++++ docs/advanced-usage/events.md | 48 ++++++ .../overriding-controllers-and-views.md | 68 +++++++++ docs/advanced-usage/translations.md | 44 ++++++ docs/ai-assistants.md | 32 ++++ docs/basic-usage/_category_.json | 2 +- docs/basic-usage/api-authentication.md | 110 ++++++++++++++ docs/basic-usage/email-verification.md | 15 ++ docs/basic-usage/logging-in.md | 56 +++++++ docs/basic-usage/managing-accounts.md | 77 ++++++++++ docs/basic-usage/middleware.md | 29 ++++ docs/basic-usage/pages.md | 114 ++++++++++++++ docs/basic-usage/passkeys.md | 74 +++++++++ docs/basic-usage/password-confirmation.md | 27 ++++ docs/basic-usage/password-reset.md | 22 +++ docs/basic-usage/profile-and-security.md | 25 +++ docs/basic-usage/routes.md | 79 ++++++++++ docs/basic-usage/two-factor-authentication.md | 75 +++++++++ docs/basic-usage/unique-gmail-rule.md | 7 +- docs/basic-usage/user-models.md | 91 +++++++++++ docs/basic-usage/user-types.md | 112 ++++++++++++++ docs/installation-and-setup.md | 143 +++++++++++++++++- docs/intro.md | 40 ++++- docs/metadata.json | 4 +- docs/requirements.md | 24 ++- docs/upgrading.md | 96 ++++++++++++ llms.txt | 54 +++++++ resources/boost/guidelines/core.blade.php | 12 ++ resources/boost/skills/javaabu-auth/SKILL.md | 87 +++++++++++ .../references/common-mistakes.md | 48 ++++++ .../skills/javaabu-auth/references/testing.md | 52 +++++++ src/Actions/LoginUser.php | 6 + tests/Feature/LoginTest.php | 1 + tests/Unit/DocsTest.php | 30 ++++ 41 files changed, 2062 insertions(+), 22 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/advanced-usage/_category_.json create mode 100644 docs/advanced-usage/actions.md create mode 100644 docs/advanced-usage/alternative-login-methods.md create mode 100644 docs/advanced-usage/api-reference.md create mode 100644 docs/advanced-usage/configuration.md create mode 100644 docs/advanced-usage/events.md create mode 100644 docs/advanced-usage/overriding-controllers-and-views.md create mode 100644 docs/advanced-usage/translations.md create mode 100644 docs/ai-assistants.md create mode 100644 docs/basic-usage/api-authentication.md create mode 100644 docs/basic-usage/email-verification.md create mode 100644 docs/basic-usage/logging-in.md create mode 100644 docs/basic-usage/managing-accounts.md create mode 100644 docs/basic-usage/middleware.md create mode 100644 docs/basic-usage/pages.md create mode 100644 docs/basic-usage/passkeys.md create mode 100644 docs/basic-usage/password-confirmation.md create mode 100644 docs/basic-usage/password-reset.md create mode 100644 docs/basic-usage/profile-and-security.md create mode 100644 docs/basic-usage/routes.md create mode 100644 docs/basic-usage/two-factor-authentication.md create mode 100644 docs/basic-usage/user-models.md create mode 100644 docs/basic-usage/user-types.md create mode 100644 docs/upgrading.md create mode 100644 llms.txt create mode 100644 resources/boost/guidelines/core.blade.php create mode 100644 resources/boost/skills/javaabu-auth/SKILL.md create mode 100644 resources/boost/skills/javaabu-auth/references/common-mistakes.md create mode 100644 resources/boost/skills/javaabu-auth/references/testing.md create mode 100644 tests/Unit/DocsTest.php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6b4d052 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# auth: notes for AI coding agents + +`javaabu/auth` v3 is multi-auth for Laravel: user types (each with a guard, password broker, routes, pages and features), every auth flow, two-factor authentication, passkeys, API tokens and admin account actions. Other Javaabu packages (javaabu/mobile-verification, the skeletons) build on `UserTypes`, `Route::authRoutes()`, `LoginMethod` and `StartAuthenticatedSession`, so treat those as public contracts. + +## Layout + +| Path | What | +| --- | --- | +| `src/UserType.php`, `src/UserTypeRegistry.php`, `src/UserTypes.php` | A user type, the registry (config + code), the facade | +| `src/Routing` | `AuthRoutes` (`Route::authRoutes()`), `AuthApiRoutes` (`Route::authApiRoutes()`), `UserAccountRoutes` (`Route::userAccountActions()`) | +| `src/Http/Controllers` | One controller per page or flow; resolved from `config('javaabu-auth.controllers')` | +| `src/Http/Middleware` | `auth.type`, `auth.guest`, `auth.active`, `auth.verified`, `auth.password-updated`, `auth.two-factor`, `auth.password.confirm`, `auth.user-type` | +| `src/Http/Responses` | Login, logout, register and 2FA challenge responses (rebindable) | +| `src/Actions` | Login pipeline (`AttemptLogin` → `StartAuthenticatedSession` → `LoginUser`), user actions behind `src/Contracts`, `TwoFactor/*`, `IssueApiToken` | +| `src/Concerns`, `src/Contracts`, `src/Models/User.php` | Model traits, their contracts, the base model | +| `src/TwoFactor` | TOTP provider (google2fa, each code once) and recovery codes | +| `src/Passkeys` | Morph `Passkey` model and the laravel/passkeys action subclasses | +| `src/Sessions` | `multi_auth_database` session handler, `Session` model, `Agent` | +| `src/Support` | `PageData` (shared props), `Strings` (React strings), `ApiTokens`, `AccountState`, `ActivityLogger` | +| `src/Policies/UserAccountPolicy.php` | The `auth.*` account abilities | +| `src/Views/ViewRenderer.php` | Blade (`auth::login`) or Inertia (`auth::Login`) per user type | +| `resources/views` | Blade pages and layouts (javaabu/forms v2 components) | +| `resources/js` | Inertia React pages (`Pages`), components and `lib` (exported as `@javaabu/auth`), and the Blade scripts (`passkeys.ts`, `auth.ts`), registered with javaabu/manifest | +| `lang/en.json`, `lang/dv.json` | English keys and Dhivehi | +| `docs`, `llms.txt`, `resources/boost` | Docusaurus pages, the index for AI assistants, the Laravel Boost guideline and skill | +| `tests` | Testbench tests with two types: `user` (`web`, `User`, no route prefix) and `admin` (`web_admin`, `Admin`, `admin.` names under `/admin`). `tests/Support/SoftwareAuthenticator.php` makes real passkey credentials. Tests needing Inertia, Sanctum or Passport are in the `optional` group. `tests/js` are Vitest tests | + +## Rules + +- Follow Javaabu codestyle ([boost-javaabu-guidelines](https://github.com/Javaabu/boost-javaabu-guidelines)): snake_case variables and properties, camelCase methods, early returns, `#[Test]` and `it_…` test names. Pint enforces formatting. +- Nothing may assume the `web` guard, `App\Models\User` or global route names. Resolve the type from the route (`UserTypes::fromRequest()`) or the model, and test behaviour on both test types, including that one type can't use another's login, challenge, confirmation, passkeys or tokens. +- Security-sensitive changes (login, 2FA, passkeys, tokens, password reset) need tests of the failure cases too. +- Inertia, Sanctum, Passport and activitylog are optional: feature-detect them (`class_exists`, `function_exists`), never `use` their classes in code that always runs, and keep CI's "Without the optional packages" job green. +- Every page exists in Blade and React with the same props. Use logical utilities only; a test rejects `ml-`, `text-left` and the like. +- Every user-facing string goes through `__()` with an English key and gets a Dhivehi line in `lang/dv.json` and an English line in `lang/en.json` (a test checks). React strings go in `Support\Strings`; regenerate `tests/js/strings.dv.json` after changing them: + + ```bash + php -r 'require "vendor/autoload.php"; $dv = json_decode(file_get_contents("lang/dv.json"), true); $out = []; foreach (Javaabu\Auth\Support\Strings::ALL as $k => $en) { $out[$k] = $dv[$en] ?? $en; } file_put_contents("tests/js/strings.dv.json", json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");' + ``` + +- List uncertain Dhivehi in `docs/advanced-usage/translations.md`. Use Thaana in tests of user-facing behaviour. +- Every change needs tests, docs (linked from `llms.txt`), the Boost skill if it changes how the package is used, and a line in `CHANGELOG.md`. +- Work on the `next-gen` branch; don't commit to `main`. + +## Commands + +```bash +composer install +composer lint # vendor/bin/pint --test +composer format # vendor/bin/pint +composer test # vendor/bin/phpunit +pnpm install # the React pages' dev tooling +pnpm check # typecheck and Vitest +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8869e77..74d3a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,23 @@ # Changelog -All notable changes will be documented in this file +All notable changes will be documented in this file. See [docs/upgrading.md](docs/upgrading.md) for moving from v2. -## 1.0.0 - 202X-XX-XX +## 3.0.0 - unreleased -- initial release +- User types: any number of kinds of users, each with its own model, guard, password broker, route names, home, view stack, layouts, features, two-factor mode, lockout limit and login methods (`config('javaabu-auth.user_types')`, `UserTypes::register()`) +- `Route::authRoutes()` registers a type's login, logout, registration, password reset, email verification, password confirmation, two-factor challenge, passkey, account status, required password change, profile and security routes, with Laravel starter kit route names +- Controllers for every page, overridable from `javaabu-auth.controllers`; actions behind contracts (`CreatesNewUsers`, `ResetsUserPasswords`, `UpdatesUserPasswords`, `UpdatesUserProfileInformation`, `DeletesUsers`); rebindable responses +- Middleware: `auth.type`, `auth.guest`, `auth.active`, `auth.verified`, `auth.password-updated`, `auth.two-factor`, `auth.password.confirm` (kept per user type), `auth.user-type` +- Rate limiting, timeboxed password checks, account lockout after failed logins, per-type guest redirects +- TOTP two-factor authentication with recovery codes, off, optional or required per type, in laravel/fortify's column format +- Passkeys with laravel/passkeys for every user type: login, password confirmation and management, in one morph table +- API authentication with Sanctum, Passport or both: `php artisan auth:install`, `Route::authApiRoutes()` (Sanctum token logins with two-factor), personal access tokens on the security page +- Profile page (name, email, account deletion) and security page (password, two-factor, passkeys, browser sessions, API tokens) +- `Route::userAccountActions()`: unlock, reset two-factor, require a new password and log out everywhere, behind `UserAccountPolicy` and permissions registered with javaabu/permissions +- `LoginMethod` contract and `StartAuthenticatedSession` for alternative login methods (javaabu/mobile-verification) +- Pages in Blade (javaabu/forms v2) and Inertia React (@javaabu/forms, shipped through javaabu/manifest), with a shared WebAuthn client and components exported as `@javaabu/auth` +- Dhivehi translations; right-to-left pages +- Optional activity logging with spatie/laravel-activitylog +- Docs for people and agents, `llms.txt`, Laravel Boost guideline and skill +- Requires PHP 8.4, Laravel 12 or 13 +- Breaking: rewritten on top of user types instead of abstract laravel/ui controllers; config moves from `config/auth.php` to `config/javaabu-auth.php`; `Javaabu\Auth\User` is now `Javaabu\Auth\Models\User` with traits; no longer requires laravel/ui, javaabu/helpers, javaabu/activitylog, javaabu/passport, javaabu/schema or spatie/laravel-medialibrary; the new-email verification flow is removed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/advanced-usage/_category_.json b/docs/advanced-usage/_category_.json new file mode 100644 index 0000000..462d41f --- /dev/null +++ b/docs/advanced-usage/_category_.json @@ -0,0 +1,11 @@ +{ + "position": 1.31, + "label": "Advanced Usage", + "collapsible": true, + "collapsed": false, + "link": { + "type": "generated-index", + "slug": "/auth/_categories/advanced-usage", + "description": "Every option, overrides, extension points and the API." + } +} diff --git a/docs/advanced-usage/actions.md b/docs/advanced-usage/actions.md new file mode 100644 index 0000000..97bc8e5 --- /dev/null +++ b/docs/advanced-usage/actions.md @@ -0,0 +1,85 @@ +--- +title: Actions +sidebar_position: 2 +--- + +What happens to the user's data lives in actions behind contracts, as in Laravel's starter kits. Bind your own class to change it: + +| Contract | Default | Used by | +| --- | --- | --- | +| `Contracts\CreatesNewUsers` | `Actions\CreateNewUser` | Registration | +| `Contracts\ResetsUserPasswords` | `Actions\ResetUserPassword` | Reset password | +| `Contracts\UpdatesUserPasswords` | `Actions\UpdateUserPassword` | Security page, required password change | +| `Contracts\UpdatesUserProfileInformation` | `Actions\UpdateUserProfileInformation` | Profile page | +| `Contracts\DeletesUsers` | `Actions\DeleteUser` | Deleting the account | + +Every method gets the `UserType`, so one action can treat types differently. + +```php +namespace App\Actions; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; +use Javaabu\Auth\Actions\PasswordValidationRules; +use Javaabu\Auth\Contracts\CreatesNewUsers; +use Javaabu\Auth\Rules\UniqueGmail; +use Javaabu\Auth\UserType; + +class CreateNewUser implements CreatesNewUsers +{ + use PasswordValidationRules; + + public function create(UserType $type, array $input): Model + { + $user = $type->newModel(); + + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique($user::class), new UniqueGmail($user::class)], + 'mobile' => ['required', 'string', 'max:20'], + 'password' => $this->passwordRules(), + ])->validate(); + + $user->forceFill([ + 'name' => $input['name'], + 'email' => mb_strtolower($input['email']), + 'mobile' => $input['mobile'], + 'password' => Hash::make($input['password']), + ]); + + $user->approve(); + $user->save(); + + return $user; + } +} +``` + +```php +// AppServiceProvider::register() +$this->app->bind(\Javaabu\Auth\Contracts\CreatesNewUsers::class, \App\Actions\CreateNewUser::class); +``` + +The login steps are actions too, resolved from the container, so they can be extended and rebound: + +| Class | What | +| --- | --- | +| `Actions\AttemptLogin` | Rate limit, lockout, password check, failed events | +| `Actions\StartAuthenticatedSession` | Two-factor challenge or login (the extension point for [login methods](alternative-login-methods.md)) | +| `Actions\LoginUser` | Logs in, regenerates the session, clears failed attempts, records `last_login_at` | +| `Actions\IssueApiToken` | The Sanctum token endpoint | +| `Actions\TwoFactor\*` | Enable, confirm, disable, recovery codes | + +# Responses + +`Http\Responses\LoginResponse`, `TwoFactorChallengeResponse`, `RegisterResponse` and `LogoutResponse` decide where users go. Rebind them: + +```php +use Javaabu\Auth\Http\Responses\LoginResponse; + +$this->app->singleton(LoginResponse::class, App\Http\Responses\LoginResponse::class); +``` + +`UserTypes::fromRequest($request)` gives the type inside a response. diff --git a/docs/advanced-usage/alternative-login-methods.md b/docs/advanced-usage/alternative-login-methods.md new file mode 100644 index 0000000..69861c7 --- /dev/null +++ b/docs/advanced-usage/alternative-login-methods.md @@ -0,0 +1,78 @@ +--- +title: Alternative login methods +sidebar_position: 1 +--- + +Besides the password form and passkeys, a user type can offer other ways to log in, such as a mobile number and a one-time code (javaabu/mobile-verification plugs in this way). A login method is a class implementing `Javaabu\Auth\Contracts\LoginMethod`: + +```php +namespace App\Auth; + +use Illuminate\Http\Request; +use Illuminate\Routing\Router; +use Illuminate\Validation\ValidationException; +use Javaabu\Auth\Actions\StartAuthenticatedSession; +use Javaabu\Auth\Contracts\LoginMethod; +use Javaabu\Auth\UserType; +use Javaabu\Auth\UserTypes; + +class MobileLoginMethod implements LoginMethod +{ + public function key(): string + { + return 'mobile'; + } + + public function label(): string + { + return __('Log in with your mobile number'); + } + + /** Registered inside the type's guest route group, so names get the type's prefix */ + public function routes(Router $router, UserType $type): void + { + $router->get('login/mobile', [MobileLoginController::class, 'create'])->name('login.mobile'); + $router->post('login/mobile', [MobileLoginController::class, 'store'])->name('login.mobile.store'); + } + + public function url(UserType $type): string + { + return $type->route('login.mobile'); + } +} +``` + +Once the method has identified the user, finish with `StartAuthenticatedSession`. It asks for the two-factor code when the user has it on, otherwise logs in on the type's guard, regenerates the session and returns the login response, exactly as the password form does: + +```php +class MobileLoginController +{ + public function store(Request $request, StartAuthenticatedSession $start_session) + { + $type = UserTypes::fromRequest($request); + + $user = $type->newModel()->newQuery()->where('mobile', $request->input('mobile'))->first(); + + if (! $user || ! $this->codeIsValid($user, $request->input('code'))) { + throw ValidationException::withMessages(['code' => __('The code is incorrect.')]); + } + + return $start_session($request, $type, $user, remember: $request->boolean('remember')); + } +} +``` + +Pass `skip_two_factor: true` when the method is already two factors. + +Add the method to the type: + +```php +'admin' => [ + 'login_methods' => [App\Auth\MobileLoginMethod::class], +], + +// or +UserTypes::get('user')->loginMethod(MobileLoginMethod::class); +``` + +Register methods before `Route::authRoutes()` runs. The login page shows a link per method (`data-login-method="mobile"`), and the React login page gets them in `auth_type.login_methods`. Leave a method's routes out for one registration with `'except' => ['login_method:mobile']`. diff --git a/docs/advanced-usage/api-reference.md b/docs/advanced-usage/api-reference.md new file mode 100644 index 0000000..089d841 --- /dev/null +++ b/docs/advanced-usage/api-reference.md @@ -0,0 +1,91 @@ +--- +title: API reference +sidebar_position: 10 +--- + +All classes are in `Javaabu\Auth`. + +# UserTypes (facade over UserTypeRegistry) + +| Method | Returns | +| --- | --- | +| `register(string $name, array\|Closure\|null $config = null)` | `UserType`: adds a type or changes one | +| `get(string $name)` | `UserType`, or throws | +| `has(string $name)` | `bool` | +| `all()` | `array` | +| `default()` | The first type | +| `for(Model\|string $user)` | The type of a model or model class (exact class first), or `null` | +| `forGuard(?string $guard)` | The type on a guard, or `null` | +| `fromRequest(?Request $request = null)` | The type of the current route, or `null` | +| `forget(string $name)`, `flush()` | For tests | + +`UserTypeRegistry::ROUTE_ACTION` (`auth_user_type`) is the route action key holding the type. + +# UserType + +Setters return the type: `model()`, `guard()`, `passwords()`, `routeName()`, `home()`, `views()`, `layouts()`, `username()`, `features()`, `enable(...$features)`, `disable(...$features)`, `twoFactor()`, `maxLoginAttempts()`, `loginMethods()`, `loginMethod()`. `UserType::fromArray($name, $config)` builds one from config. + +| Getter | Returns | +| --- | --- | +| `name()` | `string` | +| `modelClass()`, `newModel()` | The model class, a new instance | +| `guardName()`, `auth()` | The guard name, the `StatefulGuard` | +| `passwordBrokerName()`, `broker()` | The broker name, the `PasswordBroker` | +| `routeNamePrefix()`, `routeNameFor($route)`, `hasRoute($route)`, `route($route, $parameters = [], $absolute = true)` | Route names and URLs of the type | +| `homeUrl(?Model $user = null)` | Where users go after logging in | +| `viewStack()`, `layoutName($kind = 'auth')` | `blade`/`inertia`, a layout | +| `usernameColumn()` | `email` by default | +| `hasFeature($feature)`, `allFeatures()` | Features (`UserType::REGISTRATION`, `PASSWORD_RESET`, `EMAIL_VERIFICATION`, `PASSWORD_CONFIRMATION`, `PROFILE`, `DELETE_ACCOUNT`, `SESSIONS`, `PASSKEYS`, `API_TOKENS`) | +| `twoFactorMode()`, `hasTwoFactor()`, `requiresTwoFactor()` | `Enums\TwoFactorModes` (`OFF`, `OPTIONAL`, `REQUIRED`) | +| `maxLoginAttemptsAllowed()` | `?int` | +| `loginMethodsList()` | `array` | +| `owns(mixed $user)` | Whether a model is one of this type's users | + +# Route macros + +- `Route::authRoutes(?string $type = null, array $options = [])`: `Routing\AuthRoutes` +- `Route::authApiRoutes(?string $type = null, array $options = [])`: `Routing\AuthApiRoutes` +- `Route::userAccountActions(string $type, array $options = [])`: `Routing\UserAccountRoutes` + +# Models and traits + +- `Models\User`: abstract model with every trait +- `Concerns\HasUserType`: `userType()`, `sendPasswordResetNotification()`, `sendEmailVerificationNotification()` +- `Concerns\HasAccountStatus`: `hasActiveAccount()`, `accountStatusMessage()`, `approve()`, `markAsPending()`, `ban()`, `isApproved()`, `isPending()`, `isBanned()`, scopes `approved`, `pending`, `banned` +- `Concerns\LocksAfterFailedLogins`: `maxLoginAttempts()`, `isLockedOut()`, `incrementLoginAttempts()`, `resetLoginAttempts()`, scopes `lockedOut`, `notLockedOut` +- `Concerns\RequiresPasswordUpdate`: `requiresPasswordUpdate()`, `requirePasswordUpdate()`, `clearRequirePasswordUpdate()` +- `Concerns\TwoFactorAuthenticatable`: `hasEnabledTwoFactorAuthentication()`, `hasPendingTwoFactorAuthentication()`, `twoFactorSecret()`, `recoveryCodes()`, `replaceRecoveryCode()`, `twoFactorQrCodeUrl()`, `twoFactorQrCodeSvg()`, `twoFactorLabel()` +- `Concerns\HasPasskeys`: `passkeys()`, `hasPasskeysEnabled()`, and laravel/passkeys' `getPasskeyUserHandle()`, `getPasskeyDisplayName()`, `getPasskeyUsername()` +- `Concerns\HasSessions`: `sessions()`, `deleteOtherSessionRecords()` +- `Concerns\DetectsGmailAliases`: scope `hasGmailAlias($email, $field = 'email')` +- `Passkeys\Passkey`: the passkey model (`user()` morph, `toDisplayArray()`) +- `Sessions\Session`: a session row (`user()`, `agent()`, `isCurrentDevice()`, `toDisplayArray()`) +- `Enums\UserStatuses`: `APPROVED`, `PENDING`, `BANNED`, with `label()`, `message()`, `color()`, `labels()` +- `Schema\UserSchema`: `columns()`, `accountColumns()`, `twoFactorColumns()`, `sessionColumns()` + +# Contracts + +`Contracts\LoginMethod`, `HasAccountStatus`, `LocksAfterFailedLogins`, `RequiresPasswordUpdate`, `TwoFactorAuthenticatable`, `CreatesNewUsers`, `ResetsUserPasswords`, `UpdatesUserPasswords`, `UpdatesUserProfileInformation`, `DeletesUsers`. + +# Actions + +`Actions\AttemptLogin`, `StartAuthenticatedSession` (`__invoke(Request, UserType, Authenticatable, bool $remember = false, bool $skip_two_factor = false)`, `needsTwoFactorChallenge()`), `LoginUser`, `IssueApiToken`, `CreateNewUser`, `ResetUserPassword`, `UpdateUserPassword`, `UpdateUserProfileInformation`, `DeleteUser`, `TwoFactor\EnableTwoFactorAuthentication`, `TwoFactor\ConfirmTwoFactorAuthentication`, `TwoFactor\DisableTwoFactorAuthentication`, `TwoFactor\GenerateNewRecoveryCodes`. + +# Support + +- `Support\ApiTokens`: `enabledDrivers()`, `isEnabled()`, `driverFor()`, `supports()`, `create()`, `list()`, `revoke()` +- `Support\AccountState`: `inactiveMessage($user)`, `isActive($user)` +- `Support\PageData`: `shared($type)`, `urls($type)`, `strings()` +- `Support\Strings::ALL`: the React pages' string keys and English text +- `Support\ActivityLogger`: `isAvailable()`, `EVENTS` +- `TwoFactor\TwoFactorProvider`: `generateSecretKey()`, `qrCodeUrl()`, `verify($secret, $code)` (each code once), `currentCode($secret)` +- `Views\ViewRenderer`: `render($type, $page, $data = [], $layout = 'auth')`, `inertiaPage($page)` +- `Http\Middleware\RequirePassword`: `sessionKey($type)`, `markConfirmed($request, $type)`, `isConfirmed($request, $type, $timeout = null)` +- `Policies\UserAccountPolicy`: `unlock()`, `resetTwoFactor()`, `requirePasswordUpdate()`, `logOut()`, `resource($type)`, `permission($action, $type)`, `ABILITIES`, `DESCRIPTIONS` +- `Rules\UniqueGmail` +- `Notifications\ResetPassword`, `Notifications\VerifyEmail` +- `AuthServiceProvider::ignoreMigrations()`, `MIDDLEWARE`, `ACTIONS` + +# Commands + +`php artisan auth:install {--api=none|sanctum|passport|both} {--force}` diff --git a/docs/advanced-usage/configuration.md b/docs/advanced-usage/configuration.md new file mode 100644 index 0000000..75e6e66 --- /dev/null +++ b/docs/advanced-usage/configuration.md @@ -0,0 +1,38 @@ +--- +title: Configuration +sidebar_position: 4 +--- + +`config/javaabu-auth.php` (publish with `--tag=auth-config`). Every option has a default; one user type on the `web` guard needs none of it. + +| Key | Default | What | +| --- | --- | --- | +| `user_types` | one `user` type on `web` | The user types; see [User types](../basic-usage/user-types.md) | +| `views` | `AUTH_VIEWS` or `blade` | `blade` or `inertia` | +| `layouts.auth` | `auth::layouts.auth` | Blade layout of the guest pages | +| `layouts.account` | `auth::layouts.account` | Blade layout of the profile and security pages | +| `layout_section` | `content` | The section the pages fill | +| `max_login_attempts` | `AUTH_MAX_LOGIN_ATTEMPTS` or 5 | Failed logins in a row before an account locks; `null` never locks | +| `rate_limits.login` | 5 | Login attempts per minute per type, username and IP | +| `rate_limits.two_factor` | 5 | Codes per minute per user | +| `rate_limits.passkeys` | 10 | Passkey requests per minute | +| `rate_limits.password_reset` | 6 | Reset link and reset requests per minute per IP | +| `rate_limits.verification` | 6 | Verification requests per minute per IP | +| `rate_limits.api_tokens` | 6 | Token requests per minute per IP | +| `password_timeout` | `AUTH_PASSWORD_TIMEOUT` or 10800 | Seconds a password confirmation lasts | +| `two_factor.issuer` | `app.name` | Name shown in authenticator apps | +| `two_factor.window` | 1 | 30 second periods either side of now that are accepted | +| `two_factor.secret_length` | 16 | Length of new secrets | +| `two_factor.recovery_codes` | 8 | How many recovery codes users get | +| `api` | `AUTH_API` or `none` | `none`, `sanctum`, `passport` or `both` | +| `api_tokens.abilities` | `['*']` | Default Sanctum abilities or Passport scopes of personal access tokens | +| `api_tokens.expiration` | `null` | Minutes before new Sanctum tokens expire | +| `register_permissions` | `true` | Register the account-management permissions with javaabu/permissions | +| `managed_user_types` | `[]` (all) | The user types those permissions are registered for | +| `policy` | `Policies\UserAccountPolicy` | The policy behind the `auth.*` account abilities | +| `log_activity` | `true` | Log auth events with spatie/laravel-activitylog when installed | +| `controllers.*` | the package's | The controllers of the routes; see [Overriding controllers and views](overriding-controllers-and-views.md) | + +Passkey settings (relying party, origins, timeout) are laravel/passkeys' own, in `config/passkeys.php`. Guards, providers and password brokers are Laravel's, in `config/auth.php`. + +The v2 package merged its options into `config/auth.php`; v3 keeps them out of it. See [Upgrading](../upgrading.md). diff --git a/docs/advanced-usage/events.md b/docs/advanced-usage/events.md new file mode 100644 index 0000000..3c12544 --- /dev/null +++ b/docs/advanced-usage/events.md @@ -0,0 +1,48 @@ +--- +title: Events and activity log +sidebar_position: 5 +--- + +# Laravel's events + +The package fires Laravel's own events with the type's guard name: `Illuminate\Auth\Events\Login`, `Logout`, `Failed` (wrong password), `Lockout` (rate limited), `Registered`, `Verified`, `PasswordReset`, `PasswordResetLinkSent`. + +# The package's events + +All in `Javaabu\Auth\Events`, with a public `$user`: + +| Event | When | +| --- | --- | +| `AccountLocked` | Too many failed logins locked the account | +| `PasswordUpdated` | The user changed their password | +| `ProfileUpdated` | The user updated their profile | +| `AccountDeleted` | The user deleted their account | +| `TwoFactorAuthenticationEnabled` | A secret was created (not yet confirmed) | +| `TwoFactorAuthenticationConfirmed` | The user confirmed with a code; two-factor is on | +| `TwoFactorAuthenticationDisabled` | Two-factor was turned off or reset | +| `TwoFactorAuthenticationChallenged` | A login is waiting for the code | +| `TwoFactorAuthenticationFailed` | A wrong code or recovery code | +| `RecoveryCodesGenerated` | New recovery codes | +| `RecoveryCodeReplaced` | A recovery code was used (also has `$code`) | + +laravel/passkeys fires `PasskeyRegistered`, `PasskeyVerified` and `PasskeyDeleted`. + +# Activity log + +With [spatie/laravel-activitylog](https://spatie.be/docs/laravel-activitylog) installed and `log_activity` on, these are logged with the user as causer and subject, and the guard and IP in the properties: + +| Description | Event | +| --- | --- | +| `login` | `Login` | +| `logout` | `Logout` | +| `failed_login` | `Failed`, when the account exists | +| `lockout` | `AccountLocked` | +| `password_reset` | `PasswordReset` | +| `password_reset_link_sent` | `PasswordResetLinkSent` | +| `password_changed` | `PasswordUpdated` | +| `two_factor_enabled` | `TwoFactorAuthenticationConfirmed` | +| `two_factor_disabled` | `TwoFactorAuthenticationDisabled` | +| `passkey_added` | `PasskeyRegistered` | +| `passkey_removed` | `PasskeyDeleted` | + +Without the package, nothing is logged and nothing breaks. `Javaabu\Auth\Support\ActivityLogger::isAvailable()` tells you which. diff --git a/docs/advanced-usage/overriding-controllers-and-views.md b/docs/advanced-usage/overriding-controllers-and-views.md new file mode 100644 index 0000000..ce5d7eb --- /dev/null +++ b/docs/advanced-usage/overriding-controllers-and-views.md @@ -0,0 +1,68 @@ +--- +title: Overriding controllers and views +sidebar_position: 3 +--- + +# Controllers + +`Route::authRoutes()` takes its controllers from `javaabu-auth.controllers`. Extend the package's controller and point the key at yours: + +```php +namespace App\Http\Controllers\Auth; + +use Illuminate\Contracts\Support\Responsable; +use Illuminate\Contracts\View\View; +use Illuminate\Http\Request; +use Javaabu\Auth\Http\Controllers\AuthenticatedSessionController as BaseController; + +class AuthenticatedSessionController extends BaseController +{ + public function create(Request $request): View|Responsable + { + return $this->render($request, 'login', [ + 'can_reset_password' => true, + 'can_register' => false, + 'can_use_passkeys' => true, + 'announcement' => __('The portal is closed on Friday.'), + ]); + } +} +``` + +```php +'controllers' => [ + 'login' => App\Http\Controllers\Auth\AuthenticatedSessionController::class, + // … +], +``` + +Keys: `login`, `register`, `password_reset_link`, `new_password`, `email_verification_prompt`, `verify_email`, `email_verification_notification`, `confirm_password`, `account_status`, `required_password`, `two_factor_challenge`, `two_factor`, `passkey_login`, `passkey_confirmation`, `passkeys`, `profile`, `security`, `password`, `sessions`, `api_tokens`, `api_token_login`, `user_accounts`. + +Inside a controller, `$this->userType($request)` is the route's type, `$this->user($request)` the signed-in user on its guard, and `$this->render($request, $page, $data, $layout)` renders with the type's view stack. + +# Blade views + +```bash +php artisan vendor:publish --tag=auth-views +``` + +Edit the copies in `resources/views/vendor/auth`. Keep logical utilities (`ms-`, `text-start`) so the pages still work right to left. + +# React pages + +```bash +php artisan manifest:override auth js/Pages/Login.tsx +``` + +The copy in `resources/vendor/auth/js/Pages/Login.tsx` (`manifest.overrides_path`) wins over the package's. Restart `npm run dev` after adding it. Import the package's pieces from `@javaabu/auth`. + +# Per user type + +A type can use another stack or layouts: + +```php +'admin' => [ + 'views' => 'blade', + 'layouts' => ['auth' => 'admin.layouts.guest', 'account' => 'admin.layouts.app'], +], +``` diff --git a/docs/advanced-usage/translations.md b/docs/advanced-usage/translations.md new file mode 100644 index 0000000..1e75b1e --- /dev/null +++ b/docs/advanced-usage/translations.md @@ -0,0 +1,44 @@ +--- +title: Dhivehi and translations +sidebar_position: 6 +--- + +Every string the package shows (pages, validation messages, status messages, emails, permission descriptions) is an English string passed through `__()`, with Dhivehi in `lang/dv.json`. Set the app locale to `dv` and the pages, messages and emails are in Dhivehi. The package also translates the strings of Laravel's password reset and verification emails. + +To change a wording, add a line with the same English key to your app's `lang/dv.json` (or `lang/en.json`); the app's lines win over the package's. `php artisan vendor:publish --tag=auth-translations` copies the package's files to `lang/vendor/auth/` to start from; Laravel doesn't read JSON files from there, so move the lines you change into `lang/dv.json`. + +# React pages + +The React pages get their strings translated by the server, as the `strings` prop (keys in `Javaabu\Auth\Support\Strings`), so they need no translation setup of their own and change language with the app locale. + +# Right to left + +The package layouts set `dir="rtl"` for Dhivehi. The pages use logical utilities only (`ms-`, `pe-`, `text-start`), and a test rejects physical ones. Email, password, code and token inputs are `dir="ltr"`; names and passkey names are `dir="auto"`, so Thaana and Latin both read correctly. + +# Permission descriptions + +The account-management permissions are registered with English descriptions built from the type: "Unlock locked users", "Unlock locked admins". `lang/dv.json` has them for `users` and `admins`. For other types, add lines to your `lang/dv.json`: + +```json +{ + "Unlock locked vendors": "ލޮކްވެފައިވާ ވެންޑަރުންގެ އެކައުންޓް ހުޅުވުން", + "Reset two-factor authentication of vendors": "ވެންޑަރުންގެ ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން ރީސެޓްކުރުން", + "Make vendors choose a new password": "ވެންޑަރުންނަށް އައު ޕާސްވޯޑެއް ހަމަޖައްސަން މަޖުބޫރުކުރުން", + "Log out vendors from every device": "ވެންޑަރުން ހުރިހާ ޑިވައިސްއަކުން ލޮގްއައުޓް ކުރުން" +} +``` + +# Strings to review + +The Dhivehi was written for this release and should be checked by a native speaker, especially: + +| English | Dhivehi | Note | +| --- | --- | --- | +| Two-factor authentication | ޓޫ-ފެކްޓަރ އޮތެންޓިކޭޝަން | Transliterated; a Dhivehi term may read better | +| Passkey | ޕާސްކީ | Transliterated | +| Recovery code | ރިކަވަރީ ކޯޑު | Transliterated | +| Approved / Pending / Banned | ހުއްދަ ދީފައި / މަޑުކުރަމުން / މަނާކޮށްފައި | Account statuses | +| Remember me | ހަނދާންބަހައްޓާ | | +| Last active :time / Last used :time | :time ފަހުން ބޭނުންކުރި | Same wording for both; `:time` is English ("2 hours ago") unless Carbon's locale is Dhivehi | +| Passkey not recognized. … | މި ޕާސްކީ ނުވަޒަނެއް. … | | +| Your account is not active | ތިބާގެ އެކައުންޓް ހަރަކާތްތެރިއެއް ނޫން | | diff --git a/docs/ai-assistants.md b/docs/ai-assistants.md new file mode 100644 index 0000000..7d5f666 --- /dev/null +++ b/docs/ai-assistants.md @@ -0,0 +1,32 @@ +--- +title: AI assistants +sidebar_position: 1.35 +--- + +The docs are plain Markdown, written for people and AI coding assistants alike. The package ships them, so they always match the version you have installed. + +| File | What | +| --- | --- | +| `vendor/javaabu/auth/llms.txt` | The rules for using the package, and every page | +| `vendor/javaabu/auth/docs/` | The pages | +| `vendor/javaabu/auth/resources/boost/` | The Laravel Boost skill and guideline | + +# Laravel Boost + +[Laravel Boost](https://github.com/laravel/boost) finds the skill and guideline by itself in Composer packages. Run this after installing or updating the package: + +```bash +php artisan boost:update +``` + +Choose `javaabu/auth` if Boost asks which third-party packages to use. + +# Other assistants + +Point your assistant at the docs from the project's instructions file (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, `.github/copilot-instructions.md`): + +```md +## Authentication + +This app uses javaabu/auth. Read vendor/javaabu/auth/llms.txt before changing login, registration, user types, two-factor authentication, passkeys or API tokens. +``` diff --git a/docs/basic-usage/_category_.json b/docs/basic-usage/_category_.json index 4fb5b33..5a5a423 100644 --- a/docs/basic-usage/_category_.json +++ b/docs/basic-usage/_category_.json @@ -6,6 +6,6 @@ "link": { "type": "generated-index", "slug": "/auth/_categories/basic-usage", - "description": "Everything you need to know to get started!" + "description": "User types, routes, every auth flow and the pages." } } diff --git a/docs/basic-usage/api-authentication.md b/docs/basic-usage/api-authentication.md new file mode 100644 index 0000000..48e24d8 --- /dev/null +++ b/docs/basic-usage/api-authentication.md @@ -0,0 +1,110 @@ +--- +title: API authentication +sidebar_position: 12 +--- + +Choose Sanctum, Passport or both: + +```bash +composer require laravel/sanctum # and/or laravel/passport +php artisan auth:install --api=sanctum +``` + +`auth:install` sets `AUTH_API` in `.env` (`javaabu-auth.api`: `none`, `sanctum`, `passport` or `both`), publishes the driver's migrations (and makes Passport's keys), and prints the guard setup for each user type. Each user model uses one driver: the `HasApiTokens` trait it has decides which. + +# Sanctum + +```php +use Javaabu\Auth\Models\User as AuthUser; +use Laravel\Sanctum\HasApiTokens; + +class User extends AuthUser +{ + use HasApiTokens; +} +``` + +## Tokens for mobile apps + +`Route::authApiRoutes()` adds a login endpoint per user type that returns a token: + +```php +// routes/api.php (already has the `api` middleware and prefix) +Route::authApiRoutes('user', ['prefix' => '', 'middleware' => []]); +Route::authApiRoutes('admin', ['prefix' => 'admin', 'middleware' => []]); + +Route::middleware('auth:sanctum')->get('/user', fn (Request $request) => $request->user()); +``` + +```http +POST /api/tokens +Accept: application/json + +{"email": "ahmed@example.com", "password": "…", "device_name": "ޢަލީގެ ފޯނު"} +``` + +```json +{"token": "1|1aB…", "token_type": "Bearer"} +``` + +It checks the password, the account (pending, banned and locked accounts get a validation error) and, when the user has two-factor authentication on, a `code` or `recovery_code` in the same request. `DELETE /api/tokens/current` with the token revokes it. Names: `api.tokens.store`, `api.tokens.destroy` (`admin.api.tokens.store`, …). + +| Option | Default | | +| --- | --- | --- | +| `prefix` | `api` | | +| `middleware` | `['api']` | | +| `auth_middleware` | `['auth:sanctum']` | For the revoke route | + +## SPA authentication + +Sanctum's cookie authentication works with the package's session logins. List every user type's session guard in `config/sanctum.php`: `'guard' => ['web', 'web_admin']`. + +# Passport + +```php +use Javaabu\Auth\Models\User as AuthUser; +use Laravel\Passport\Contracts\OAuthenticatable; +use Laravel\Passport\HasApiTokens; + +class Admin extends AuthUser implements OAuthenticatable +{ + use HasApiTokens; +} +``` + +Passport 13 handles several user providers itself. Add a passport guard and a personal access client per type: + +```php +// config/auth.php +'guards' => [ + 'api' => ['driver' => 'passport', 'provider' => 'users'], + 'api_admin' => ['driver' => 'passport', 'provider' => 'admins'], +], +``` + +```bash +php artisan passport:client --personal --provider=users +php artisan passport:client --personal --provider=admins +``` + +A user's token only passes the guard of its own provider. OAuth flows (authorization code with PKCE, client credentials, device code) go through Passport's own `/oauth/*` routes. `Route::authApiRoutes()` is for Sanctum; with Passport use the OAuth flows or personal access tokens. + +# Personal access tokens on the security page + +Turn on the `api_tokens` feature for the type. Users create a named token (shown once) and delete their tokens on the security page, with whichever driver their model uses. In code: + +```php +use Javaabu\Auth\Support\ApiTokens; + +$plain_text = ApiTokens::create($user, 'CI', ['read']); // Sanctum abilities or Passport scopes +ApiTokens::list($user); +ApiTokens::revoke($user, $token_id); +ApiTokens::driverFor($user); // 'sanctum', 'passport' or null +``` + +```php +'api_tokens' => [ + 'abilities' => ['*'], // default abilities (Sanctum) or scopes (Passport) + 'expiration' => null, // minutes, Sanctum only (Passport uses its own expiry) +], +``` diff --git a/docs/basic-usage/email-verification.md b/docs/basic-usage/email-verification.md new file mode 100644 index 0000000..c1d5d26 --- /dev/null +++ b/docs/basic-usage/email-verification.md @@ -0,0 +1,15 @@ +--- +title: Email verification +sidebar_position: 7 +--- + +For types with the `email_verification` feature and models that implement `MustVerifyEmail` (the package's `User` does): + +- Registering sends a verification email linking to the type's signed `verification.verify` route (`Javaabu\Auth\Notifications\VerifyEmail`). +- `auth.type:{type}` sends unverified users to `verify-email` (`verification.notice`), which can resend the email (`verification.send`, rate limited). +- The link must be opened by the signed-in user it was sent to; it marks the email verified, fires Laravel's `Verified` event and redirects to the intended URL or home. +- Changing the email on the profile page marks it unverified and sends a new link. + +Turn it off for a type with `'features' => ['email_verification' => false]`; users of that type are never asked. + +Laravel's `verified` middleware also works, given the type's notice route: `verified:admin.verification.notice`. `auth.verified:admin` does the same. diff --git a/docs/basic-usage/logging-in.md b/docs/basic-usage/logging-in.md new file mode 100644 index 0000000..7f8d0d6 --- /dev/null +++ b/docs/basic-usage/logging-in.md @@ -0,0 +1,56 @@ +--- +title: Logging in and registering +sidebar_position: 5 +--- + +# Logging in + +`POST login` takes the type's username (`email` by default), `password` and `remember`. It: + +1. Refuses when the username and IP address made `rate_limits.login` (5) failed attempts in the last minute (`429`, the `Lockout` event). +2. Refuses a locked account (see below). +3. Checks the password against the type's user provider, within a fixed time so the answer doesn't reveal whether the account exists. +4. Fires Laravel's `Failed` event and counts the failure on the account when the password is wrong. +5. Asks for a two-factor code when the user has two-factor authentication on (see [Two-factor authentication](two-factor-authentication.md)). +6. Logs in on the type's guard, regenerates the session, clears failed attempts and records `last_login_at`. +7. Redirects to the intended URL or the type's home. JSON requests get `{"two_factor": false, "redirect": "…"}`. + +A user of one type can't log in on another type's login page, even with the same email and password. + +# Logging out + +`POST logout` logs out of that type's guard only. When another type is still signed in on the same browser (a public user who is also an admin), the session stays and only its id is regenerated. + +# Locking accounts + +With `max_login_attempts` set (5 by default, `AUTH_MAX_LOGIN_ATTEMPTS`) and the `LocksAfterFailedLogins` trait, the account locks after that many failed logins in a row, whatever the IP address. A locked account can't log in until the password is reset, or an admin unlocks it (see [Managing accounts](managing-accounts.md)). The `AccountLocked` event fires when it locks. + +```php +'max_login_attempts' => null, // per type: never lock +``` + +# Registering + +`POST register` takes `name`, the username, `password` and `password_confirmation`. It validates and creates the user with the `CreatesNewUsers` action, fires Laravel's `Registered` event (which sends the verification email for `MustVerifyEmail` models) and logs the user in. Emails are lowercased and must be unique among the type's users. + +Change the fields and rules by binding your own action; see [Actions](../advanced-usage/actions.md). + +# Rate limits + +| Limiter | Default per minute | Key | +| --- | --- | --- | +| login (in `AttemptLogin`) | 5 | type, username and IP | +| two-factor (in the challenge) | 5 | type and user | +| `auth.passkeys` | 10 | credential or session, and IP | +| `auth.password-reset` | 6 | IP | +| `auth.verification` | 6 | IP | +| `auth.api-tokens` | 6 | IP | + +Change the numbers in `javaabu-auth.rate_limits`, or redefine a named limiter in your app: + +```php +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\RateLimiter; + +RateLimiter::for('auth.password-reset', fn ($request) => Limit::perHour(10)->by($request->ip())); +``` diff --git a/docs/basic-usage/managing-accounts.md b/docs/basic-usage/managing-accounts.md new file mode 100644 index 0000000..279dbaa --- /dev/null +++ b/docs/basic-usage/managing-accounts.md @@ -0,0 +1,77 @@ +--- +title: Managing accounts +sidebar_position: 14 +--- + +Admins help users who are stuck: unlock a locked account, reset two-factor authentication for someone who lost their phone, make someone choose a new password, or log someone out of every device. `Route::userAccountActions()` adds these for one user type's accounts. Put them behind the admins' middleware: + +```php +Route::userAccountActions('user', [ + 'prefix' => 'admin/users', + 'middleware' => ['web', 'auth.type:admin'], +]); +``` + +| Method | URL | Name | Ability | Permission | +| --- | --- | --- | --- | --- | +| POST | `{prefix}/{account}/unlock` | `users.account.unlock` | `auth.unlock` | `unlock_users` | +| DELETE | `{prefix}/{account}/two-factor` | `users.account.reset-two-factor` | `auth.reset-two-factor` | `reset_two_factor_users` | +| POST | `{prefix}/{account}/require-password-update` | `users.account.require-password-update` | `auth.require-password-update` | `require_password_update_users` | +| POST | `{prefix}/{account}/log-out` | `users.account.log-out` | `auth.log-out` | `log_out_users` | + +`{account}` is the user's route key. The name prefix defaults to the plural of the type (`users.account`); set `name` to change it. Each action answers JSON (`{"message": …}`) or redirects back with a status. + +Logging out everywhere replaces the remember token, deletes the user's sessions (with the `multi_auth_database` driver) and their API tokens. + +```blade + + {{ __('Unlock') }} + +``` + +# Permissions + +The package registers these four permissions for every user type (`unlock_admins`, …) through javaabu/permissions, in the type's plural group (`users`, `admins`), so they appear next to your own user permissions on the role pages. Run `php artisan permissions:sync` after installing. Limit them to some types, or turn them off: + +```php +'managed_user_types' => ['user'], +'register_permissions' => false, +``` + +# The policy + +The abilities are defined on the gate and point at `Javaabu\Auth\Policies\UserAccountPolicy`, which checks the permission of the managed type. They take the managed type and the account, so your own user policy doesn't get in the way: + +```php +use Illuminate\Support\Facades\Gate; +use Javaabu\Auth\UserTypes; + +Gate::allows('auth.unlock', [UserTypes::get('user'), $user]); +@can('auth.reset-two-factor', [UserTypes::get('user'), $user]) … @endcan +``` + +Replace it with your own class in config, extending the package's: + +```php +// config/javaabu-auth.php +'policy' => App\Policies\UserAccountPolicy::class, +``` + +```php +namespace App\Policies; + +use Illuminate\Database\Eloquent\Model; +use Javaabu\Auth\Policies\UserAccountPolicy as BasePolicy; +use Javaabu\Auth\UserType; + +class UserAccountPolicy extends BasePolicy +{ + public function logOut(Model $user, UserType $type, ?Model $account = null): bool + { + // Admins can't log themselves out from here + return parent::logOut($user, $type, $account) && ! $account?->is($user); + } +} +``` + +Or redefine one ability in a service provider: `Gate::define('auth.unlock', fn ($user, $type, $account) => $user->isSuperAdmin())`. The package doesn't replace abilities that are already defined. diff --git a/docs/basic-usage/middleware.md b/docs/basic-usage/middleware.md new file mode 100644 index 0000000..0a4a401 --- /dev/null +++ b/docs/basic-usage/middleware.md @@ -0,0 +1,29 @@ +--- +title: Middleware +sidebar_position: 3 +--- + +| Alias | Class | What | +| --- | --- | --- | +| `auth.type:{type}` | `Http\Middleware\Authenticate` | Signed in on the type's guard, then every check below. `auth.type:{type},basic` only checks the guard | +| `auth.guest:{type}` | `Http\Middleware\RedirectIfAuthenticated` | Sends signed-in users of the type to its home | +| `auth.active:{type}` | `Http\Middleware\EnsureAccountIsActive` | Pending, banned and locked accounts go to `account.inactive` (403 for JSON) | +| `auth.verified:{type}` | `Http\Middleware\EnsureEmailIsVerified` | Unverified emails go to `verification.notice`, when the type has `email_verification` | +| `auth.password-updated:{type}` | `Http\Middleware\EnsurePasswordIsUpdated` | Users made to change their password go to `password.required` | +| `auth.two-factor:{type}` | `Http\Middleware\EnsureTwoFactorIsEnabled` | For types with `two_factor` `required`: users without it go to the security page to set it up | +| `auth.password.confirm:{type}` | `Http\Middleware\RequirePassword` | Asks for the password again, every `javaabu-auth.password_timeout` seconds. An optional second parameter sets the timeout | +| `auth.user-type:{type}` | `Http\Middleware\SetUserType` | Only tells the package which type the route belongs to | + +`auth.type:admin` is all you usually need on app routes: + +```php +Route::middleware('auth.type:admin')->group(function () { + // signed in as an admin, active, verified, password changed if required, 2FA set up if required +}); + +Route::middleware(['auth.type:admin', 'auth.password.confirm:admin'])->group(function () { + Route::delete('/admin/everything', DestroyEverythingController::class); +}); +``` + +Password confirmation is kept per user type: confirming as a public user doesn't unlock admin pages in the same browser. diff --git a/docs/basic-usage/pages.md b/docs/basic-usage/pages.md new file mode 100644 index 0000000..6f8ee1f --- /dev/null +++ b/docs/basic-usage/pages.md @@ -0,0 +1,114 @@ +--- +title: Pages (Blade and React) +sidebar_position: 13 +--- + +Every page ships twice: Blade with [javaabu/forms](https://github.com/Javaabu/forms) components, and Inertia React with [@javaabu/forms](https://github.com/Javaabu/react-forms) and [@javaabu/onu](https://www.npmjs.com/package/@javaabu/onu). Both get the same data and behave the same. + +```php +'views' => env('AUTH_VIEWS', 'blade'), // or 'inertia'; a user type can choose its own with 'views' +``` + +| Page | Blade view | Inertia page | +| --- | --- | --- | +| Log in | `auth::login` | `auth::Login` | +| Register | `auth::register` | `auth::Register` | +| Forgot password | `auth::forgot-password` | `auth::ForgotPassword` | +| Reset password | `auth::reset-password` | `auth::ResetPassword` | +| Verify email | `auth::verify-email` | `auth::VerifyEmail` | +| Confirm password | `auth::confirm-password` | `auth::ConfirmPassword` | +| Two-factor challenge | `auth::two-factor-challenge` | `auth::TwoFactorChallenge` | +| Account not active | `auth::account-inactive` | `auth::AccountInactive` | +| Required new password | `auth::update-required-password` | `auth::UpdateRequiredPassword` | +| Profile | `auth::settings.profile` | `auth::Settings/Profile` | +| Security | `auth::settings.security` | `auth::Settings/Security` | + +# Props + +Every page gets: + +| Prop | What | +| --- | --- | +| `auth_type` | `{name, features, two_factor, username, login_methods}` | +| `urls` | The type's route URLs by name without the prefix: `urls['login.store']`, `urls['profile.edit']`, `urls.home` | +| `status` | The flashed status message, if any | +| `strings` | React only: the translated strings, keyed as in `Javaabu\Auth\Support\Strings` | + +and its own: `can_reset_password`, `can_register` and `can_use_passkeys` (login), `token` and `email` (reset), `email` (verify), `message` (inactive), `profile`, `must_verify_email` and `can_delete_account` (profile), `two_factor`, `passkeys`, `sessions`, `api_tokens` and `plain_text_token` (security; a section is `null` when the type doesn't have it). The TypeScript types are in `@javaabu/auth` (`LoginPageProps`, `SecurityPageProps`, …). + +# Blade + +The guest pages extend `javaabu-auth.layouts.auth`, the profile and security pages `javaabu-auth.layouts.account`, filling `javaabu-auth.layout_section`. A user type can set its own `layouts`. The pages load their scripts through javaabu/manifest (`auth::passkeys`, `auth::auth`), so the layout needs `@manifestStyles` and `@manifestScripts`, or manifest's auto-injection. + +```blade +{{-- resources/views/layouts/guest.blade.php --}} + + + + + @manifestStyles + + + @yield('content') + @manifestScripts + + +``` + +Override a view with `php artisan vendor:publish --tag=auth-views`, then edit `resources/views/vendor/auth/…`. + +# Inertia React + +Install the Inertia adapter and the npm packages: + +```bash +composer require inertiajs/inertia-laravel +npm install @javaabu/forms@github:Javaabu/react-forms#next-gen @javaabu/onu @inertiajs/react react react-dom +php artisan manifest:check +``` + +Resolve package pages with javaabu/manifest, and submit forms as Inertia visits: + +```tsx +// resources/js/app.tsx +import { createInertiaApp } from "@inertiajs/react"; +import { FormsProvider } from "@javaabu/forms"; +import { inertia } from "@javaabu/forms/inertia"; +import { resolvePage } from "@javaabu/manifest/inertia"; +import { createRoot } from "react-dom/client"; + +createInertiaApp({ + resolve: (name) => resolvePage(name, import.meta.glob("./pages/**/*.tsx")), + setup({ el, App, props }) { + createRoot(el).render( + + + , + ); + }, +}); +``` + +The pages render their content only; wrap them in your layout with Inertia's persistent layouts, for example in `resolve`: + +```tsx +resolve: async (name) => { + const page = await resolvePage(name, import.meta.glob("./pages/**/*.tsx")); + + if (name.startsWith("auth::Settings/")) { + page.default.layout ??= (children) => {children}; + } + + return page; +}, +``` + +Override a page with `php artisan manifest:override auth js/Pages/Login.tsx`. The components the pages use are exported from `@javaabu/auth` for building your own: `AuthCard`, `AccountLayout`, `StatusAlert`, `PasskeyButton`, `TwoFactorSettings`, `PasskeysSettings`, `SessionsSettings`, `ApiTokensSettings`, `t()`, and the WebAuthn functions. + +# Forms and stencil + +The auth forms have five fields at most, so they're written directly with the form components rather than defined with javaabu/stencil. + +# Dhivehi and right to left + +Every string is translated (`lang/dv.json`), the pages use logical utilities only (`ms-`, `text-start`), email, password and code inputs are `dir="ltr"`, and name inputs are `dir="auto"`. See [Dhivehi and translations](../advanced-usage/translations.md). diff --git a/docs/basic-usage/passkeys.md b/docs/basic-usage/passkeys.md new file mode 100644 index 0000000..85a42ae --- /dev/null +++ b/docs/basic-usage/passkeys.md @@ -0,0 +1,74 @@ +--- +title: Passkeys +sidebar_position: 10 +--- + +Passkeys let users log in with their fingerprint, face or screen lock (WebAuthn), and confirm their password the same way. The package builds on [laravel/passkeys](https://github.com/laravel/passkeys-server), Laravel's own WebAuthn server, which Fortify and the starter kits use. + +# Setup + +1. Turn on the `passkeys` feature for the type: `'features' => ['passkeys' => true]`. +2. Give the model `Concerns\HasPasskeys` and implement `Laravel\Passkeys\Contracts\PasskeyUser` (the package's `User` does). +3. Run the migrations: the package's `passkeys` table has `user_type` and `user_id`, so every user type shares it. Don't also publish laravel/passkeys' own migration. +4. Passkeys need HTTPS (or `localhost`). laravel/passkeys reads its settings from `config/passkeys.php`: + +```dotenv +APP_URL=https://example.mv +PASSKEYS_USER_HANDLE_SECRET= # defaults to APP_KEY +``` + +```php +// config/passkeys.php (php artisan vendor:publish --tag=passkeys-config) +'relying_party_id' => 'example.mv', +'allowed_origins' => ['https://example.mv', 'https://admin.example.mv'], +``` + +The package registers the passkey routes per user type, so laravel/passkeys' own routes are turned off (`Passkeys::ignoreRoutes()`) and its `guard`, `middleware` and `redirect` settings aren't used. + +# What users get + +- **Login page:** "Log in with a passkey" (`passkey.login-options`, `passkey.login`). The browser offers the passkeys saved for the site; no username needed. +- **Security page:** the user's passkeys, with the authenticator's name (iCloud Keychain, Google Password Manager, …), when it was added and last used, a remove button, and "Add a passkey" with a name. +- **Confirm password page:** "Confirm with a passkey" when the user has one. + +A passkey login skips the two-factor code. The account checks of `auth.type` (status, lockout, verified email) still apply after it. A passkey belongs to one user of one type: an admin's passkey can't log in on the public login page, and an admin and a user with the same id keep separate passkeys. + +# Blocking logins + +```php +use Laravel\Passkeys\Passkeys; +use Illuminate\Validation\ValidationException; + +Passkeys::authorizeLoginUsing(function ($request, $user, $passkey) { + if ($user->isBanned()) { + throw ValidationException::withMessages(['credential' => [__('Your account has been banned.')]]); + } + + return true; +}); +``` + +# The browser side + +The Blade pages load `auth::passkeys`, which wires up the buttons. In React, use `PasskeyButton`, or the functions it uses: + +```tsx +import { PasskeyButton, registerPasskey, verifyPasskey } from "@javaabu/auth"; + +; + +const result = await registerPasskey({ optionsUrl, url, data: { name: "ލެޕްޓޮޕް" } }); +``` + +The endpoints speak the same JSON as `@laravel/passkeys`, so that client works too. + +# Events + +laravel/passkeys fires `PasskeyRegistered`, `PasskeyVerified` and `PasskeyDeleted` (`Laravel\Passkeys\Events`). With activitylog installed, adding and removing are logged. diff --git a/docs/basic-usage/password-confirmation.md b/docs/basic-usage/password-confirmation.md new file mode 100644 index 0000000..56f0417 --- /dev/null +++ b/docs/basic-usage/password-confirmation.md @@ -0,0 +1,27 @@ +--- +title: Password confirmation +sidebar_position: 8 +--- + +`auth.password.confirm:{type}` asks signed-in users for their password before sensitive pages, then lets them through for `javaabu-auth.password_timeout` seconds (3 hours by default, `AUTH_PASSWORD_TIMEOUT`). + +The package puts it on the security page and on turning two-factor authentication on or off, managing passkeys and logging out other sessions. Use it on your own routes: + +```php +Route::middleware(['auth.type:admin', 'auth.password.confirm:admin']) + ->delete('/admin/users/{user}', [UsersController::class, 'destroy']); + +Route::middleware('auth.password.confirm:admin,600') // ten minutes + ->get('/admin/billing', BillingController::class); +``` + +Users with a passkey can confirm with it instead (`passkey.confirm`). JSON requests get `423` until confirmed. + +Each user type keeps its own confirmation in the session (`auth.password_confirmed_at.{type}`), unlike Laravel's `password.confirm`, so an admin who is also signed in as a public user confirms each separately. + +```php +use Javaabu\Auth\Http\Middleware\RequirePassword; + +RequirePassword::isConfirmed($request, UserTypes::get('admin')); +RequirePassword::markConfirmed($request, UserTypes::get('admin')); +``` diff --git a/docs/basic-usage/password-reset.md b/docs/basic-usage/password-reset.md new file mode 100644 index 0000000..cdd9091 --- /dev/null +++ b/docs/basic-usage/password-reset.md @@ -0,0 +1,22 @@ +--- +title: Password reset +sidebar_position: 6 +--- + +With the `password_reset` feature, the login page links to `password.request`. + +1. The user enters their email on `forgot-password`. The type's password broker emails a link to `reset-password/{token}?email=…` (the type's own `password.reset` route). The page answers the same whether or not the account exists. +2. On the reset page they choose a new password. The `ResetsUserPasswords` action validates it (`Password::default()`, confirmed) and saves it; the remember token is replaced, Laravel's `PasswordReset` event fires, a locked account is unlocked and a required password change is cleared. +3. They're sent to the login page with a status message. + +Each type uses its own broker (`passwords`), so a token from the users' broker doesn't work on the admins' reset page. + +The email is Laravel's, with the link changed to the type's route: `Javaabu\Auth\Notifications\ResetPassword`. `ResetPassword::createUrlUsing()` and `toMailUsing()` still work. Its strings are translated in `lang/dv.json`. + +Set password rules for the whole app with Laravel's `Password::defaults()`: + +```php +use Illuminate\Validation\Rules\Password; + +Password::defaults(fn () => Password::min(10)->mixedCase()->uncompromised()); +``` diff --git a/docs/basic-usage/profile-and-security.md b/docs/basic-usage/profile-and-security.md new file mode 100644 index 0000000..9d1da6f --- /dev/null +++ b/docs/basic-usage/profile-and-security.md @@ -0,0 +1,25 @@ +--- +title: Profile and security pages +sidebar_position: 11 +--- + +Signed-in users manage their account on two pages, both behind `auth.type:{type}`. + +# Profile (`profile.edit`) + +- **Profile information:** name and email (`profile.update`), through the `UpdatesUserProfileInformation` action. Changing the email marks it unverified and sends a new verification link, for types with `email_verification`. +- **Delete account**, with the `delete_account` feature: asks for the password, logs out, deletes the user through the `DeletesUsers` action (soft deletes with the package's model; API tokens are deleted), and goes to the login page. + +# Security (`security.edit`) + +Behind password confirmation. + +- **Update password** (`user-password.update`): current password, new password and confirmation, through `UpdatesUserPasswords`. Fires `PasswordUpdated`. +- **Two-factor authentication**, unless the type has it `off`. See [Two-factor authentication](two-factor-authentication.md). +- **Passkeys**, with the `passkeys` feature. See [Passkeys](passkeys.md). +- **Browser sessions**, with the `sessions` feature and the `multi_auth_database` session driver: the user's browsers (platform, browser, IP, last active, "This device"), and "Log out other browser sessions" (`sessions.destroy`), which deletes their other sessions. +- **API tokens**, with the `api_tokens` feature and Sanctum or Passport on the model. See [API authentication](api-authentication.md). + +# Required password change + +When `require_password_update` is set (by an admin, see [Managing accounts](managing-accounts.md), or with `$user->requirePasswordUpdate()`), `auth.type` sends the user to `update-password` until they choose a new password. diff --git a/docs/basic-usage/routes.md b/docs/basic-usage/routes.md new file mode 100644 index 0000000..6242da2 --- /dev/null +++ b/docs/basic-usage/routes.md @@ -0,0 +1,79 @@ +--- +title: Routes +sidebar_position: 2 +--- + +Nothing is registered automatically. `Route::authRoutes()` registers one user type's routes where you call it, so you choose the prefix, the middleware and the domain: + +```php +// routes/web.php +Route::authRoutes('user'); +Route::authRoutes('admin', [ + 'prefix' => 'admin', + 'middleware' => ['web'], + 'domain' => null, + 'account_prefix' => 'settings', + 'except' => ['passkeys'], +]); +``` + +| Option | Default | What | +| --- | --- | --- | +| `prefix` | `''` | URL prefix of every route | +| `middleware` | `['web']` | Middleware of every route | +| `domain` | none | Domain of every route | +| `account_prefix` | `settings` | Prefix of the profile and security pages | +| `except` | `[]` | Features to leave out even when the type has them: `registration`, `password_reset`, `email_verification`, `password_confirmation`, `passkeys`, `two_factor`, `profile`, `delete_account`, `security`, `sessions`, `api_tokens`, `login_method:{key}` | + +# The routes + +Names are prefixed with the type's `route_name` (`admin.login`). Routes only exist for the type's features. + +| Method | URL | Name | | +| --- | --- | --- | --- | +| GET, POST | `login` | `login`, `login.store` | Log in | +| POST | `logout` | `logout` | Log out of this type only | +| GET, POST | `register` | `register`, `register.store` | `registration` | +| GET, POST | `forgot-password` | `password.request`, `password.email` | `password_reset` | +| GET | `reset-password/{token}` | `password.reset` | `password_reset` | +| POST | `reset-password` | `password.update` | `password_reset` | +| GET, POST | `two-factor-challenge` | `two-factor.login`, `two-factor.login.store` | two-factor not `off` | +| GET, POST | `passkeys/login/options`, `passkeys/login` | `passkey.login-options`, `passkey.login` | `passkeys` | +| GET | `verify-email` | `verification.notice` | `email_verification` | +| GET | `verify-email/{id}/{hash}` | `verification.verify` | `email_verification`, signed | +| POST | `email/verification-notification` | `verification.send` | `email_verification` | +| GET, POST | `confirm-password` | `password.confirm`, `password.confirm.store` | `password_confirmation` | +| GET, POST | `passkeys/confirm/options`, `passkeys/confirm` | `passkey.confirm-options`, `passkey.confirm` | `passkeys` and `password_confirmation` | +| GET | `account/inactive` | `account.inactive` | Why a pending, banned or locked account can't continue | +| GET, PUT | `update-password` | `password.required`, `password.required.update` | Choosing a new password when made to | +| GET, PATCH | `settings/profile` | `profile.edit`, `profile.update` | `profile` | +| DELETE | `settings/profile` | `profile.destroy` | `delete_account` | +| GET | `settings/security` | `security.edit` | Password confirmation first | +| PUT | `settings/password` | `user-password.update` | | +| POST, DELETE | `settings/two-factor` | `two-factor.enable`, `two-factor.disable` | two-factor not `off` | +| POST | `settings/two-factor/confirm` | `two-factor.confirm` | | +| GET | `settings/two-factor/qr-code`, `…/secret-key`, `…/recovery-codes` | `two-factor.qr-code`, `two-factor.secret-key`, `two-factor.recovery-codes` | JSON | +| POST | `settings/two-factor/recovery-codes` | `two-factor.regenerate-recovery-codes` | | +| GET, POST | `settings/passkeys/options`, `settings/passkeys` | `passkey.registration-options`, `passkey.store` | `passkeys` | +| DELETE | `settings/passkeys/{passkey_id}` | `passkey.destroy` | `passkeys` | +| DELETE | `settings/sessions` | `sessions.destroy` | `sessions` | +| POST, DELETE | `settings/api-tokens`, `settings/api-tokens/{token_id}` | `api-tokens.store`, `api-tokens.destroy` | `api_tokens` | + +Guest routes have `auth.guest:{type}`, which sends signed-in users of that type to its home. Signed-in routes have `auth.type:{type}`. The security page and the two-factor, passkey and session actions are behind `auth.password.confirm:{type}`. + +# Your own pages + +Protect the app's pages with `auth.type`: + +```php +Route::middleware('auth.type:admin')->prefix('admin')->group(function () { + Route::get('/', AdminDashboardController::class)->name('admin.home'); +}); +``` + +See [Middleware](middleware.md). Laravel's `auth:web_admin` works too: guests on it are sent to `admin.login`, not `login`. + +# Other route macros + +- `Route::authApiRoutes('user', ['prefix' => 'api'])`: Sanctum token endpoints, see [API authentication](api-authentication.md). +- `Route::userAccountActions('user', ['prefix' => 'admin/users', 'middleware' => ['web', 'auth.type:admin']])`: what admins do to other accounts, see [Managing accounts](managing-accounts.md). diff --git a/docs/basic-usage/two-factor-authentication.md b/docs/basic-usage/two-factor-authentication.md new file mode 100644 index 0000000..dfcc856 --- /dev/null +++ b/docs/basic-usage/two-factor-authentication.md @@ -0,0 +1,75 @@ +--- +title: Two-factor authentication +sidebar_position: 9 +--- + +TOTP codes from an authenticator app (Google Authenticator, 1Password, Microsoft Authenticator, …), with recovery codes. Each user type chooses: + +```php +'two_factor' => 'off', // users can't turn it on, and logins never ask for a code +'two_factor' => 'optional', // the default: users turn it on from the security page +'two_factor' => 'required', // users must set it up before they can use the app, and can't turn it off +``` + +The model needs `Concerns\TwoFactorAuthenticatable` and its contract, and the columns from `UserSchema::twoFactorColumns()`. The columns and their encryption are the same as laravel/fortify's, so users moving from a Fortify app keep their two-factor setup. + +# Turning it on + +On the security page (behind password confirmation): + +1. **Turn on** (`two-factor.enable`) creates a secret and 8 recovery codes. The page shows the QR code and the setup key. +2. The user enters a code from their app (`two-factor.confirm`). Only then is it on (`two_factor_confirmed_at`), and the page shows the recovery codes once. +3. **Regenerate recovery codes** (`two-factor.regenerate-recovery-codes`) replaces them and shows the new ones once. +4. **Turn off** (`two-factor.disable`) removes it, unless the type requires it. + +For your own UI, the JSON endpoints `two-factor.qr-code` (`{svg, url}`), `two-factor.secret-key` and `two-factor.recovery-codes` return the pieces. + +# Logging in + +After the right password, a user with two-factor authentication on goes to `two-factor-challenge` instead of being logged in. They enter a code, or one of their recovery codes (which is then replaced). Each code is accepted once; 5 wrong codes in a minute are refused for the rest of the minute. A challenge started on one type's login page can't be finished on another's. + +Passkey logins skip the challenge: a passkey is already two factors. + +# Required + +With `'two_factor' => 'required'`, `auth.type:{type}` sends users who haven't set it up to the security page, and only lets them use the security page, the two-factor routes, password confirmation and logout until they have. + +# Settings + +```php +'two_factor' => [ + 'issuer' => null, // shown in the app; defaults to config('app.name') + 'window' => 1, // 30 second periods either side of now that are accepted + 'secret_length' => 16, + 'recovery_codes' => 8, +], +``` + +The account name in the app is the user's email; override `twoFactorLabel()` on the model to change it. + +# In code + +```php +use Javaabu\Auth\Actions\TwoFactor\ConfirmTwoFactorAuthentication; +use Javaabu\Auth\Actions\TwoFactor\DisableTwoFactorAuthentication; +use Javaabu\Auth\Actions\TwoFactor\EnableTwoFactorAuthentication; +use Javaabu\Auth\Actions\TwoFactor\GenerateNewRecoveryCodes; +use Javaabu\Auth\TwoFactor\TwoFactorProvider; + +app(EnableTwoFactorAuthentication::class)($user); +app(ConfirmTwoFactorAuthentication::class)($user, '123456'); +app(DisableTwoFactorAuthentication::class)($user); +app(GenerateNewRecoveryCodes::class)($user); + +$user->hasEnabledTwoFactorAuthentication(); +$user->hasPendingTwoFactorAuthentication(); +$user->recoveryCodes(); +$user->twoFactorQrCodeSvg(); + +app(TwoFactorProvider::class)->verify($user->twoFactorSecret(), $code); +app(TwoFactorProvider::class)->currentCode($secret); // in tests +``` + +Events: `TwoFactorAuthenticationEnabled`, `TwoFactorAuthenticationConfirmed`, `TwoFactorAuthenticationDisabled`, `TwoFactorAuthenticationChallenged`, `TwoFactorAuthenticationFailed`, `RecoveryCodesGenerated`, `RecoveryCodeReplaced`, all in `Javaabu\Auth\Events` with a `$user`. + +Admins with the permission can reset a user's two-factor authentication: see [Managing accounts](managing-accounts.md). diff --git a/docs/basic-usage/unique-gmail-rule.md b/docs/basic-usage/unique-gmail-rule.md index b7596d8..26e6ab8 100644 --- a/docs/basic-usage/unique-gmail-rule.md +++ b/docs/basic-usage/unique-gmail-rule.md @@ -1,8 +1,9 @@ --- title: Unique Gmail Validation Rule +sidebar_position: 15 --- -This package provides a `UniqueGmail` validation rule to detect gmail aliases. The rule uses the `hasGmailAlias()` of the `Javaabu\Auth\User` class. Here is how to use the rule. +This package provides a `UniqueGmail` validation rule to detect gmail aliases. The rule uses the `hasGmailAlias()` scope of the `Javaabu\Auth\Concerns\DetectsGmailAliases` trait, which the package's `User` model has. Here is how to use the rule. ## Basic Usage @@ -15,13 +16,13 @@ $rule = new UniqueGmail(User::class); The class takes in the following arguments: -- **`$model_class`**: (Required) This must be a fully qualified class name of a `Javaabu\Auth\User` model. +- **`$model_class`**: (Required) The fully qualified class name of a model with the `DetectsGmailAliases` trait. - **`$email_column`**: The email column to check against. If left blank, will use the attribute name as the column name. ## Ignoring a Specific User ID -To ignore a specif user, you can use the `ignore()` method. +To ignore a specific user, you can use the `ignore()` method. ```php use Javaabu\Auth\Rules\UniqueGmail; diff --git a/docs/basic-usage/user-models.md b/docs/basic-usage/user-models.md new file mode 100644 index 0000000..a263992 --- /dev/null +++ b/docs/basic-usage/user-models.md @@ -0,0 +1,91 @@ +--- +title: The user model +sidebar_position: 4 +--- + +`Javaabu\Auth\Models\User` has every feature. Extend it once per user type: + +```php +namespace App\Models; + +use Javaabu\Auth\Models\User as AuthUser; + +class Admin extends AuthUser +{ + protected $table = 'admins'; +} +``` + +It extends Laravel's `Illuminate\Foundation\Auth\User`, uses `SoftDeletes` and `Notifiable`, hashes `password`, and implements `MustVerifyEmail`, `Laravel\Passkeys\Contracts\PasskeyUser` and the package's contracts. + +# Picking traits yourself + +| Trait | Contract | Columns | What | +| --- | --- | --- | --- | +| `Concerns\HasUserType` | | | `userType()`; password reset and verification emails link to the type's routes | +| `Concerns\HasAccountStatus` | `Contracts\HasAccountStatus` | `status` | Pending, approved and banned accounts (`UserStatuses`) | +| `Concerns\LocksAfterFailedLogins` | `Contracts\LocksAfterFailedLogins` | `login_attempts` | Locks the account after `max_login_attempts` failed logins in a row, until the password is reset | +| `Concerns\RequiresPasswordUpdate` | `Contracts\RequiresPasswordUpdate` | `require_password_update` | Makes the user choose a new password before continuing | +| `Concerns\TwoFactorAuthenticatable` | `Contracts\TwoFactorAuthenticatable` | `two_factor_*` | TOTP two-factor authentication | +| `Concerns\HasPasskeys` | `Laravel\Passkeys\Contracts\PasskeyUser` | (the `passkeys` table) | Passkeys | +| `Concerns\HasSessions` | | (the `sessions` table) | `sessions()` and `deleteOtherSessionRecords()` with the `multi_auth_database` driver | +| `Concerns\DetectsGmailAliases` | | | `hasGmailAlias()` scope, see [Unique Gmail rule](unique-gmail-rule.md) | + +Each trait does nothing unless the model implements the matching contract, so a model without `HasAccountStatus` simply has no status checks. + +```php +namespace App\Models; + +use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; +use Javaabu\Auth\Concerns\HasPasskeys; +use Javaabu\Auth\Concerns\HasUserType; +use Javaabu\Auth\Concerns\TwoFactorAuthenticatable; +use Javaabu\Auth\Contracts\TwoFactorAuthenticatable as TwoFactorAuthenticatableContract; +use Laravel\Passkeys\Contracts\PasskeyUser; + +class User extends Authenticatable implements MustVerifyEmail, PasskeyUser, TwoFactorAuthenticatableContract +{ + use HasPasskeys; + use HasUserType; + use Notifiable; + use TwoFactorAuthenticatable; +} +``` + +# Account status + +```php +use Javaabu\Auth\Enums\UserStatuses; + +$user->approve(save: true); +$user->markAsPending(save: true); +$user->ban(save: true); + +$user->isApproved(); +$user->hasActiveAccount(); // approved +$user->accountStatusMessage(); // "Your account has been banned." + +User::approved()->get(); +User::pending()->get(); +User::banned()->get(); +User::lockedOut()->get(); +User::notLockedOut()->get(); + +UserStatuses::labels(); // ['approved' => 'Approved', …], translated +``` + +New users start as `pending`. Pending and banned users can log in (so they can verify their email), but `auth.type` sends them to the `account.inactive` page until they're approved. Approve users when they register by setting the status in your [CreatesNewUsers action](../advanced-usage/actions.md). + +# Lockout and required password changes + +```php +$user->isLockedOut(); +$user->resetLoginAttempts(); // unlock + +$user->requirePasswordUpdate(); // saves; asked on next request +$user->requiresPasswordUpdate(); +``` + +Resetting the password unlocks the account and clears a required password change. diff --git a/docs/basic-usage/user-types.md b/docs/basic-usage/user-types.md new file mode 100644 index 0000000..3378609 --- /dev/null +++ b/docs/basic-usage/user-types.md @@ -0,0 +1,112 @@ +--- +title: User types +sidebar_position: 1 +--- + +A user type is one kind of user: public users, admins, staff, vendors. Each has its own model, guard, password broker, routes, pages and features. Everything in the package (controllers, middleware, emails, permissions) works out the type from the route or the model, so nothing assumes the `web` guard or `App\Models\User`. + +# In config + +```php +// config/javaabu-auth.php +use App\Models\Admin; + +'user_types' => [ + 'user' => [ + 'guard' => 'web', + 'passwords' => 'users', + 'route_name' => '', + 'home' => 'dashboard', + 'features' => ['passkeys' => true, 'delete_account' => true], + ], + + 'admin' => [ + 'model' => Admin::class, + 'guard' => 'web_admin', + 'passwords' => 'admins', + 'home' => '/admin', + 'features' => ['registration' => false, 'passkeys' => true], + 'two_factor' => 'required', + 'max_login_attempts' => 5, + 'layouts' => ['account' => 'admin.layouts.app'], + ], +], +``` + +The first type is the default, used where no route or model says otherwise. + +| Option | Default | What | +| --- | --- | --- | +| `model` | the guard's eloquent provider model | The user model | +| `guard` | the type's name | A session guard from `config/auth.php` | +| `passwords` | the plural of the name (`admins`) | The password broker | +| `route_name` | the type's name | Prefix of the route names: `admin` → `admin.login`. `''` gives `login`, which Laravel's defaults expect | +| `home` | `/` | Where users go after logging in: a path, a route name, or a closure given the user | +| `views` | `javaabu-auth.views` | `blade` or `inertia` | +| `layouts` | `javaabu-auth.layouts` | `['auth' => …, 'account' => …]` Blade layouts | +| `username` | `email` | The column users log in with | +| `features` | see below | Features to turn on or off | +| `two_factor` | `optional` | `off`, `optional` or `required` | +| `max_login_attempts` | `javaabu-auth.max_login_attempts` | Lock the account after this many failed logins in a row; `null` never locks | +| `login_methods` | `[]` | [Alternative login methods](../advanced-usage/alternative-login-methods.md) | + +# Features + +| Feature | Default | What | +| --- | --- | --- | +| `registration` | on | The register page | +| `password_reset` | on | Forgot and reset password | +| `email_verification` | on | Verification emails, the notice page, and `auth.type` sending unverified users to it (for models that implement `MustVerifyEmail`) | +| `password_confirmation` | on | The confirm password page; the security page and its actions ask for the password first | +| `profile` | on | The profile page | +| `delete_account` | off | Users delete their own account from the profile page | +| `sessions` | on | The browser sessions list (needs the `multi_auth_database` session driver) | +| `passkeys` | off | Passkey login, confirmation and management | +| `api_tokens` | off | Personal access tokens on the security page (needs Sanctum or Passport) | + +```php +'features' => ['registration' => false, 'passkeys' => true], +'features' => ['passkeys', 'api_tokens'], // a list turns them on +``` + +# In code + +`UserTypes::register()` adds a type, or changes one from config. Call it in a service provider's `register()` or `boot()`, before the routes load: + +```php +use Javaabu\Auth\UserType; +use Javaabu\Auth\UserTypes; + +UserTypes::register('vendor', fn (UserType $type) => $type + ->model(Vendor::class) + ->guard('web_vendor') + ->passwords('vendors') + ->home(fn (Vendor $vendor) => route('vendors.dashboard', $vendor)) + ->disable(UserType::REGISTRATION) + ->enable(UserType::PASSKEYS) + ->twoFactor('required')); + +UserTypes::get('user')->enable(UserType::API_TOKENS); +``` + +# Finding a type + +```php +UserTypes::get('admin'); // by name +UserTypes::for($admin); // by model or model class +UserTypes::forGuard('web_admin'); // by guard +UserTypes::fromRequest($request); // the type of the current route +UserTypes::default(); // the first type +UserTypes::all(); + +$admin->userType(); // with the HasUserType trait + +$type = UserTypes::get('admin'); +$type->route('login'); // url of admin.login +$type->routeNameFor('login'); // 'admin.login' +$type->homeUrl($admin); +$type->auth(); // Auth::guard('web_admin') +$type->broker(); // Password::broker('admins') +$type->hasFeature(UserType::PASSKEYS); +$type->requiresTwoFactor(); +``` diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md index 13b7224..5da4da1 100644 --- a/docs/installation-and-setup.md +++ b/docs/installation-and-setup.md @@ -3,22 +3,153 @@ title: Installation & Setup sidebar_position: 1.2 --- -You can install the package via composer: +# Install ```bash composer require javaabu/auth +php artisan auth:install +php artisan migrate ``` -# Publishing the config file +`auth:install` publishes `config/javaabu-auth.php` and the passkeys migration, and asks which API authentication you want (see [API authentication](basic-usage/api-authentication.md)). Pass `--api=none|sanctum|passport|both` to skip the question. -Publishing the config file is optional: +Then run `php artisan permissions:sync` so the [account-management permissions](basic-usage/managing-accounts.md) exist. + +# The user model + +Extend the package's model, which has every feature: + +```php +group(function () { + Route::get('/dashboard', DashboardController::class)->name('dashboard'); +}); +``` + +Set where users go after logging in: + +```php +// config/javaabu-auth.php +'user_types' => [ + 'user' => [ + 'guard' => 'web', + 'passwords' => 'users', + 'route_name' => '', + 'home' => 'dashboard', // a route name, a path or a closure + ], +], +``` + +That's a working app: `/login`, `/register`, `/forgot-password`, `/settings/profile`, `/settings/security`, and the rest. See [Routes](basic-usage/routes.md) for the full list. + +# Several user types + +Add a guard, a provider and a password broker for each type in `config/auth.php`: + +```php +'guards' => [ + 'web' => ['driver' => 'session', 'provider' => 'users'], + 'web_admin' => ['driver' => 'session', 'provider' => 'admins'], +], + +'providers' => [ + 'users' => ['driver' => 'eloquent', 'model' => App\Models\User::class], + 'admins' => ['driver' => 'eloquent', 'model' => App\Models\Admin::class], +], + +'passwords' => [ + 'users' => ['provider' => 'users', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 60], + 'admins' => ['provider' => 'admins', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 60], +], +``` + +Then the types, and their routes. See [User types](basic-usage/user-types.md). + +# Pages + +The pages render with Blade by default. They extend `auth::layouts.auth` (guest pages) and `auth::layouts.account` (profile and security), small layouts that load the javaabu/forms assets. Point them at your own layouts: + +```php +'layouts' => [ + 'auth' => 'layouts.guest', + 'account' => 'layouts.app', +], +'layout_section' => 'content', +``` + +For Inertia React, set `'views' => 'inertia'` and follow [Pages](basic-usage/pages.md). + +# Publishing ```bash -php artisan vendor:publish --provider="Javaabu\Auth\AuthServiceProvider" --tag="auth-config" +php artisan vendor:publish --tag=auth-config +php artisan vendor:publish --tag=auth-views +php artisan vendor:publish --tag=auth-translations +php artisan vendor:publish --tag=auth-migrations ``` -This is the default content of the config file: +After publishing the migrations, stop the package from loading its own: `AuthServiceProvider::ignoreMigrations()` in a service provider's `register()`. + +# The session driver + +To list a user's browsers on the security page and log them out of the others, use the `multi_auth_database` session driver, which stores the user type with each session: + +```dotenv +SESSION_DRIVER=multi_auth_database +``` ```php -// TODO +Schema::create('sessions', function (Blueprint $table) { + UserSchema::sessionColumns($table); +}); ``` diff --git a/docs/intro.md b/docs/intro.md index 0f32f38..1e4de93 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -5,10 +5,42 @@ sidebar_position: 1.0 # Auth -:::danger +[javaabu/auth](https://github.com/Javaabu/auth) is authentication for Laravel apps with more than one kind of user. Admins and public users (or staff, vendors, …) each get their own guard, password broker, routes, pages and rules, from one package: -This package is currently under development. If anything works, that's a surprise. +- **User types.** Each type has a model, a guard, a password broker, a home page and a set of features. Admins can have registration off and two-factor authentication required while public users sign up freely. +- **Every auth page.** Login, registration, forgot and reset password, email verification, password confirmation, the two-factor challenge, passkeys, and profile and security pages (password, two-factor, passkeys, browser sessions, API tokens). +- **Two-factor authentication.** TOTP with recovery codes, off, optional or required per user type, in the same columns as laravel/fortify. +- **Passkeys.** Log in and confirm the password with WebAuthn passkeys, built on Laravel's own `laravel/passkeys`. +- **API authentication.** Sanctum, Passport or both, with token endpoints per user type and `php artisan auth:install`. +- **Blade and Inertia React.** Every page in javaabu/forms Blade components and in React with @javaabu/forms, chosen by a config option. Dhivehi and right to left throughout. +- **Overridable.** Controllers from config, actions from the container, routes from a macro, views publishable, and admin actions behind a policy and javaabu/permissions permissions. -::: +```php +// config/javaabu-auth.php +'user_types' => [ + 'user' => [ + 'guard' => 'web', + 'route_name' => '', + 'home' => '/dashboard', + ], + 'admin' => [ + 'guard' => 'web_admin', + 'passwords' => 'admins', + 'home' => '/admin', + 'features' => ['registration' => false, 'passkeys' => true], + 'two_factor' => 'required', + ], +], +``` -[Auth](https://github.com/Javaabu/auth) Laravel Authentication Helpers. +```php +// routes/web.php +Route::authRoutes('user'); +Route::authRoutes('admin', ['prefix' => 'admin']); + +Route::middleware('auth.type:admin')->prefix('admin')->group(function () { + Route::get('/', AdminDashboardController::class)->name('admin.home'); +}); +``` + +Start with [Installation & Setup](installation-and-setup.md). Coming from v2? Read [Upgrading](upgrading.md). diff --git a/docs/metadata.json b/docs/metadata.json index 0eb1856..143fdbb 100644 --- a/docs/metadata.json +++ b/docs/metadata.json @@ -1,5 +1,5 @@ { "name": "Auth", - "description": "Laravel Authentication Helpers", - "version": "2" + "description": "Multi-auth for Laravel: login, registration, password reset, email verification, two-factor authentication, passkeys and API tokens for any number of user types", + "version": "3" } diff --git a/docs/requirements.md b/docs/requirements.md index 73bd6c0..dfcc983 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -3,7 +3,25 @@ title: Requirements sidebar_position: 1.1 --- -This package requires the following: +- PHP 8.4 or 8.5 +- Laravel 12 or 13 -- Laravel 9.0 or higher -- PHP 8.1 or higher +It installs: + +- [javaabu/forms](https://github.com/Javaabu/forms) v2 for the Blade pages, and [javaabu/manifest](https://github.com/Javaabu/manifest) to ship the scripts and React pages +- [javaabu/permissions](https://github.com/Javaabu/permissions) v3 for the account-management permissions +- [laravel/passkeys](https://github.com/laravel/passkeys-server) for WebAuthn, [pragmarx/google2fa](https://github.com/antonioribeiro/google2fa) and [bacon/bacon-qr-code](https://github.com/Bacon/BaconQrCode) for two-factor authentication +- [mobiledetect/mobiledetectlib](https://github.com/serbanghita/Mobile-Detect) to name browsers on the browser sessions list + +Optional, each detected when installed: + +| Package | For | +| --- | --- | +| `inertiajs/inertia-laravel` ^2.0 or ^3.0 | The Inertia React pages (`javaabu-auth.views` = `inertia`) | +| `laravel/sanctum` ^4.0 | API tokens and SPA authentication | +| `laravel/passport` ^13.0 | OAuth2 API authentication | +| `spatie/laravel-activitylog` ^4.10 | Logging logins, failed logins, lockouts and password changes | + +The React pages need `react` and `react-dom` 18.3 or 19, `@inertiajs/react` 2 or 3, `@javaabu/forms` and `@javaabu/onu` in the app. `php artisan manifest:check` lists what's missing. + +`laravel/passkeys` is still 0.x. The package pins `^0.2.1`; expect a minor update when it reaches 1.0. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..13ed8a1 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,96 @@ +--- +title: Upgrading from v2 +sidebar_position: 1.25 +--- + +v3 is a rewrite. v2 gave you abstract controllers built on laravel/ui's traits, to extend once per user type; v3 gives you the controllers, routes and pages, configured per user type. Plan to replace your auth controllers, routes and views rather than adapt them. + +# Requirements + +- PHP 8.4+ and Laravel 12+ (v2: PHP 8.2+, Laravel 11+). +- No longer required: `laravel/ui`, `javaabu/helpers`, `javaabu/activitylog`, `javaabu/passport`, `javaabu/schema`, `spatie/laravel-medialibrary`. Require them in your app if you still use them. +- New: `javaabu/forms` v2, `javaabu/manifest`, `javaabu/permissions` v3, `laravel/passkeys`, `pragmarx/google2fa`, `bacon/bacon-qr-code`. + +# Config + +v2 merged its config into `config/auth.php` (`max_login_attempts`, `passport_guards`, and a default `web_admin` guard). v3 has `config/javaabu-auth.php` and leaves `config/auth.php` to you. + +1. Keep your guards, providers and brokers in `config/auth.php`. v2's defaults gave `web_admin` (session) and `api_admin`/`api` (passport); check they're still defined. +2. Remove `max_login_attempts` and `passport_guards` from `config/auth.php`. `max_login_attempts` moves to `javaabu-auth.max_login_attempts`. +3. Run `php artisan auth:install` and describe your user types in `user_types`. For each of your v2 user models, the `guardName()`, `homeUrl()` and password reset/verification route names become `guard`, `home` and `route_name`. + +# Models + +`Javaabu\Auth\User` is now `Javaabu\Auth\Models\User`, and is much smaller: + +| v2 | v3 | +| --- | --- | +| `UserContract` methods `guardName()`, `homeUrl()`, `loginUrl()`, `getRouteForPasswordReset()`, `getRouteForEmailVerification()`, `emailVerificationRedirectUrl()`, `inactiveNoticeUrl()`, `passwordUpdateUrl()` | The user type: `$user->userType()->guardName()`, `->homeUrl($user)`, `->route('login')`. Remove them from your models | +| `HasStatus`, `status` cast | `Concerns\HasAccountStatus`; `UserStatuses` keeps its values (`approved`, `pending`, `banned`); `getMessage()` is now `message()`, `getColor()` is `color()` | +| `$user->is_active`, `is_locked_out`, `is_approved`, `is_banned`, `is_pending`, `status_message` attributes | `isActive()`, `isLockedOut()`, `isApproved()`, `isBanned()`, `isPending()`, `accountStatusMessage()` | +| `scopeActive()` | `approved()->notLockedOut()->whereNotNull('email_verified_at')` | +| `incrementLoginAttempts()` / `resetLoginAttempts()` | Same names; they now save | +| `PasswordUpdatable`, `requiresPasswordUpdate()` | `Concerns\RequiresPasswordUpdate` | +| `sessions()`, `deleteOtherSessionRecords()` | `Concerns\HasSessions` | +| `DetectsGmailAliases` (`Traits`) | `Concerns\DetectsGmailAliases` | +| `HasApiTokens` (Passport), `HasUserIdentifier`, `findForPassport()` | Add Sanctum's or Passport's `HasApiTokens` yourself; see [API authentication](basic-usage/api-authentication.md) | +| `IsAdminModel`, `LogsActivity`, `CausesActivity`, media library avatar, `initials`, `list_name`, `userVisible` scope | Removed: add them back in your model if you use them | +| `setPasswordAttribute()` hashing | The `hashed` cast | +| `new_email` flow (`requestEmailUpdate()`, `updateEmail()`) | Removed: changing the email on the profile page marks it unverified and sends a verification link to the new address | + +The table columns are the same, plus two-factor columns. Add them: + +```php +Schema::table('users', function (Blueprint $table) { + UserSchema::twoFactorColumns($table); +}); +``` + +`status` was a native enum column (javaabu/schema); v3's `UserSchema` makes it a string. Existing columns keep working. + +# Controllers and routes + +Delete the controllers that extended `Javaabu\Auth\Http\Controllers\Auth\*` and their routes, and register the package's: + +```php +// before +Route::get('login', [LoginController::class, 'showLoginForm'])->name('login'); +// … one set per user type + +// after +Route::authRoutes('user'); +Route::authRoutes('admin', ['prefix' => 'admin']); +``` + +Route names follow Laravel's starter kits now. With `route_name` `admin`: `admin.login`, `admin.password.request`, `admin.password.reset`, `admin.verification.notice`, `admin.password.confirm`. v2's `password.new-password` is `password.required`. + +To change a page's behaviour, extend the package controller and set it in `javaabu-auth.controllers`. See [Overriding controllers and views](advanced-usage/overriding-controllers-and-views.md). + +# Middleware + +| v2 | v3 | +| --- | --- | +| `auth:web_admin`, `active:web_admin`, `password-update-not-required:web_admin` | `auth.type:admin` (all of them, plus verified email and required 2FA) | +| `active` | `auth.active` | +| `email.verified` | `auth.verified` | +| `password-update-not-required` | `auth.password-updated` | +| `inactive`, `needs-verification`, `password-update-required`, `inactive-email.unverified` | Built into the package's routes | +| `password.confirm` | `auth.password.confirm:{type}`, kept per user type | + +The parameter is the user type's name, not the guard. + +# Views + +v2 asked you for every view (`getLoginForm()`, …). v3 ships them: Blade with javaabu/forms v2 components, or Inertia React. Point `javaabu-auth.layouts` at your layouts, or publish the views and move your markup into them. + +# Events and logging + +v2's listeners logged to activitylog through javaabu/activitylog and counted failed logins. v3 does the same with spatie/laravel-activitylog when installed (see [Events and activity log](advanced-usage/events.md)) and counts failures in the login action. `Javaabu\Auth\Events\PasswordChanged` is now `PasswordUpdated`; `EmailUpdated` is removed. + +# Sessions + +The `multi_auth_database` session driver is unchanged. `Javaabu\Auth\Session\Session` is now `Javaabu\Auth\Sessions\Session`, and `Javaabu\Auth\Agent` is `Javaabu\Auth\Sessions\Agent`. + +# Translations + +v2's `auth::validation.unique-gmail` key is now the JSON string "The same gmail :attribute is already taken.". All strings are JSON (`lang/dv.json`). diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..36f3ec3 --- /dev/null +++ b/llms.txt @@ -0,0 +1,54 @@ +# javaabu/auth + +> Multi-auth for Laravel: any number of user types (public users, admins, …), each with its own guard, password broker, routes, pages and features. Login, registration, password reset, email verification, password confirmation, TOTP two-factor authentication with recovery codes (off, optional or required per type), passkeys (laravel/passkeys), profile and security pages (password, two-factor, passkeys, browser sessions, API tokens), API authentication with Sanctum, Passport or both, account status and lockout, and admin actions on other accounts behind javaabu/permissions permissions. Pages in Blade (javaabu/forms) and Inertia React (@javaabu/forms) that work in Dhivehi and right to left. Not built on laravel/fortify, but uses its 2FA column format and route names. + +Paths are relative to this file. In a project, that's vendor/javaabu/auth/. + +## Rules + +- A user type is configured in `config('javaabu-auth.user_types')` or with `Javaabu\Auth\UserTypes::register()`: `model`, `guard`, `passwords`, `route_name`, `home`, `views`, `layouts`, `username`, `features`, `two_factor`, `max_login_attempts`, `login_methods`. The first type is the default. +- Never assume the `web` guard, `App\Models\User` or `route('login')`. Get the type with `UserTypes::get()`, `for($user)`, `forGuard()` or `fromRequest()`, and URLs with `$type->route('login')`, `$type->homeUrl($user)`. +- Register a type's routes with `Route::authRoutes('admin', ['prefix' => 'admin'])`; nothing loads automatically. Route names get the type's `route_name` prefix: `admin.login`, `admin.password.request`, `admin.profile.edit`. +- Protect app pages with `auth.type:{type}` (guard, account status, verified email, required password change, required 2FA). Ask for the password again with `auth.password.confirm:{type}`, which is kept per type. +- Change behaviour by binding the action contracts (`CreatesNewUsers`, `ResetsUserPasswords`, `UpdatesUserPasswords`, `UpdatesUserProfileInformation`, `DeletesUsers`), extending a controller set in `config('javaabu-auth.controllers')`, or rebinding the responses (`LoginResponse`, …). Don't write auth controllers by hand. +- Alternative login methods implement `Javaabu\Auth\Contracts\LoginMethod` and finish with `Javaabu\Auth\Actions\StartAuthenticatedSession`; never call `Auth::login()` directly. +- User models extend `Javaabu\Auth\Models\User`, or use the traits in `Javaabu\Auth\Concerns` with their contracts. Tables use `Javaabu\Auth\Schema\UserSchema::columns()`. +- Two-factor columns and encryption match laravel/fortify. Passkeys use the package's `passkeys` table with `user_type`; don't publish laravel/passkeys' migration or routes. +- API tokens: the model's `HasApiTokens` (Sanctum or Passport) decides the driver; set `config('javaabu-auth.api')` with `php artisan auth:install --api=…`. Sanctum token logins come from `Route::authApiRoutes()`. +- Account actions (unlock, reset 2FA, require a new password, log out everywhere) come from `Route::userAccountActions()`, behind `auth.*` gate abilities taking `[$type, $account]` and the `{action}_{type plural}` permissions; run `php artisan permissions:sync`. +- Pages render with `config('javaabu-auth.views')` (`blade` or `inertia`, per type with `views`). Blade pages extend `javaabu-auth.layouts.auth` / `.account`. React pages are `auth::Login`, …, `auth::Settings/Security`, resolved with javaabu/manifest's `resolvePage()`. Override with `vendor:publish --tag=auth-views` or `php artisan manifest:override auth `. +- Every user-facing string is an English `__()` key with Dhivehi in `lang/dv.json`; app overrides go in the app's `lang/dv.json`. Use logical utilities (`ms-`, `text-start`) only. + +## Docs + +- [Introduction](docs/intro.md): what the package does +- [Requirements](docs/requirements.md): PHP, Laravel, dependencies and optional packages +- [Installation & Setup](docs/installation-and-setup.md): install, auth:install, the model and table, one and several user types, layouts, publishing, the session driver +- [Upgrading from v2](docs/upgrading.md): config, models, controllers, routes, middleware, views and events that changed +- [User types](docs/basic-usage/user-types.md): every option and feature, registering in code, finding a type +- [Routes](docs/basic-usage/routes.md): Route::authRoutes() options and every route with its name +- [Middleware](docs/basic-usage/middleware.md): auth.type, auth.guest, auth.active, auth.verified, auth.password-updated, auth.two-factor, auth.password.confirm, auth.user-type +- [The user model](docs/basic-usage/user-models.md): the base model, each trait and contract, account status, lockout, required password changes +- [Logging in and registering](docs/basic-usage/logging-in.md): the login steps, logout, lockout, registration, rate limits +- [Password reset](docs/basic-usage/password-reset.md): the flow, brokers per type, the email, password rules +- [Email verification](docs/basic-usage/email-verification.md): the flow, turning it off, Laravel's verified middleware +- [Password confirmation](docs/basic-usage/password-confirmation.md): auth.password.confirm, timeouts, passkeys, per-type confirmation +- [Two-factor authentication](docs/basic-usage/two-factor-authentication.md): modes, turning it on, the challenge, required setup, settings, actions, events +- [Passkeys](docs/basic-usage/passkeys.md): setup, config, what users get, blocking logins, the browser side, events +- [Profile and security pages](docs/basic-usage/profile-and-security.md): profile, account deletion, password, two-factor, passkeys, browser sessions, API tokens +- [API authentication](docs/basic-usage/api-authentication.md): auth:install, Sanctum token endpoints and SPA auth, Passport with several providers, personal access tokens +- [Pages (Blade and React)](docs/basic-usage/pages.md): every page and its props, Blade layouts, setting up Inertia React, overriding, exported components, RTL +- [Managing accounts](docs/basic-usage/managing-accounts.md): Route::userAccountActions(), permissions, the policy and overriding it +- [Unique Gmail rule](docs/basic-usage/unique-gmail-rule.md): the UniqueGmail validation rule +- [Alternative login methods](docs/advanced-usage/alternative-login-methods.md): the LoginMethod contract and StartAuthenticatedSession +- [Actions](docs/advanced-usage/actions.md): the action contracts, the login actions, responses +- [Overriding controllers and views](docs/advanced-usage/overriding-controllers-and-views.md): controllers from config, Blade views, React pages, per-type stacks and layouts +- [Configuration](docs/advanced-usage/configuration.md): every option in config/javaabu-auth.php +- [Events and activity log](docs/advanced-usage/events.md): Laravel's and the package's events, optional activitylog logging +- [Dhivehi and translations](docs/advanced-usage/translations.md): translations, React strings, right to left, permission descriptions, strings to review +- [API reference](docs/advanced-usage/api-reference.md): every public class and method +- [AI assistants](docs/ai-assistants.md): Laravel Boost, and using these docs from other assistants +- [Questions and Security](docs/questions-and-security.md): where to get help +- [Changelog](docs/changelog.md): where changes are listed +- [About Us](docs/about-us.md): Javaabu +- [Contributing to the docs](docs/contributing-to-docs.md): how the docs are built diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..10be0b8 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,12 @@ +{{-- + javaabu/auth guidelines for AI code assistants + + Maintained by: Javaabu (https://javaabu.com) +--}} + +## javaabu/auth + +- This project authenticates its users with `javaabu/auth`. Each kind of user (public users, admins, …) is a **user type** in `config('javaabu-auth.user_types')` with its own guard, password broker, routes, pages and features. Never hard-code the `web` guard, `App\Models\User` or `route('login')`; use `Javaabu\Auth\UserTypes` (`UserTypes::get('admin')->route('login')`). +- Routes come from `Route::authRoutes('admin', ['prefix' => 'admin'])`; protect app pages with the `auth.type:admin` middleware. Don't write login, registration, password reset, verification, two-factor or passkey controllers by hand. +- Always activate the `javaabu-auth` skill when adding a user type, changing login or registration, protecting routes, working with two-factor authentication, passkeys, API tokens, account status or lockout, or overriding the package's pages, controllers or actions. +- Pages render as Blade (javaabu/forms) or Inertia React (`auth::Login`, … through javaabu/manifest), set by `config('javaabu-auth.views')`. Override them with `vendor:publish --tag=auth-views` or `php artisan manifest:override auth `. diff --git a/resources/boost/skills/javaabu-auth/SKILL.md b/resources/boost/skills/javaabu-auth/SKILL.md new file mode 100644 index 0000000..22a723f --- /dev/null +++ b/resources/boost/skills/javaabu-auth/SKILL.md @@ -0,0 +1,87 @@ +--- +name: javaabu-auth +description: Authentication with javaabu/auth for Laravel apps with one or more kinds of users (user types such as public users and admins, each with its own guard, routes and pages). Use whenever the project depends on javaabu/auth and the task adds or changes a user type, login, registration, password reset, email verification, password confirmation, two-factor authentication, passkeys, API tokens (Sanctum or Passport), account status or lockout, the profile or security pages, protects routes for signed-in users, or overrides the package's controllers, actions or pages. +license: MIT +metadata: + author: Javaabu +--- + +# Authentication with javaabu/auth + +The package owns the auth controllers, routes and pages; the app configures **user types** and protects its own routes. The docs ship with the package: `vendor/javaabu/auth/llms.txt` lists every page in `vendor/javaabu/auth/docs/`. + +## Adding a user type + +1. A guard, provider and password broker in `config/auth.php`: + +```php +'guards' => [ + 'web_admin' => ['driver' => 'session', 'provider' => 'admins'], +], +'providers' => [ + 'admins' => ['driver' => 'eloquent', 'model' => App\Models\Admin::class], +], +'passwords' => [ + 'admins' => ['provider' => 'admins', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 60], +], +``` + +2. The model and its table: + +```php +namespace App\Models; + +use Javaabu\Auth\Models\User as AuthUser; + +class Admin extends AuthUser +{ + protected $table = 'admins'; +} +``` + +```php +use Javaabu\Auth\Schema\UserSchema; + +Schema::create('admins', function (Blueprint $table) { + UserSchema::columns($table); +}); +``` + +3. The type in `config/javaabu-auth.php`: + +```php +'admin' => [ + 'guard' => 'web_admin', + 'passwords' => 'admins', + 'home' => '/admin', + 'features' => ['registration' => false, 'passkeys' => true], + 'two_factor' => 'required', +], +``` + +4. Its routes, and the app's pages behind `auth.type`: + +```php +Route::authRoutes('admin', ['prefix' => 'admin']); + +Route::middleware('auth.type:admin')->prefix('admin')->name('admin.')->group(function () { + Route::get('/', AdminDashboardController::class)->name('home'); +}); +``` + +Route names get the type's `route_name` prefix (`admin.login`, `admin.password.request`, `admin.profile.edit`, `admin.security.edit`). The default `user` type has `route_name` `''`, so its names are `login`, `register`, … as Laravel expects. + +## Rules + +- Find a type with `UserTypes::get($name)`, `UserTypes::for($user)`, `UserTypes::forGuard($guard)` or `UserTypes::fromRequest($request)`; build URLs with `$type->route('login')` and `$type->homeUrl($user)`, never `route('login')` for a non-default type. +- `auth.type:{type}` checks the guard, account status, verified email, required password change and required two-factor setup. Use it instead of `auth:{guard}` on app pages. `auth.password.confirm:{type}` asks for the password again, per type. +- Change what registration or profile updates do by binding your own `Javaabu\Auth\Contracts\CreatesNewUsers` (or `ResetsUserPasswords`, `UpdatesUserPasswords`, `UpdatesUserProfileInformation`, `DeletesUsers`) in a service provider. Every method receives the `UserType`. Don't edit the package's controllers. +- Change a controller by extending the package's and setting it in `config('javaabu-auth.controllers')`. Change where users go by rebinding `Javaabu\Auth\Http\Responses\LoginResponse` (and `RegisterResponse`, `LogoutResponse`) or the type's `home`. +- New login methods (mobile number and code, …) implement `Javaabu\Auth\Contracts\LoginMethod` and finish with `Javaabu\Auth\Actions\StartAuthenticatedSession`, which runs the two-factor challenge. Don't call `Auth::login()` yourself. +- Two-factor authentication is per type: `'two_factor' => 'off' | 'optional' | 'required'`. Use the actions in `Javaabu\Auth\Actions\TwoFactor`, never write secrets directly. In tests get a code with `app(TwoFactorProvider::class)->currentCode($secret)`. +- Passkeys need the `passkeys` feature, `Concerns\HasPasskeys` + `Laravel\Passkeys\Contracts\PasskeyUser` on the model, and the package's `passkeys` table (with `user_type`). Don't publish laravel/passkeys' own migration or routes. +- API tokens: the model's `HasApiTokens` trait (Sanctum or Passport) picks the driver; create tokens with `Javaabu\Auth\Support\ApiTokens::create()`. Mobile token logins come from `Route::authApiRoutes('user')` (Sanctum). +- Admin actions on other users (unlock, reset two-factor, require a new password, log out everywhere) come from `Route::userAccountActions('user', ['middleware' => ['web', 'auth.type:admin']])`, behind the `auth.*` abilities and the `{action}_{type plural}` permissions. Run `php artisan permissions:sync`. +- Every user-facing string goes through `__()` with an English key; add the Dhivehi to `lang/dv.json`. In overridden views use logical utilities (`ms-`, `text-start`), `dir="ltr"` on email, password and code inputs, `dir="auto"` on names. + +See [references/common-mistakes.md](references/common-mistakes.md) for what goes wrong, and [references/testing.md](references/testing.md) for tests. diff --git a/resources/boost/skills/javaabu-auth/references/common-mistakes.md b/resources/boost/skills/javaabu-auth/references/common-mistakes.md new file mode 100644 index 0000000..6357883 --- /dev/null +++ b/resources/boost/skills/javaabu-auth/references/common-mistakes.md @@ -0,0 +1,48 @@ +# Common mistakes with javaabu/auth + +## Assuming one guard + +```php +// Wrong: the admin is on web_admin +$admin = auth()->user(); +return redirect()->route('login'); + +// Right +$type = UserTypes::get('admin'); +$admin = $type->auth()->user(); +return redirect()->to($type->route('login')); +``` + +Inside a route registered by `Route::authRoutes()` or behind `auth.type:admin`, `UserTypes::fromRequest($request)` gives the type, and `auth.type` sets the default guard for the request so `$request->user()` works. + +## Protecting pages with `auth:` only + +`auth:web_admin` lets pending, banned, locked and unverified admins through. Use `auth.type:admin`. + +## Writing auth controllers + +The package has every auth page. To change behaviour, bind an action or extend a controller (see the skill). To add fields to registration, bind a `CreatesNewUsers` action and override the `register` view or page. + +## Logging users in by hand + +`Auth::guard('web')->login($user)` skips the two-factor challenge and the session regeneration. Use `StartAuthenticatedSession`: + +```php +return app(StartAuthenticatedSession::class)($request, UserTypes::get('user'), $user, remember: true); +``` + +## Registering a type after the routes + +`UserTypes::register()` and `->loginMethod()` must run before `Route::authRoutes()`; register types in a service provider, not in a route file after the macro. + +## Publishing laravel/passkeys' migration + +Its `passkeys` table has no `user_type`, so passkeys of different user types collide. The package ships its own migration; use only that. + +## Expecting `lang/vendor/auth/dv.json` to work + +Laravel doesn't read JSON translations from `lang/vendor`. Put changed lines in the app's `lang/dv.json`. + +## Physical direction utilities + +`ml-4`, `text-left` break the pages in Dhivehi. Use `ms-4`, `text-start`. diff --git a/resources/boost/skills/javaabu-auth/references/testing.md b/resources/boost/skills/javaabu-auth/references/testing.md new file mode 100644 index 0000000..89a2686 --- /dev/null +++ b/resources/boost/skills/javaabu-auth/references/testing.md @@ -0,0 +1,52 @@ +# Testing an app that uses javaabu/auth + +```php +use Javaabu\Auth\Http\Middleware\RequirePassword; +use Javaabu\Auth\TwoFactor\TwoFactorProvider; +use Javaabu\Auth\UserTypes; +use PHPUnit\Framework\Attributes\Test; + +#[Test] +public function admins_see_the_dashboard(): void +{ + $admin = Admin::factory()->create(['status' => 'approved', 'email_verified_at' => now()]); + + $this->actingAs($admin, 'web_admin') + ->get('/admin') + ->assertOk(); +} + +#[Test] +public function the_security_page_works_once_the_password_is_confirmed(): void +{ + $admin = Admin::factory()->create(); + + $this->actingAs($admin, 'web_admin') + ->withSession([RequirePassword::sessionKey(UserTypes::get('admin')) => time()]) + ->get('/admin/settings/security') + ->assertOk(); +} + +#[Test] +public function admins_log_in_with_a_two_factor_code(): void +{ + $secret = 'JBSWY3DPEHPK3PXP'; + $admin = Admin::factory()->create([ + 'two_factor_secret' => encrypt($secret), + 'two_factor_recovery_codes' => encrypt(json_encode(['code-1'])), + 'two_factor_confirmed_at' => now(), + ]); + + $this->post('/admin/login', ['email' => $admin->email, 'password' => 'password']) + ->assertRedirect('/admin/two-factor-challenge'); + + $this->post('/admin/two-factor-challenge', ['code' => app(TwoFactorProvider::class)->currentCode($secret)]) + ->assertRedirect('/admin'); + + $this->assertAuthenticatedAs($admin, 'web_admin'); +} +``` + +- New users are `pending` by default (`HasAccountStatus`); set `status` to `approved` and `email_verified_at` in factories for users that should get through `auth.type`. +- With `max_login_attempts` set, tests that fail logins on purpose may lock the account; reset with `$user->resetLoginAttempts()`. +- Use Thaana in names and other user-facing data. diff --git a/src/Actions/LoginUser.php b/src/Actions/LoginUser.php index ec16872..53af1ee 100644 --- a/src/Actions/LoginUser.php +++ b/src/Actions/LoginUser.php @@ -3,6 +3,7 @@ namespace Javaabu\Auth\Actions; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Javaabu\Auth\Contracts\LocksAfterFailedLogins; use Javaabu\Auth\UserType; @@ -24,5 +25,10 @@ public function __invoke(Request $request, UserType $type, Authenticatable $user if ($user instanceof LocksAfterFailedLogins) { $user->resetLoginAttempts(); } + + // Only for tables that have the column (UserSchema::accountColumns()) + if ($user instanceof Model && array_key_exists('last_login_at', $user->getAttributes())) { + $user->forceFill(['last_login_at' => now()])->saveQuietly(); + } } } diff --git a/tests/Feature/LoginTest.php b/tests/Feature/LoginTest.php index 30031e5..253ed22 100644 --- a/tests/Feature/LoginTest.php +++ b/tests/Feature/LoginTest.php @@ -130,6 +130,7 @@ public function it_resets_failed_logins_after_a_successful_login(): void ->assertRedirect('/dashboard'); $this->assertNull($user->fresh()->login_attempts); + $this->assertNotNull($user->fresh()->last_login_at); } #[Test] diff --git a/tests/Unit/DocsTest.php b/tests/Unit/DocsTest.php new file mode 100644 index 0000000..0af4b07 --- /dev/null +++ b/tests/Unit/DocsTest.php @@ -0,0 +1,30 @@ +assertStringContainsString("({$path})", $llms, "llms.txt doesn't link {$path}"); + } + } + + #[Test] + public function the_boost_skill_has_its_front_matter(): void + { + $skill = file_get_contents(__DIR__ . '/../../resources/boost/skills/javaabu-auth/SKILL.md'); + + $this->assertStringStartsWith("---\nname: javaabu-auth\n", $skill); + $this->assertFileExists(__DIR__ . '/../../resources/boost/guidelines/core.blade.php'); + } +} From 1c02f0e339c9a293d489c1129e5dd77d08c07c2f Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Wed, 4 Nov 2026 16:53:06 +0500 Subject: [PATCH 09/14] Match usernames as typed, then lowercased; verification pages tolerate models without MustVerifyEmail Co-Authored-By: Claude Opus 5.5 --- docs/basic-usage/logging-in.md | 2 +- src/Actions/AttemptLogin.php | 4 +-- src/Actions/IssueApiToken.php | 4 +-- ...mailVerificationNotificationController.php | 3 ++- .../EmailVerificationPromptController.php | 3 ++- .../Controllers/NewPasswordController.php | 4 ++- .../PasswordResetLinkController.php | 8 +++--- .../Controllers/VerifyEmailController.php | 5 ++++ src/UserType.php | 25 +++++++++++++++++++ tests/Feature/LoginTest.php | 22 ++++++++++++++++ 10 files changed, 69 insertions(+), 11 deletions(-) diff --git a/docs/basic-usage/logging-in.md b/docs/basic-usage/logging-in.md index 7f8d0d6..6c881c9 100644 --- a/docs/basic-usage/logging-in.md +++ b/docs/basic-usage/logging-in.md @@ -15,7 +15,7 @@ sidebar_position: 5 6. Logs in on the type's guard, regenerates the session, clears failed attempts and records `last_login_at`. 7. Redirects to the intended URL or the type's home. JSON requests get `{"two_factor": false, "redirect": "…"}`. -A user of one type can't log in on another type's login page, even with the same email and password. +The username is matched as typed, then lowercased (new accounts are saved lowercase; older mixed-case ones still match on case-sensitive databases). A user of one type can't log in on another type's login page, even with the same email and password. # Logging out diff --git a/src/Actions/AttemptLogin.php b/src/Actions/AttemptLogin.php index b4be343..aca9c95 100644 --- a/src/Actions/AttemptLogin.php +++ b/src/Actions/AttemptLogin.php @@ -53,7 +53,7 @@ protected function validateCredentials(Request $request, UserType $type): ?Authe $provider = $type->auth()->getProvider(); $credentials = $this->credentials($request, $type); - $user = $provider->retrieveByCredentials([$type->usernameColumn() => $credentials[$type->usernameColumn()]]); + $user = $type->findByUsername($credentials[$type->usernameColumn()]); if ($user instanceof LocksAfterFailedLogins && $user->isLockedOut()) { throw ValidationException::withMessages([ @@ -82,7 +82,7 @@ protected function validateCredentials(Request $request, UserType $type): ?Authe protected function credentials(Request $request, UserType $type): array { return [ - $type->usernameColumn() => mb_strtolower(trim((string) $request->input($type->usernameColumn()))), + $type->usernameColumn() => trim((string) $request->input($type->usernameColumn())), 'password' => (string) $request->input('password'), ]; } diff --git a/src/Actions/IssueApiToken.php b/src/Actions/IssueApiToken.php index 3294792..2eb1ab6 100644 --- a/src/Actions/IssueApiToken.php +++ b/src/Actions/IssueApiToken.php @@ -27,11 +27,11 @@ public function __invoke(Request $request, UserType $type): string $username = $type->usernameColumn(); $provider = $type->auth()->getProvider(); $credentials = [ - $username => mb_strtolower(trim((string) $request->input($username))), + $username => trim((string) $request->input($username)), 'password' => (string) $request->input('password'), ]; - $user = $provider->retrieveByCredentials([$username => $credentials[$username]]); + $user = $type->findByUsername($credentials[$username]); if (! $user || ! $provider->validateCredentials($user, $credentials)) { event(new Failed($type->guardName(), $user, $credentials)); diff --git a/src/Http/Controllers/EmailVerificationNotificationController.php b/src/Http/Controllers/EmailVerificationNotificationController.php index 91a4d22..cbba0af 100644 --- a/src/Http/Controllers/EmailVerificationNotificationController.php +++ b/src/Http/Controllers/EmailVerificationNotificationController.php @@ -2,6 +2,7 @@ namespace Javaabu\Auth\Http\Controllers; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -13,7 +14,7 @@ public function store(Request $request): Response $type = $this->userType($request); $user = $this->user($request); - if ($user->hasVerifiedEmail()) { + if (! $user instanceof MustVerifyEmail || $user->hasVerifiedEmail()) { return $request->wantsJson() ? new JsonResponse('', 204) : redirect()->intended($type->homeUrl($user)); diff --git a/src/Http/Controllers/EmailVerificationPromptController.php b/src/Http/Controllers/EmailVerificationPromptController.php index 083b7bc..7ebdcef 100644 --- a/src/Http/Controllers/EmailVerificationPromptController.php +++ b/src/Http/Controllers/EmailVerificationPromptController.php @@ -2,6 +2,7 @@ namespace Javaabu\Auth\Http\Controllers; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Support\Responsable; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; @@ -14,7 +15,7 @@ public function __invoke(Request $request): View|Responsable|RedirectResponse $type = $this->userType($request); $user = $this->user($request); - if ($user->hasVerifiedEmail()) { + if (! $user instanceof MustVerifyEmail || $user->hasVerifiedEmail()) { return redirect()->intended($type->homeUrl($user)); } diff --git a/src/Http/Controllers/NewPasswordController.php b/src/Http/Controllers/NewPasswordController.php index 2141e7e..ddf6dbd 100644 --- a/src/Http/Controllers/NewPasswordController.php +++ b/src/Http/Controllers/NewPasswordController.php @@ -36,9 +36,11 @@ public function store(Request $request, ResetsUserPasswords $resetter): Response 'password' => ['required', 'string'], ]); + $user = $type->findByUsername((string) $request->input($username)); + $status = $type->broker()->reset( [ - $username => mb_strtolower(trim((string) $request->input($username))), + $username => $user?->getAttribute($username) ?? trim((string) $request->input($username)), 'password' => $request->input('password'), 'password_confirmation' => $request->input('password_confirmation'), 'token' => $request->input('token'), diff --git a/src/Http/Controllers/PasswordResetLinkController.php b/src/Http/Controllers/PasswordResetLinkController.php index 5c96ad4..553744f 100644 --- a/src/Http/Controllers/PasswordResetLinkController.php +++ b/src/Http/Controllers/PasswordResetLinkController.php @@ -26,9 +26,11 @@ public function store(Request $request): Response $username => ['required', 'string', 'email'], ]); - $status = $type->broker()->sendResetLink([ - $username => mb_strtolower(trim((string) $request->input($username))), - ]); + $user = $type->findByUsername((string) $request->input($username)); + + $status = $user + ? $type->broker()->sendResetLink([$username => $user->getAttribute($username)]) + : PasswordBroker::INVALID_USER; if ($status === PasswordBroker::RESET_THROTTLED) { throw ValidationException::withMessages([ diff --git a/src/Http/Controllers/VerifyEmailController.php b/src/Http/Controllers/VerifyEmailController.php index 7bf9cc1..fac383e 100644 --- a/src/Http/Controllers/VerifyEmailController.php +++ b/src/Http/Controllers/VerifyEmailController.php @@ -4,6 +4,7 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Events\Verified; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -23,6 +24,10 @@ public function __invoke(Request $request, string $id, string $hash): Response throw new AuthorizationException(__('This verification link is invalid.')); } + if (! $user instanceof MustVerifyEmail) { + return redirect()->intended($type->homeUrl($user)); + } + if (! $user->hasVerifiedEmail() && $user->markEmailAsVerified()) { event(new Verified($user)); } diff --git a/src/UserType.php b/src/UserType.php index 5c23e56..a28800d 100644 --- a/src/UserType.php +++ b/src/UserType.php @@ -3,6 +3,7 @@ namespace Javaabu\Auth; use Closure; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\PasswordBroker; use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Database\Eloquent\Model; @@ -306,6 +307,30 @@ public function usernameColumn(): string return $this->username; } + /** + * Find a user by username as entered, then lowercased, so mixed-case emails + * saved before the package lowercased them still match on case-sensitive databases + */ + public function findByUsername(?string $username): ?Authenticatable + { + $username = trim((string) $username); + + if ($username === '') { + return null; + } + + $provider = $this->auth()->getProvider(); + $column = $this->usernameColumn(); + + $user = $provider->retrieveByCredentials([$column => $username]); + + if ($user || mb_strtolower($username) === $username) { + return $user; + } + + return $provider->retrieveByCredentials([$column => mb_strtolower($username)]); + } + /** * Turn features on or off: `['registration' => false, 'passkeys' => true]` * diff --git a/tests/Feature/LoginTest.php b/tests/Feature/LoginTest.php index 253ed22..d88f5db 100644 --- a/tests/Feature/LoginTest.php +++ b/tests/Feature/LoginTest.php @@ -198,4 +198,26 @@ public function it_sends_users_with_unverified_emails_to_the_verification_notice ->get('/dashboard') ->assertRedirect(route('verification.notice')); } + + #[Test] + public function it_finds_mixed_case_emails_saved_before_lowercasing(): void + { + $user = User::factory()->create(['email' => 'Ahmed.Ali@Example.com']); + + $this->post('/login', ['email' => 'Ahmed.Ali@Example.com', 'password' => 'password']) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user, 'web'); + } + + #[Test] + public function it_finds_lowercase_emails_typed_in_mixed_case(): void + { + $user = User::factory()->create(['email' => 'ahmed@example.com']); + + $this->post('/login', ['email' => ' AHMED@example.com ', 'password' => 'password']) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user, 'web'); + } } From 1a5f933232d82314dd78d7b20d4c6d68d852ba10 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Fri, 6 Nov 2026 00:25:09 +0500 Subject: [PATCH 10/14] Ship docs with the Composer package llms.txt and the Boost skill point at vendor/javaabu/auth/docs, so docs must not be export-ignored. Dev-only JS tooling files are now export-ignored instead. Closes #16 Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 17 ++++++++++------- tests/Unit/DocsTest.php | 12 ++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index 4ba65c5..f954f98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,16 +1,19 @@ # Path-based git attributes # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html -# Ignore all test and documentation with "export-ignore". +# Keep development files out of the Composer package. docs, llms.txt and +# resources/boost ship with it, for AI assistants and Laravel Boost. /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore -/phpunit.xml.dist export-ignore -/.scrutinizer.yml export-ignore -/tests export-ignore -/.editorconfig export-ignore /.github export-ignore -/docs export-ignore +/.editorconfig export-ignore +/phpunit.xml.dist export-ignore /pint.json export-ignore +/tests export-ignore /AGENTS.md export-ignore /CLAUDE.md export-ignore +/node_modules export-ignore +/package.json export-ignore +/pnpm-lock.yaml export-ignore +/tsconfig.json export-ignore +/vitest.config.ts export-ignore diff --git a/tests/Unit/DocsTest.php b/tests/Unit/DocsTest.php index 0af4b07..2630237 100644 --- a/tests/Unit/DocsTest.php +++ b/tests/Unit/DocsTest.php @@ -27,4 +27,16 @@ public function the_boost_skill_has_its_front_matter(): void $this->assertStringStartsWith("---\nname: javaabu-auth\n", $skill); $this->assertFileExists(__DIR__ . '/../../resources/boost/guidelines/core.blade.php'); } + + #[Test] + public function the_docs_ship_with_the_composer_package(): void + { + $attributes = file_get_contents(__DIR__ . '/../../.gitattributes'); + + foreach (['/docs', '/llms.txt', '/resources', '/lang', '/src', '/config', '/database'] as $path) { + $this->assertDoesNotMatchRegularExpression('#^' . preg_quote($path, '#') . '\\s+export-ignore#m', $attributes, "{$path} must ship with the package"); + } + + $this->assertMatchesRegularExpression('#^/tests\\s+export-ignore#m', $attributes); + } } From 015e00cc4a002595438a6c178aba68abe956145d Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Fri, 6 Nov 2026 00:27:51 +0500 Subject: [PATCH 11/14] Let packages add links to the account navigation UserTypes::accountLink() adds a link for every user type that has the route, and UserType::accountLink() / withoutAccountLink() change one type. The Blade navigation and React AccountLayout render Profile, Security and the added links from the new account_links prop, marking the current page; the Blade partial also works on pages that didn't come from auth. Documented for package authors. Closes #15 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- docs/advanced-usage/api-reference.md | 9 +- docs/advanced-usage/for-package-authors.md | 163 ++++++++++++++++++ docs/basic-usage/pages.md | 3 +- llms.txt | 2 + resources/boost/skills/javaabu-auth/SKILL.md | 1 + resources/js/Pages/Settings/Profile.tsx | 4 +- resources/js/Pages/Settings/Security.tsx | 4 +- resources/js/components/AccountLayout.tsx | 45 +++-- resources/js/types.ts | 9 + .../views/partials/account-nav.blade.php | 34 ++-- src/Support/AccountLink.php | 58 +++++++ src/Support/PageData.php | 11 ++ src/UserType.php | 48 ++++++ src/UserTypeRegistry.php | 23 +++ src/UserTypes.php | 3 + tests/Feature/AccountLinksTest.php | 139 +++++++++++++++ tests/Http/InertiaPagesTest.php | 2 + tests/js/fixtures.ts | 8 +- tests/js/pages.test.tsx | 14 ++ tests/views/mobile-number.blade.php | 6 + 21 files changed, 558 insertions(+), 31 deletions(-) create mode 100644 docs/advanced-usage/for-package-authors.md create mode 100644 src/Support/AccountLink.php create mode 100644 tests/Feature/AccountLinksTest.php create mode 100644 tests/views/mobile-number.blade.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d3a3f..db78160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,10 @@ All notable changes will be documented in this file. See [docs/upgrading.md](doc - Profile page (name, email, account deletion) and security page (password, two-factor, passkeys, browser sessions, API tokens) - `Route::userAccountActions()`: unlock, reset two-factor, require a new password and log out everywhere, behind `UserAccountPolicy` and permissions registered with javaabu/permissions - `LoginMethod` contract and `StartAuthenticatedSession` for alternative login methods (javaabu/mobile-verification) +- Account navigation links for packages and apps: `UserTypes::accountLink()`, `UserType::accountLink()` / `withoutAccountLink()`, the `account_links` page prop, and `AccountLayout`'s `links` - Pages in Blade (javaabu/forms v2) and Inertia React (@javaabu/forms, shipped through javaabu/manifest), with a shared WebAuthn client and components exported as `@javaabu/auth` - Dhivehi translations; right-to-left pages - Optional activity logging with spatie/laravel-activitylog -- Docs for people and agents, `llms.txt`, Laravel Boost guideline and skill +- Docs for people and agents, `llms.txt`, Laravel Boost guideline and skill; `docs/` ships in the Composer package - Requires PHP 8.4, Laravel 12 or 13 - Breaking: rewritten on top of user types instead of abstract laravel/ui controllers; config moves from `config/auth.php` to `config/javaabu-auth.php`; `Javaabu\Auth\User` is now `Javaabu\Auth\Models\User` with traits; no longer requires laravel/ui, javaabu/helpers, javaabu/activitylog, javaabu/passport, javaabu/schema or spatie/laravel-medialibrary; the new-email verification flow is removed diff --git a/docs/advanced-usage/api-reference.md b/docs/advanced-usage/api-reference.md index 089d841..8654d07 100644 --- a/docs/advanced-usage/api-reference.md +++ b/docs/advanced-usage/api-reference.md @@ -17,13 +17,15 @@ All classes are in `Javaabu\Auth`. | `for(Model\|string $user)` | The type of a model or model class (exact class first), or `null` | | `forGuard(?string $guard)` | The type on a guard, or `null` | | `fromRequest(?Request $request = null)` | The type of the current route, or `null` | +| `accountLink(string $key, string\|Closure $label, ?string $route = null, int $position = 100)` | `Support\AccountLink`: a link in every type's account navigation, shown where the route exists | +| `accountLinks()` | The links added for every type | | `forget(string $name)`, `flush()` | For tests | `UserTypeRegistry::ROUTE_ACTION` (`auth_user_type`) is the route action key holding the type. # UserType -Setters return the type: `model()`, `guard()`, `passwords()`, `routeName()`, `home()`, `views()`, `layouts()`, `username()`, `features()`, `enable(...$features)`, `disable(...$features)`, `twoFactor()`, `maxLoginAttempts()`, `loginMethods()`, `loginMethod()`. `UserType::fromArray($name, $config)` builds one from config. +Setters return the type: `accountLink($key, $label, $route = null, $position = 100)`, `withoutAccountLink($key)`, `model()`, `guard()`, `passwords()`, `routeName()`, `home()`, `views()`, `layouts()`, `username()`, `features()`, `enable(...$features)`, `disable(...$features)`, `twoFactor()`, `maxLoginAttempts()`, `loginMethods()`, `loginMethod()`. `UserType::fromArray($name, $config)` builds one from config. | Getter | Returns | | --- | --- | @@ -39,6 +41,8 @@ Setters return the type: `model()`, `guard()`, `passwords()`, `routeName()`, `ho | `twoFactorMode()`, `hasTwoFactor()`, `requiresTwoFactor()` | `Enums\TwoFactorModes` (`OFF`, `OPTIONAL`, `REQUIRED`) | | `maxLoginAttemptsAllowed()` | `?int` | | `loginMethodsList()` | `array` | +| `accountLinks()` | `array`: the type's account navigation, built-ins included, whose routes exist, by position | +| `findByUsername(?string $username)` | The user, matched as typed then lowercased | | `owns(mixed $user)` | Whether a model is one of this type's users | # Route macros @@ -75,7 +79,8 @@ Setters return the type: `model()`, `guard()`, `passwords()`, `routeName()`, `ho - `Support\ApiTokens`: `enabledDrivers()`, `isEnabled()`, `driverFor()`, `supports()`, `create()`, `list()`, `revoke()` - `Support\AccountState`: `inactiveMessage($user)`, `isActive($user)` -- `Support\PageData`: `shared($type)`, `urls($type)`, `strings()` +- `Support\PageData`: `shared($type)`, `urls($type)`, `accountLinks($type)`, `strings()` +- `Support\AccountLink`: `key`, `label`, `route`, `position`, `label($type)`, `isAvailableFor($type)`, `toArray($type)` - `Support\Strings::ALL`: the React pages' string keys and English text - `Support\ActivityLogger`: `isAvailable()`, `EVENTS` - `TwoFactor\TwoFactorProvider`: `generateSecretKey()`, `qrCodeUrl()`, `verify($secret, $code)` (each code once), `currentCode($secret)` diff --git a/docs/advanced-usage/for-package-authors.md b/docs/advanced-usage/for-package-authors.md new file mode 100644 index 0000000..a9914fd --- /dev/null +++ b/docs/advanced-usage/for-package-authors.md @@ -0,0 +1,163 @@ +--- +title: For package authors +sidebar_position: 0.5 +--- + +Packages that add account features (a mobile number page, notification preferences, …) or other ways to log in build on javaabu/auth instead of their own auth code. This page shows the pieces, with a mobile number package as the example. + +# composer.json + +```json +{ + "require": { + "javaabu/auth": "^3.0" + } +} +``` + +# An account page per user type + +Let the app register the page for the user types that should have it, with a route macro, the way `Route::authRoutes()` works. Put the routes behind `auth.type:{type}` and give them the type's route name prefix, so the page belongs to that type: + +```php +namespace Javaabu\MobileVerification; + +use Illuminate\Routing\Router; +use Illuminate\Support\Facades\Route; +use Illuminate\Support\ServiceProvider; +use Javaabu\Auth\UserTypes; + +class MobileVerificationServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Route::macro('mobileNumberRoutes', function (string $type, array $options = []) { + /** @var Router $this */ + $user_type = UserTypes::get($type); + + $this->middleware([...($options['middleware'] ?? ['web']), "auth.type:{$type}"]) + ->prefix($options['prefix'] ?? '') + ->name($user_type->routeNamePrefix()) + ->group(function (Router $router) { + $router->get('settings/mobile-number', [MobileNumbersController::class, 'edit'])->name('mobile-number'); + $router->put('settings/mobile-number', [MobileNumbersController::class, 'update'])->name('mobile-number.update'); + }); + }); + + // Show the page in the account navigation of every type that has the route + UserTypes::accountLink('mobile-number', 'Mobile number', position: 30); + } +} +``` + +```php +// the app's routes/web.php +Route::authRoutes('user'); +Route::mobileNumberRoutes('user'); +``` + +# Account navigation links + +The account navigation (the Blade partial `auth::partials.account-nav` and React's `AccountLayout`) shows Profile (position 10), Security (20), the links packages add, and Log out. A link shows for a user type only when the type has the link's route, so registering it for every type is safe: + +```php +use Javaabu\Auth\UserType; +use Javaabu\Auth\UserTypes; + +// every type that has the `mobile-number` route (`mobile-number`, `admin.mobile-number`, …) +UserTypes::accountLink('mobile-number', 'Mobile number'); + +// one type, with another route name, a closure label and a position +UserTypes::get('admin')->accountLink('phone', fn (UserType $type) => __('Phone'), route: 'mobile-number', position: 15); + +// apps remove links per type +UserTypes::get('admin')->withoutAccountLink('mobile-number'); +``` + +| Argument | What | +| --- | --- | +| `key` | Unique; the same key replaces a link (`profile` and `security` are the built-in ones) | +| `label` | An English string, translated with `__()` when shown, or a closure given the type | +| `route` | The route name without the type's prefix; defaults to the key | +| `position` | Lower comes first; Profile is 10, Security 20, the default 100 | + +Links are read when a page renders, so registering them in `boot()` is enough, in any order with the routes. Add the label's Dhivehi to the package's `lang/dv.json`. + +Pages get the links as the `account_links` prop (`[{key, label, url, active}]`), the current page marked `active` (and `aria-current="page"`). + +# Rendering the page like auth's own + +Render with the type's stack and layouts through auth's `ViewRenderer`, so the page gets the same props as auth's pages (`auth_type`, `urls`, `account_links`, `status`, and `strings` in React) and extends the type's `account` layout: + +```php +use Illuminate\Http\Request; +use Javaabu\Auth\UserTypes; +use Javaabu\Auth\Views\ViewRenderer; + +class MobileNumbersController +{ + public function edit(Request $request, ViewRenderer $views) + { + $type = UserTypes::fromRequest($request); + + return $views->render($type, 'mobile-number', [ + 'mobile' => $type->auth()->user()->mobile, + ], 'account'); + } +} +``` + +`ViewRenderer` renders `auth::{page}` and `auth::{Page}`, so for a page of your own package, build the same data and render your own view with it: + +```php +use Javaabu\Auth\Support\PageData; + +$data = [...app(PageData::class)->shared($type), 'mobile' => $user->mobile]; + +return $type->viewStack() === 'inertia' + ? Inertia::render('mobile-verification::MobileNumber', [...$data, 'strings' => $strings]) + : view('mobile-verification::mobile-number', [ + ...$data, + 'user_type' => $type, + 'layout' => $type->layoutName('account'), + 'layout_section' => config('javaabu-auth.layout_section', 'content'), + ]); +``` + +```blade +{{-- resources/views/mobile-number.blade.php --}} +@extends($layout) + +@section($layout_section) + … +@endsection +``` + +The package's account layout includes the navigation. In an app's own layout, `@include('auth::partials.account-nav')` works on any page of a user type; it finds the type and links itself. + +In React, wrap the page in `AccountLayout` and pass the links: + +```tsx +import { AccountLayout, type AuthPageProps } from "@javaabu/auth"; + +// `title` and `mobile` are this package's own props; `strings` is auth's (for "Account" and "Log out") +export default function MobileNumber({ urls, strings, account_links, title, mobile }: AuthPageProps & { title: string; mobile: string | null }) { + return ( + + … + + ); +} +``` + +# Another way to log in + +Implement `Javaabu\Auth\Contracts\LoginMethod` and finish with `StartAuthenticatedSession`; see [Alternative login methods](alternative-login-methods.md). + +# Rules + +- Never assume the `web` guard or `App\Models\User`. Take a user type name, resolve it with `UserTypes::get()`, and use `$type->auth()`, `$type->route()` and `$type->newModel()`. +- Protect pages with `auth.type:{type}`, and sensitive actions with `auth.password.confirm:{type}`. +- Name routes with the type's prefix (`$type->routeNamePrefix()`), so each type gets its own. +- Register account links with an English label and add its Dhivehi; use logical utilities only in views. +- Test with two user types on two guards. diff --git a/docs/basic-usage/pages.md b/docs/basic-usage/pages.md index 6f8ee1f..faddf7e 100644 --- a/docs/basic-usage/pages.md +++ b/docs/basic-usage/pages.md @@ -31,6 +31,7 @@ Every page gets: | --- | --- | | `auth_type` | `{name, features, two_factor, username, login_methods}` | | `urls` | The type's route URLs by name without the prefix: `urls['login.store']`, `urls['profile.edit']`, `urls.home` | +| `account_links` | The account navigation: `[{key, label, url, active}]` for Profile, Security and links packages added (see [For package authors](../advanced-usage/for-package-authors.md#account-navigation-links)) | | `status` | The flashed status message, if any | | `strings` | React only: the translated strings, keyed as in `Javaabu\Auth\Support\Strings` | @@ -103,7 +104,7 @@ resolve: async (name) => { }, ``` -Override a page with `php artisan manifest:override auth js/Pages/Login.tsx`. The components the pages use are exported from `@javaabu/auth` for building your own: `AuthCard`, `AccountLayout`, `StatusAlert`, `PasskeyButton`, `TwoFactorSettings`, `PasskeysSettings`, `SessionsSettings`, `ApiTokensSettings`, `t()`, and the WebAuthn functions. +Override a page with `php artisan manifest:override auth js/Pages/Login.tsx`. `AccountLayout` takes the `account_links` prop as `links`. The components the pages use are exported from `@javaabu/auth` for building your own: `AuthCard`, `AccountLayout`, `StatusAlert`, `PasskeyButton`, `TwoFactorSettings`, `PasskeysSettings`, `SessionsSettings`, `ApiTokensSettings`, `t()`, and the WebAuthn functions. # Forms and stencil diff --git a/llms.txt b/llms.txt index 36f3ec3..769b84b 100644 --- a/llms.txt +++ b/llms.txt @@ -17,6 +17,7 @@ Paths are relative to this file. In a project, that's vendor/javaabu/auth/. - API tokens: the model's `HasApiTokens` (Sanctum or Passport) decides the driver; set `config('javaabu-auth.api')` with `php artisan auth:install --api=…`. Sanctum token logins come from `Route::authApiRoutes()`. - Account actions (unlock, reset 2FA, require a new password, log out everywhere) come from `Route::userAccountActions()`, behind `auth.*` gate abilities taking `[$type, $account]` and the `{action}_{type plural}` permissions; run `php artisan permissions:sync`. - Pages render with `config('javaabu-auth.views')` (`blade` or `inertia`, per type with `views`). Blade pages extend `javaabu-auth.layouts.auth` / `.account`. React pages are `auth::Login`, …, `auth::Settings/Security`, resolved with javaabu/manifest's `resolvePage()`. Override with `vendor:publish --tag=auth-views` or `php artisan manifest:override auth `. +- Packages add links to the account navigation with `UserTypes::accountLink('mobile-number', 'Mobile number')` (every type that has the route) or `$type->accountLink(…)`; pages get them as `account_links`. Render package account pages with the type's `account` layout and `PageData::shared()` props. - Every user-facing string is an English `__()` key with Dhivehi in `lang/dv.json`; app overrides go in the app's `lang/dv.json`. Use logical utilities (`ms-`, `text-start`) only. ## Docs @@ -40,6 +41,7 @@ Paths are relative to this file. In a project, that's vendor/javaabu/auth/. - [Pages (Blade and React)](docs/basic-usage/pages.md): every page and its props, Blade layouts, setting up Inertia React, overriding, exported components, RTL - [Managing accounts](docs/basic-usage/managing-accounts.md): Route::userAccountActions(), permissions, the policy and overriding it - [Unique Gmail rule](docs/basic-usage/unique-gmail-rule.md): the UniqueGmail validation rule +- [For package authors](docs/advanced-usage/for-package-authors.md): account pages per user type, account navigation links, rendering like auth's pages, rules - [Alternative login methods](docs/advanced-usage/alternative-login-methods.md): the LoginMethod contract and StartAuthenticatedSession - [Actions](docs/advanced-usage/actions.md): the action contracts, the login actions, responses - [Overriding controllers and views](docs/advanced-usage/overriding-controllers-and-views.md): controllers from config, Blade views, React pages, per-type stacks and layouts diff --git a/resources/boost/skills/javaabu-auth/SKILL.md b/resources/boost/skills/javaabu-auth/SKILL.md index 22a723f..7731981 100644 --- a/resources/boost/skills/javaabu-auth/SKILL.md +++ b/resources/boost/skills/javaabu-auth/SKILL.md @@ -82,6 +82,7 @@ Route names get the type's `route_name` prefix (`admin.login`, `admin.password.r - Passkeys need the `passkeys` feature, `Concerns\HasPasskeys` + `Laravel\Passkeys\Contracts\PasskeyUser` on the model, and the package's `passkeys` table (with `user_type`). Don't publish laravel/passkeys' own migration or routes. - API tokens: the model's `HasApiTokens` trait (Sanctum or Passport) picks the driver; create tokens with `Javaabu\Auth\Support\ApiTokens::create()`. Mobile token logins come from `Route::authApiRoutes('user')` (Sanctum). - Admin actions on other users (unlock, reset two-factor, require a new password, log out everywhere) come from `Route::userAccountActions('user', ['middleware' => ['web', 'auth.type:admin']])`, behind the `auth.*` abilities and the `{action}_{type plural}` permissions. Run `php artisan permissions:sync`. +- A page in the account area (a package's or the app's) goes behind `auth.type:{type}`, uses the type's route name prefix, and is added to the account navigation with `UserTypes::accountLink('key', 'English label')` or `$type->accountLink(…)`; don't edit the navigation partial. See `docs/advanced-usage/for-package-authors.md`. - Every user-facing string goes through `__()` with an English key; add the Dhivehi to `lang/dv.json`. In overridden views use logical utilities (`ms-`, `text-start`), `dir="ltr"` on email, password and code inputs, `dir="auto"` on names. See [references/common-mistakes.md](references/common-mistakes.md) for what goes wrong, and [references/testing.md](references/testing.md) for tests. diff --git a/resources/js/Pages/Settings/Profile.tsx b/resources/js/Pages/Settings/Profile.tsx index 03f4710..e7c3e01 100644 --- a/resources/js/Pages/Settings/Profile.tsx +++ b/resources/js/Pages/Settings/Profile.tsx @@ -6,9 +6,9 @@ import { t } from "../../lib/strings"; import type { ProfilePageProps } from "../../types"; /** auth::Settings/Profile */ -export default function Profile({ auth_type, urls, strings, status, profile, must_verify_email, can_delete_account }: ProfilePageProps) { +export default function Profile({ auth_type, urls, strings, account_links, status, profile, must_verify_email, can_delete_account }: ProfilePageProps) { return ( - + diff --git a/resources/js/Pages/Settings/Security.tsx b/resources/js/Pages/Settings/Security.tsx index 3da81a3..9258739 100644 --- a/resources/js/Pages/Settings/Security.tsx +++ b/resources/js/Pages/Settings/Security.tsx @@ -9,9 +9,9 @@ import { t } from "../../lib/strings"; import type { SecurityPageProps } from "../../types"; /** auth::Settings/Security */ -export default function Security({ urls, strings, status, two_factor, passkeys, sessions, api_tokens, plain_text_token }: SecurityPageProps) { +export default function Security({ urls, strings, account_links, status, two_factor, passkeys, sessions, api_tokens, plain_text_token }: SecurityPageProps) { return ( - + diff --git a/resources/js/components/AccountLayout.tsx b/resources/js/components/AccountLayout.tsx index e80461b..87cdbee 100644 --- a/resources/js/components/AccountLayout.tsx +++ b/resources/js/components/AccountLayout.tsx @@ -1,32 +1,53 @@ import { Form, LinkButton, Submit } from "@javaabu/forms"; import type { ReactNode } from "react"; import { t } from "../lib/strings"; -import type { AuthPageProps } from "../types"; +import type { AccountLink, AuthPageProps } from "../types"; export interface AccountLayoutProps extends Pick { title: string; page: string; + /** The `account_links` prop: Profile, Security and the links packages added. Without it, Profile and Security from `urls` */ + links?: AccountLink[]; children: ReactNode; } -/** The heading and account navigation of the profile and security pages */ -export function AccountLayout({ title, page, urls, strings, children }: AccountLayoutProps) { +/** The links from `urls` when a page has no `account_links` prop */ +function fallbackLinks(urls: AuthPageProps["urls"], strings: AuthPageProps["strings"]): AccountLink[] { + const links: AccountLink[] = []; + + if (urls["profile.edit"]) { + links.push({ key: "profile", label: t(strings, "profile"), url: urls["profile.edit"], active: false }); + } + + if (urls["security.edit"]) { + links.push({ key: "security", label: t(strings, "security"), url: urls["security.edit"], active: false }); + } + + return links; +} + +/** The heading and account navigation of the profile, security and package account pages */ +export function AccountLayout({ title, page, urls, strings, links, children }: AccountLayoutProps) { + const navigation = links ?? fallbackLinks(urls, strings); + return (

{title}