From 9be3e167f18f9e83effa2491d981705e91cb966e Mon Sep 17 00:00:00 2001 From: BinadaPasandul Date: Sun, 2 Aug 2026 22:02:30 +0530 Subject: [PATCH 1/2] feat: user management development --- .github/workflows/backend-ci.yml | 26 +- .github/workflows/backend-pr.yml | 9 + .github/workflows/frontend-ci.yml | 18 +- .github/workflows/frontend-pr.yml | 9 + backend/.gitignore | 8 +- backend/Directory.Build.props | 3 + backend/Directory.Packages.props | 9 +- backend/global.json | 2 +- .../Contracts/Auth/AuthRequests.cs | 24 + .../Contracts/Users/UserRequests.cs | 26 + .../Endpoints/AuthEndpoints.cs | 123 + .../Endpoints/UserEndpoints.cs | 133 + .../Extensions/AuthenticationExtensions.cs | 111 + .../Extensions/ResultExtensions.cs | 57 + .../Middleware/GlobalExceptionMiddleware.cs | 47 +- .../PasswordChangeRequiredMiddleware.cs | 46 + backend/src/RestaurantPOS.API/Program.cs | 83 +- .../RestaurantPOS.API.csproj | 1 + .../RestaurantPOS.API/RestaurantPOS.API.http | 60 +- .../AllowPendingPasswordChangeAttribute.cs | 8 + .../Security/AuthorizationPolicies.cs | 42 + .../Security/HttpCurrentUser.cs | 25 + .../src/RestaurantPOS.API/appsettings.json | 17 +- .../ChangePassword/ChangePasswordCommand.cs | 82 + .../ClearApprovalPinCommand.cs | 43 + .../Commands/Login/LoginCommand.cs | 72 + .../Commands/Logout/LogoutCommand.cs | 44 + .../RefreshSession/RefreshSessionCommand.cs | 69 + .../SetApprovalPin/SetApprovalPinCommand.cs | 91 + .../VerifyApprovalPinCommand.cs | 70 + .../Authentication/Common/SessionFactory.cs | 34 + .../Dtos/AuthenticationResult.cs | 23 + .../GetCurrentUser/GetCurrentUserQuery.cs | 39 + .../Common/Behaviors/LoggingBehavior.cs | 63 + .../Common/Behaviors/ValidationBehavior.cs | 44 + .../Exceptions/ValidationAppException.cs | 47 + .../Common/Interfaces/IAppDbContext.cs | 21 + .../Common/Interfaces/ICurrentUser.cs | 12 + .../Common/Interfaces/IDateTimeProvider.cs | 7 + .../Common/Interfaces/IPasswordHasher.cs | 14 + .../Common/Interfaces/ITokenService.cs | 27 + .../Common/Mappings/UserMappings.cs | 29 + .../Common/Security/AppClaimTypes.cs | 17 + .../Common/Security/PasswordPolicy.cs | 38 + .../DependencyInjection.cs | 18 +- .../RestaurantPOS.Application.csproj | 3 + .../Commands/CreateUser/CreateUserCommand.cs | 87 + .../ResetUserPasswordCommand.cs | 55 + .../SetUserActive/SetUserActiveCommand.cs | 82 + .../Commands/UpdateUser/UpdateUserCommand.cs | 101 + .../Users/Common/ModuleRules.cs | 23 + .../Users/Dtos/UserDto.cs | 36 + .../Queries/GetModules/GetModulesQuery.cs | 47 + .../Queries/GetUserById/GetUserByIdQuery.cs | 30 + .../Users/Queries/GetUsers/GetUsersQuery.cs | 61 + .../RestaurantPOS.Domain/Common/BaseEntity.cs | 2 +- .../src/RestaurantPOS.Domain/Common/Error.cs | 28 +- .../Common/IDomainEvent.cs | 2 +- .../src/RestaurantPOS.Domain/Common/Result.cs | 2 +- .../Entities/RefreshToken.cs | 40 + .../src/RestaurantPOS.Domain/Entities/User.cs | 301 ++ .../Entities/UserModulePermission.cs | 33 + .../RestaurantPOS.Domain/Enums/AppModule.cs | 21 + .../RestaurantPOS.Domain/Enums/UserRole.cs | 14 + .../RestaurantPOS.Domain/Errors/AuthErrors.cs | 40 + .../RestaurantPOS.Domain/Errors/UserErrors.cs | 37 + .../Modules/ModuleCatalog.cs | 80 + .../Modules/ModuleDescriptor.cs | 24 + .../Clock/SystemDateTimeProvider.cs | 8 + .../DependencyInjection.cs | 40 +- .../Identity/BCryptPasswordHasher.cs | 41 + .../Identity/JwtOptions.cs | 36 + .../Identity/JwtSigningKeyProvider.cs | 65 + .../Identity/JwtTokenService.cs | 93 + .../Persistence/AppDbContext.cs | 12 +- .../RefreshTokenConfiguration.cs | 33 + .../Configurations/UserConfiguration.cs | 72 + .../UserModulePermissionConfiguration.cs | 25 + .../AuditableEntityInterceptor.cs | 64 + ...02081447_InitialUserManagement.Designer.cs | 158 + .../20260802081447_InitialUserManagement.cs | 111 + .../Migrations/AppDbContextModelSnapshot.cs | 155 + .../Persistence/Seeding/DatabaseSeeder.cs | 72 + .../Persistence/Seeding/SeedAdminOptions.cs | 24 + .../RestaurantPOS.Infrastructure.csproj | 4 + .../LayerDependencyTests.cs | 2 + .../Authentication/ApprovalPinTests.cs | 154 + .../Authentication/AuthenticationTests.cs | 164 + .../Common/IntegrationTestBase.cs | 86 + .../Common/PosApiClient.cs | 161 + .../CustomWebApplicationFactory.cs | 79 +- .../HealthCheckTests.cs | 3 + .../RestaurantPOS.IntegrationTests.csproj | 1 - .../Users/UserManagementTests.cs | 235 ++ .../Application/ApprovalPinValidationTests.cs | 47 + .../CreateUserCommandValidatorTests.cs | 79 + .../Domain/ModuleCatalogTests.cs | 69 + .../Domain/UserTests.cs | 193 + .../RestaurantPOS.UnitTests/SmokeTests.cs | 1 + docs/user-management.md | 163 + frontend/.husky/commit-msg | 1 - frontend/.husky/pre-commit | 1 - frontend/commitlint.config.cjs | 3 - frontend/lint-staged.config.mjs | 9 - frontend/package-lock.json | 3707 ++++++++--------- frontend/package.json | 21 +- frontend/src/app/App.tsx | 35 +- frontend/src/app/globals.css | 113 +- frontend/src/app/routing/RequireAuth.tsx | 29 + frontend/src/app/routing/RequireGuest.tsx | 23 + frontend/src/app/routing/RequireModule.tsx | 20 + frontend/src/app/routing/router.tsx | 55 + frontend/src/entities/user/index.ts | 13 + .../src/entities/user/model/permissions.ts | 41 + frontend/src/entities/user/model/types.ts | 81 + frontend/src/features/auth/api/authApi.ts | 32 + frontend/src/features/auth/index.ts | 18 + frontend/src/features/auth/model/authSlice.ts | 50 +- .../src/features/auth/model/useApprovalPin.ts | 46 + frontend/src/features/auth/model/useAuth.ts | 108 + frontend/src/features/users/api/usersApi.ts | 24 + frontend/src/features/users/index.ts | 5 + frontend/src/features/users/model/useUsers.ts | 47 + .../src/features/users/model/userSchema.ts | 86 + .../users/ui/ModulePermissionPicker.tsx | 135 + .../features/users/ui/ResetPasswordDialog.tsx | 107 + .../src/features/users/ui/UserFormDialog.tsx | 313 ++ .../src/pages/account/ApprovalPinCard.tsx | 165 + frontend/src/pages/account/index.tsx | 140 + frontend/src/pages/change-password/index.tsx | 104 + frontend/src/pages/dashboard/index.tsx | 66 +- frontend/src/pages/login/index.tsx | 74 +- frontend/src/pages/users/index.tsx | 225 + frontend/src/shared/api/axiosClient.ts | 22 +- frontend/src/shared/api/endpoints/index.ts | 44 +- .../api/interceptors/authInterceptor.ts | 8 +- .../api/interceptors/errorInterceptor.ts | 19 +- .../interceptors/refreshTokenInterceptor.ts | 148 +- frontend/src/shared/api/problem.ts | 90 + frontend/src/shared/api/tokenStorage.ts | 34 + frontend/src/shared/config/moduleRoutes.ts | 51 + frontend/src/shared/store/index.ts | 4 - frontend/src/shared/theme/index.ts | 40 +- frontend/src/shared/ui/alert.tsx | 51 + frontend/src/shared/ui/badge.tsx | 32 + frontend/src/shared/ui/button.tsx | 70 + frontend/src/shared/ui/card.tsx | 55 + frontend/src/shared/ui/checkbox.tsx | 27 + frontend/src/shared/ui/dialog.tsx | 105 + frontend/src/shared/ui/dropdown-menu.tsx | 78 + frontend/src/shared/ui/form-field.tsx | 63 + frontend/src/shared/ui/index.ts | 43 +- frontend/src/shared/ui/input.tsx | 25 + frontend/src/shared/ui/label.tsx | 20 + frontend/src/shared/ui/select.tsx | 84 + frontend/src/shared/ui/separator.tsx | 23 + frontend/src/shared/ui/states.tsx | 42 + frontend/src/shared/ui/switch.tsx | 29 + frontend/src/shared/ui/table.tsx | 64 + frontend/src/widgets/app-shell/AppShell.tsx | 26 + frontend/src/widgets/app-shell/Sidebar.tsx | 88 + frontend/src/widgets/app-shell/Topbar.tsx | 57 + frontend/tailwind.config.ts | 79 +- frontend/tests/components/LoginPage.test.tsx | 51 + .../ModulePermissionPicker.test.tsx | 75 + frontend/tests/components/example.test.tsx | 13 - frontend/tests/integration/example.test.ts | 9 - frontend/tests/mocks/fixtures.ts | 76 + frontend/tests/mocks/handlers.ts | 26 +- .../unit/entities/user-permissions.test.ts | 67 + .../tests/unit/features/userSchema.test.ts | 102 + frontend/tests/utils/render.tsx | 11 +- 172 files changed, 10928 insertions(+), 2310 deletions(-) create mode 100644 backend/src/RestaurantPOS.API/Contracts/Auth/AuthRequests.cs create mode 100644 backend/src/RestaurantPOS.API/Contracts/Users/UserRequests.cs create mode 100644 backend/src/RestaurantPOS.API/Endpoints/AuthEndpoints.cs create mode 100644 backend/src/RestaurantPOS.API/Endpoints/UserEndpoints.cs create mode 100644 backend/src/RestaurantPOS.API/Extensions/AuthenticationExtensions.cs create mode 100644 backend/src/RestaurantPOS.API/Extensions/ResultExtensions.cs create mode 100644 backend/src/RestaurantPOS.API/Middleware/PasswordChangeRequiredMiddleware.cs create mode 100644 backend/src/RestaurantPOS.API/Security/AllowPendingPasswordChangeAttribute.cs create mode 100644 backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs create mode 100644 backend/src/RestaurantPOS.API/Security/HttpCurrentUser.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Commands/ChangePassword/ChangePasswordCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Commands/ClearApprovalPin/ClearApprovalPinCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Commands/Login/LoginCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Commands/Logout/LogoutCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Commands/RefreshSession/RefreshSessionCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Commands/SetApprovalPin/SetApprovalPinCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Commands/VerifyApprovalPin/VerifyApprovalPinCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Common/SessionFactory.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Dtos/AuthenticationResult.cs create mode 100644 backend/src/RestaurantPOS.Application/Authentication/Queries/GetCurrentUser/GetCurrentUserQuery.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Behaviors/LoggingBehavior.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Behaviors/ValidationBehavior.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Exceptions/ValidationAppException.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Interfaces/ICurrentUser.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Interfaces/IDateTimeProvider.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Interfaces/IPasswordHasher.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Interfaces/ITokenService.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Mappings/UserMappings.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Security/AppClaimTypes.cs create mode 100644 backend/src/RestaurantPOS.Application/Common/Security/PasswordPolicy.cs create mode 100644 backend/src/RestaurantPOS.Application/Users/Commands/CreateUser/CreateUserCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Users/Commands/ResetUserPassword/ResetUserPasswordCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Users/Commands/SetUserActive/SetUserActiveCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs create mode 100644 backend/src/RestaurantPOS.Application/Users/Common/ModuleRules.cs create mode 100644 backend/src/RestaurantPOS.Application/Users/Dtos/UserDto.cs create mode 100644 backend/src/RestaurantPOS.Application/Users/Queries/GetModules/GetModulesQuery.cs create mode 100644 backend/src/RestaurantPOS.Application/Users/Queries/GetUserById/GetUserByIdQuery.cs create mode 100644 backend/src/RestaurantPOS.Application/Users/Queries/GetUsers/GetUsersQuery.cs create mode 100644 backend/src/RestaurantPOS.Domain/Entities/RefreshToken.cs create mode 100644 backend/src/RestaurantPOS.Domain/Entities/User.cs create mode 100644 backend/src/RestaurantPOS.Domain/Entities/UserModulePermission.cs create mode 100644 backend/src/RestaurantPOS.Domain/Enums/AppModule.cs create mode 100644 backend/src/RestaurantPOS.Domain/Enums/UserRole.cs create mode 100644 backend/src/RestaurantPOS.Domain/Errors/AuthErrors.cs create mode 100644 backend/src/RestaurantPOS.Domain/Errors/UserErrors.cs create mode 100644 backend/src/RestaurantPOS.Domain/Modules/ModuleCatalog.cs create mode 100644 backend/src/RestaurantPOS.Domain/Modules/ModuleDescriptor.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Clock/SystemDateTimeProvider.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Identity/BCryptPasswordHasher.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Identity/JwtOptions.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Identity/JwtSigningKeyProvider.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Identity/JwtTokenService.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserConfiguration.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserModulePermissionConfiguration.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.Designer.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs create mode 100644 backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/SeedAdminOptions.cs create mode 100644 backend/tests/RestaurantPOS.IntegrationTests/Authentication/ApprovalPinTests.cs create mode 100644 backend/tests/RestaurantPOS.IntegrationTests/Authentication/AuthenticationTests.cs create mode 100644 backend/tests/RestaurantPOS.IntegrationTests/Common/IntegrationTestBase.cs create mode 100644 backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs create mode 100644 backend/tests/RestaurantPOS.IntegrationTests/Users/UserManagementTests.cs create mode 100644 backend/tests/RestaurantPOS.UnitTests/Application/ApprovalPinValidationTests.cs create mode 100644 backend/tests/RestaurantPOS.UnitTests/Application/CreateUserCommandValidatorTests.cs create mode 100644 backend/tests/RestaurantPOS.UnitTests/Domain/ModuleCatalogTests.cs create mode 100644 backend/tests/RestaurantPOS.UnitTests/Domain/UserTests.cs create mode 100644 docs/user-management.md delete mode 100644 frontend/.husky/commit-msg delete mode 100644 frontend/.husky/pre-commit delete mode 100644 frontend/commitlint.config.cjs delete mode 100644 frontend/lint-staged.config.mjs create mode 100644 frontend/src/app/routing/RequireAuth.tsx create mode 100644 frontend/src/app/routing/RequireGuest.tsx create mode 100644 frontend/src/app/routing/RequireModule.tsx create mode 100644 frontend/src/app/routing/router.tsx create mode 100644 frontend/src/entities/user/index.ts create mode 100644 frontend/src/entities/user/model/permissions.ts create mode 100644 frontend/src/entities/user/model/types.ts create mode 100644 frontend/src/features/auth/api/authApi.ts create mode 100644 frontend/src/features/auth/index.ts create mode 100644 frontend/src/features/auth/model/useApprovalPin.ts create mode 100644 frontend/src/features/auth/model/useAuth.ts create mode 100644 frontend/src/features/users/api/usersApi.ts create mode 100644 frontend/src/features/users/index.ts create mode 100644 frontend/src/features/users/model/useUsers.ts create mode 100644 frontend/src/features/users/model/userSchema.ts create mode 100644 frontend/src/features/users/ui/ModulePermissionPicker.tsx create mode 100644 frontend/src/features/users/ui/ResetPasswordDialog.tsx create mode 100644 frontend/src/features/users/ui/UserFormDialog.tsx create mode 100644 frontend/src/pages/account/ApprovalPinCard.tsx create mode 100644 frontend/src/pages/account/index.tsx create mode 100644 frontend/src/pages/change-password/index.tsx create mode 100644 frontend/src/pages/users/index.tsx create mode 100644 frontend/src/shared/api/problem.ts create mode 100644 frontend/src/shared/api/tokenStorage.ts create mode 100644 frontend/src/shared/config/moduleRoutes.ts create mode 100644 frontend/src/shared/ui/alert.tsx create mode 100644 frontend/src/shared/ui/badge.tsx create mode 100644 frontend/src/shared/ui/button.tsx create mode 100644 frontend/src/shared/ui/card.tsx create mode 100644 frontend/src/shared/ui/checkbox.tsx create mode 100644 frontend/src/shared/ui/dialog.tsx create mode 100644 frontend/src/shared/ui/dropdown-menu.tsx create mode 100644 frontend/src/shared/ui/form-field.tsx create mode 100644 frontend/src/shared/ui/input.tsx create mode 100644 frontend/src/shared/ui/label.tsx create mode 100644 frontend/src/shared/ui/select.tsx create mode 100644 frontend/src/shared/ui/separator.tsx create mode 100644 frontend/src/shared/ui/states.tsx create mode 100644 frontend/src/shared/ui/switch.tsx create mode 100644 frontend/src/shared/ui/table.tsx create mode 100644 frontend/src/widgets/app-shell/AppShell.tsx create mode 100644 frontend/src/widgets/app-shell/Sidebar.tsx create mode 100644 frontend/src/widgets/app-shell/Topbar.tsx create mode 100644 frontend/tests/components/LoginPage.test.tsx create mode 100644 frontend/tests/components/ModulePermissionPicker.test.tsx delete mode 100644 frontend/tests/components/example.test.tsx delete mode 100644 frontend/tests/integration/example.test.ts create mode 100644 frontend/tests/mocks/fixtures.ts create mode 100644 frontend/tests/unit/entities/user-permissions.test.ts create mode 100644 frontend/tests/unit/features/userSchema.test.ts diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index efe5bac..f7fc467 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -7,9 +7,23 @@ on: - 'backend/**' - '.github/workflows/backend-ci.yml' +# A newer push to the same branch makes an in-flight run redundant. +concurrency: + group: backend-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: build-and-test: runs-on: ubuntu-latest + timeout-minutes: 20 + + # Exposed at job level so steps can skip Sonar when the secret is absent, which is what + # happens on a fork or before the token has been configured. + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }} steps: - name: Checkout code @@ -23,17 +37,18 @@ jobs: global-json-file: backend/global.json - name: Setup Java (required by Sonar scanner) + if: env.SONAR_TOKEN != '' uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - name: Install SonarCloud scanner + if: env.SONAR_TOKEN != '' run: dotnet tool install --global dotnet-sonarscanner - name: Begin SonarCloud analysis - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }} + if: env.SONAR_TOKEN != '' run: | dotnet sonarscanner begin \ /k:"RidentIT_RestaurantPOS" \ @@ -72,13 +87,14 @@ jobs: working-directory: backend - name: End SonarCloud analysis - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACKEND }} + if: env.SONAR_TOKEN != '' run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN_BACKEND }}" working-directory: backend - name: Upload coverage report + if: always() uses: actions/upload-artifact@v4 with: name: backend-coverage - path: backend/**/TestResults/**/coverage.opencover.xml \ No newline at end of file + path: backend/**/TestResults/**/coverage.opencover.xml + if-no-files-found: warn diff --git a/.github/workflows/backend-pr.yml b/.github/workflows/backend-pr.yml index 372e145..cc6c99d 100644 --- a/.github/workflows/backend-pr.yml +++ b/.github/workflows/backend-pr.yml @@ -8,9 +8,18 @@ on: - 'backend/**' - '.github/workflows/backend-pr.yml' +concurrency: + group: backend-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + jobs: pr-validation: runs-on: ubuntu-latest + timeout-minutes: 20 # Expose secret as job-level env so steps can check if it is set env: diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index d31c992..664096d 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -7,9 +7,20 @@ on: - 'frontend/**' - '.github/workflows/frontend-ci.yml' +concurrency: + group: frontend-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: build-and-test: runs-on: ubuntu-latest + timeout-minutes: 25 + + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }} steps: - name: Checkout code @@ -41,11 +52,14 @@ jobs: working-directory: frontend - name: Upload Coverage to Artifacts + if: always() uses: actions/upload-artifact@v4 with: name: frontend-coverage path: frontend/coverage + if-no-files-found: warn + # Playwright serves the built output via `vite preview`, so the build must come first. - name: Build Application (Vite) run: npm run build working-directory: frontend @@ -64,11 +78,13 @@ jobs: with: name: playwright-report path: frontend/playwright-report + if-no-files-found: ignore - name: SonarCloud Main Analysis + if: env.SONAR_TOKEN != '' uses: SonarSource/sonarcloud-github-action@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }} with: - projectBaseDir: frontend \ No newline at end of file + projectBaseDir: frontend diff --git a/.github/workflows/frontend-pr.yml b/.github/workflows/frontend-pr.yml index bc81d68..49fe827 100644 --- a/.github/workflows/frontend-pr.yml +++ b/.github/workflows/frontend-pr.yml @@ -8,9 +8,18 @@ on: - 'frontend/**' - '.github/workflows/frontend-pr.yml' +concurrency: + group: frontend-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + jobs: pr-validation: runs-on: ubuntu-latest + timeout-minutes: 20 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONTEND }} diff --git a/backend/.gitignore b/backend/.gitignore index bd3015d..984e33e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -107,7 +107,8 @@ StyleCopReport.xml *.scc logs/ *.db -*.db +*.db-shm +*.db-wal integrationtests.db # Chutzpah Test files @@ -484,3 +485,8 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# Auto-generated JWT signing key (see JwtSigningKeyProvider). +# Machine-local secret: never commit it, and never share it between installations. +keys/ +*.key diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index 7808224..9ad9453 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -1,6 +1,9 @@ net9.0 + + Major enable enable true diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index dbd0fa6..35ea973 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -13,17 +13,22 @@ - - + + + + + + + diff --git a/backend/global.json b/backend/global.json index 065c16b..7ac218c 100644 --- a/backend/global.json +++ b/backend/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "9.0.311", - "rollForward": "latestFeature" + "rollForward": "latestMajor" } } \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Auth/AuthRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Auth/AuthRequests.cs new file mode 100644 index 0000000..d5c7868 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Auth/AuthRequests.cs @@ -0,0 +1,24 @@ +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.API.Contracts.Auth; + +/// Sign-in credentials. +public sealed record LoginRequest(string Username, string Password); + +/// Exchanges a refresh token for a new session. +public sealed record RefreshRequest(string RefreshToken); + +/// Ends a session. The token is optional so sign-out never fails. +public sealed record LogoutRequest(string? RefreshToken); + +/// Changes the signed-in user's own password. +public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword); + +/// +/// Sets the signed-in administrator's approval PIN. Leave null to have +/// the server generate a random -digit PIN. +/// +public sealed record SetApprovalPinRequest(string CurrentPassword, string? Pin); + +/// Presents a PIN for authorisation of a privileged action. +public sealed record VerifyApprovalPinRequest(string Pin, string? Reason); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Contracts/Users/UserRequests.cs b/backend/src/RestaurantPOS.API/Contracts/Users/UserRequests.cs new file mode 100644 index 0000000..cf4f41b --- /dev/null +++ b/backend/src/RestaurantPOS.API/Contracts/Users/UserRequests.cs @@ -0,0 +1,26 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Contracts.Users; + +/// Creates a staff account. +/// Ignored for the Admin role, which holds every module. +public sealed record CreateUserRequest( + string Username, + string FullName, + string? Email, + string Password, + UserRole Role, + IReadOnlyCollection? Modules); + +/// Updates a staff account's profile, role and module grants. +public sealed record UpdateUserRequest( + string FullName, + string? Email, + UserRole Role, + IReadOnlyCollection? Modules); + +/// Sets a temporary password that the user must then change. +public sealed record ResetUserPasswordRequest(string NewPassword); + +/// Enables or disables a staff account. +public sealed record SetUserActiveRequest(bool IsActive); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/AuthEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..5f12a46 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/AuthEndpoints.cs @@ -0,0 +1,123 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Auth; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Authentication.Commands.ChangePassword; +using RestaurantPOS.Application.Authentication.Commands.ClearApprovalPin; +using RestaurantPOS.Application.Authentication.Commands.Login; +using RestaurantPOS.Application.Authentication.Commands.Logout; +using RestaurantPOS.Application.Authentication.Commands.RefreshSession; +using RestaurantPOS.Application.Authentication.Commands.SetApprovalPin; +using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; +using RestaurantPOS.Application.Authentication.Queries.GetCurrentUser; + +namespace RestaurantPOS.API.Endpoints; + +/// Sign-in, session lifecycle and the administrator approval PIN. +public static class AuthEndpoints +{ + /// Rate-limiter policy guarding endpoints that accept a guessable secret. + public const string SensitiveRateLimitPolicy = "sensitive"; + + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + var group = routes.MapGroup("/auth").WithTags("Authentication"); + + group.MapPost("/login", async (LoginRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new LoginCommand(request.Username, request.Password), ct); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .RequireRateLimiting(SensitiveRateLimitPolicy) + .WithName("Login") + .WithSummary("Signs in with a username and password."); + + group.MapPost("/refresh", async (RefreshRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new RefreshSessionCommand(request.RefreshToken), ct); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("RefreshSession") + .WithSummary("Exchanges a refresh token for a new session."); + + group.MapPost("/logout", async (LogoutRequest request, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new LogoutCommand(request.RefreshToken), ct); + return result.ToHttpResult(); + }) + .AllowAnonymous() + .WithName("Logout") + .WithSummary("Revokes a refresh token."); + + group.MapGet("/me", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetCurrentUserQuery(), ct); + return result.ToHttpResult(); + }) + .RequireAuthorization() + // Reachable mid-reset so the client can render the user's name on that screen. + .WithMetadata(new AllowPendingPasswordChangeAttribute()) + .WithName("GetCurrentUser") + .WithSummary("Returns the signed-in user and their effective module access."); + + group.MapPost("/change-password", + async (ChangePasswordRequest request, ISender sender, CancellationToken ct) => + { + var command = new ChangePasswordCommand(request.CurrentPassword, request.NewPassword); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .RequireAuthorization() + // The whole point of this endpoint is to clear the pending-change state. + .WithMetadata(new AllowPendingPasswordChangeAttribute()) + .RequireRateLimiting(SensitiveRateLimitPolicy) + .WithName("ChangePassword") + .WithSummary("Changes the signed-in user's password and returns a fresh session."); + + MapApprovalPinEndpoints(group); + + return routes; + } + + private static void MapApprovalPinEndpoints(RouteGroupBuilder group) + { + group.MapPost("/pin", async (SetApprovalPinRequest request, ISender sender, CancellationToken ct) => + { + var command = new SetApprovalPinCommand(request.CurrentPassword, request.Pin); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .RequireAuthorization(AuthorizationPolicies.AdminOnly) + .RequireRateLimiting(SensitiveRateLimitPolicy) + .WithName("SetApprovalPin") + .WithSummary("Sets or generates the administrator's 4-digit approval PIN."); + + group.MapDelete("/pin", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new ClearApprovalPinCommand(), ct); + return result.ToHttpResult(); + }) + .RequireAuthorization(AuthorizationPolicies.AdminOnly) + .WithName("ClearApprovalPin") + .WithSummary("Removes the administrator's approval PIN."); + + group.MapPost("/pin/verify", + async (VerifyApprovalPinRequest request, ISender sender, CancellationToken ct) => + { + var command = new VerifyApprovalPinCommand(request.Pin, request.Reason); + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + // Any signed-in user may present a PIN: the point is that a cashier calls this with + // an administrator standing over their shoulder to authorise, say, a void. + .RequireAuthorization() + .RequireRateLimiting(SensitiveRateLimitPolicy) + .WithName("VerifyApprovalPin") + .WithSummary("Authorises a privileged action with an administrator's PIN."); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Endpoints/UserEndpoints.cs b/backend/src/RestaurantPOS.API/Endpoints/UserEndpoints.cs new file mode 100644 index 0000000..5745098 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Endpoints/UserEndpoints.cs @@ -0,0 +1,133 @@ +using MediatR; + +using RestaurantPOS.API.Contracts.Users; +using RestaurantPOS.API.Extensions; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Users.Commands.CreateUser; +using RestaurantPOS.Application.Users.Commands.ResetUserPassword; +using RestaurantPOS.Application.Users.Commands.SetUserActive; +using RestaurantPOS.Application.Users.Commands.UpdateUser; +using RestaurantPOS.Application.Users.Queries.GetModules; +using RestaurantPOS.Application.Users.Queries.GetUserById; +using RestaurantPOS.Application.Users.Queries.GetUsers; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Endpoints; + +/// Staff account administration. Every endpoint here requires the Admin role. +public static class UserEndpoints +{ + public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + var group = routes.MapGroup("/users") + .WithTags("Users") + // Creating accounts and assigning modules is itself an administrative power, so it + // is gated on the role rather than on a grantable module. + .RequireAuthorization(AuthorizationPolicies.AdminOnly); + + group.MapGet("/", async ( + string? search, + UserRole? role, + bool? isActive, + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(new GetUsersQuery(search, role, isActive), ct); + return result.ToHttpResult(); + }) + .WithName("GetUsers") + .WithSummary("Lists staff accounts, optionally filtered."); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetUserByIdQuery(id), ct); + return result.ToHttpResult(); + }) + .WithName("GetUserById") + .WithSummary("Loads a single staff account."); + + group.MapPost("/", async (CreateUserRequest request, ISender sender, CancellationToken ct) => + { + var command = new CreateUserCommand( + request.Username, + request.FullName, + request.Email, + request.Password, + request.Role, + request.Modules); + + var result = await sender.Send(command, ct); + return result.ToCreatedResult(user => $"/api/v1/users/{user.Id}"); + }) + .WithName("CreateUser") + .WithSummary("Creates a staff account that must change its password at first sign-in."); + + group.MapPut("/{id:guid}", async ( + Guid id, + UpdateUserRequest request, + ISender sender, + CancellationToken ct) => + { + var command = new UpdateUserCommand( + id, + request.FullName, + request.Email, + request.Role, + request.Modules); + + var result = await sender.Send(command, ct); + return result.ToHttpResult(); + }) + .WithName("UpdateUser") + .WithSummary("Updates a staff account's profile, role and module grants."); + + group.MapPut("/{id:guid}/status", async ( + Guid id, + SetUserActiveRequest request, + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(new SetUserActiveCommand(id, request.IsActive), ct); + return result.ToHttpResult(); + }) + .WithName("SetUserActive") + .WithSummary("Activates or deactivates a staff account."); + + group.MapPost("/{id:guid}/password", async ( + Guid id, + ResetUserPasswordRequest request, + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(new ResetUserPasswordCommand(id, request.NewPassword), ct); + return result.ToHttpResult(); + }) + .WithName("ResetUserPassword") + .WithSummary("Sets a temporary password the user must then change."); + + return routes; + } + + /// + /// Exposes the module catalog. Readable by any signed-in user because the client builds its + /// navigation from it; the list itself is not sensitive. + /// + public static IEndpointRouteBuilder MapModuleEndpoints(this IEndpointRouteBuilder routes) + { + ArgumentNullException.ThrowIfNull(routes); + + routes.MapGet("/modules", async (ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetModulesQuery(), ct); + return result.ToHttpResult(); + }) + .WithTags("Modules") + .RequireAuthorization() + .WithName("GetModules") + .WithSummary("Returns every module that can appear in navigation or be granted."); + + return routes; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Extensions/AuthenticationExtensions.cs b/backend/src/RestaurantPOS.API/Extensions/AuthenticationExtensions.cs new file mode 100644 index 0000000..490d56b --- /dev/null +++ b/backend/src/RestaurantPOS.API/Extensions/AuthenticationExtensions.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using System.Threading.RateLimiting; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +using RestaurantPOS.API.Endpoints; +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Infrastructure.Identity; + +namespace RestaurantPOS.API.Extensions; + +/// Wires up bearer authentication, the module policies and abuse protection. +public static class AuthenticationExtensions +{ + public static IServiceCollection AddApiAuthentication(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddHttpContextAccessor(); + services.AddScoped(); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + // The signing key and issuer settings come from the same options the token + // service issues with, so the two can never disagree. + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + // A till and the server share a clock, so there is no reason to accept + // tokens past their stated expiry. + ClockSkew = TimeSpan.Zero, + }; + }); + + // Bound late so the signing key provider (a singleton) is resolved from the container + // rather than constructed twice. + services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure, JwtSigningKeyProvider>((bearer, jwtOptions, keyProvider) => + { + bearer.TokenValidationParameters.ValidIssuer = jwtOptions.Value.Issuer; + bearer.TokenValidationParameters.ValidAudience = jwtOptions.Value.Audience; + bearer.TokenValidationParameters.IssuerSigningKey = keyProvider.SecurityKey; + }); + + services.AddAuthorizationBuilder() + .AddAppPolicies() + // Nothing is public unless it opts out with AllowAnonymous. + .SetFallbackPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()); + + return services; + } + + /// + /// Throttles endpoints that accept a guessable secret. The 4-digit approval PIN has only + /// ten thousand combinations, so without this an attacker at an authenticated till could + /// simply enumerate it. + /// + public static IServiceCollection AddApiRateLimiting(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRateLimiter(options => + { + options.AddPolicy(AuthEndpoints.SensitiveRateLimitPolicy, context => + RateLimitPartition.GetFixedWindowLimiter( + // Partition by user when signed in, otherwise by address, so one till + // hammering the PIN cannot lock out the others. + partitionKey: context.User.Identity?.Name + ?? context.Connection.RemoteIpAddress?.ToString() + ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + })); + + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.HttpContext.Response.ContentType = "application/problem+json"; + + var problem = new ProblemDetails + { + Title = "Too many attempts. Please wait a moment and try again.", + Status = StatusCodes.Status429TooManyRequests, + Extensions = { ["code"] = "Auth.TooManyAttempts" }, + }; + + await context.HttpContext.Response.WriteAsync( + JsonSerializer.Serialize(problem, ProblemJsonOptions), + cancellationToken); + }; + }); + + return services; + } + + private static readonly JsonSerializerOptions ProblemJsonOptions = + new(JsonSerializerDefaults.Web); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Extensions/ResultExtensions.cs b/backend/src/RestaurantPOS.API/Extensions/ResultExtensions.cs new file mode 100644 index 0000000..8ef0f82 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Extensions/ResultExtensions.cs @@ -0,0 +1,57 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.API.Extensions; + +/// +/// Translates the application layer's into HTTP responses, so endpoints +/// never decide status codes for themselves and every failure looks the same on the wire. +/// +public static class ResultExtensions +{ + /// Maps a valueless result to 204 No Content, or an RFC 7807 problem on failure. + public static IResult ToHttpResult(this Result result) + { + ArgumentNullException.ThrowIfNull(result); + + return result.IsSuccess ? Results.NoContent() : Problem(result.Error); + } + + /// Maps a result to 200 OK with its value, or an RFC 7807 problem on failure. + public static IResult ToHttpResult(this Result result) + { + ArgumentNullException.ThrowIfNull(result); + + return result.IsSuccess ? Results.Ok(result.Value) : Problem(result.Error); + } + + /// Maps a successful result to 201 Created at . + public static IResult ToCreatedResult(this Result result, Func location) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(location); + + return result.IsSuccess + ? Results.Created(location(result.Value), result.Value) + : Problem(result.Error); + } + + private static IResult Problem(Error error) + { + var statusCode = error.Type switch + { + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + _ => StatusCodes.Status500InternalServerError, + }; + + return Results.Problem( + title: error.Description, + statusCode: statusCode, + // The stable machine-readable code lets the client branch on a specific failure + // (for example, routing to the password-reset screen) without matching on prose. + extensions: new Dictionary { ["code"] = error.Code }); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Middleware/GlobalExceptionMiddleware.cs b/backend/src/RestaurantPOS.API/Middleware/GlobalExceptionMiddleware.cs index 888e25a..dbc5fc3 100644 --- a/backend/src/RestaurantPOS.API/Middleware/GlobalExceptionMiddleware.cs +++ b/backend/src/RestaurantPOS.API/Middleware/GlobalExceptionMiddleware.cs @@ -1,8 +1,13 @@ -using System.Net; -using System.Text.Json; +using Microsoft.AspNetCore.Mvc; + +using RestaurantPOS.Application.Common.Exceptions; namespace RestaurantPOS.API.Middleware; +/// +/// Converts unhandled exceptions into RFC 7807 problem responses, matching the shape produced +/// by so clients only parse one error format. +/// public class GlobalExceptionMiddleware { private readonly RequestDelegate _next; @@ -19,24 +24,46 @@ public GlobalExceptionMiddleware(RequestDelegate next, ILogger e.Key, e => e.Value, StringComparer.Ordinal)) + { + Title = "One or more validation errors occurred.", + Status = StatusCodes.Status400BadRequest, + }); + } catch (Exception ex) { LogUnhandledException(_logger, "Unhandled exception occurred", ex); - context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; - - var response = new + await WriteProblemAsync(context, new ProblemDetails { - statusCode = context.Response.StatusCode, - message = "An unexpected error occurred. Please try again later." - }; + Title = "An unexpected error occurred. Please try again later.", + Status = StatusCodes.Status500InternalServerError, + }); + } + } - await context.Response.WriteAsync(JsonSerializer.Serialize(response)); + private static async Task WriteProblemAsync(HttpContext context, ProblemDetails problem) + { + if (context.Response.HasStarted) + { + // Too late to change the response; swallowing here avoids masking the original error. + return; } + + context.Response.Clear(); + context.Response.StatusCode = problem.Status ?? StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsJsonAsync(problem, problem.GetType()); } } \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Middleware/PasswordChangeRequiredMiddleware.cs b/backend/src/RestaurantPOS.API/Middleware/PasswordChangeRequiredMiddleware.cs new file mode 100644 index 0000000..2065840 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Middleware/PasswordChangeRequiredMiddleware.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Mvc; + +using RestaurantPOS.API.Security; +using RestaurantPOS.Application.Common.Security; + +namespace RestaurantPOS.API.Middleware; + +/// +/// Confines a user who has not yet chosen their own password to the password-change flow. +/// +/// +/// The frontend also routes such users straight to the reset screen, but enforcing it here as +/// well means a temporary password issued by an administrator cannot be used to do real work +/// by calling the API directly. +/// +public sealed class PasswordChangeRequiredMiddleware(RequestDelegate next) +{ + /// Machine-readable code the client keys on to show the reset screen. + public const string ErrorCode = "Auth.PasswordChangeRequired"; + + public async Task InvokeAsync(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var mustChange = context.User.HasClaim(AppClaimTypes.MustChangePassword, "true"); + + var endpointIsExempt = context.GetEndpoint()?.Metadata + .GetMetadata() is not null; + + if (!mustChange || endpointIsExempt) + { + await next(context); + return; + } + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Title = "You must choose a new password before continuing.", + Status = StatusCodes.Status403Forbidden, + Extensions = { ["code"] = ErrorCode }, + }); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Program.cs b/backend/src/RestaurantPOS.API/Program.cs index ac0e2c1..ae9dcc4 100644 --- a/backend/src/RestaurantPOS.API/Program.cs +++ b/backend/src/RestaurantPOS.API/Program.cs @@ -1,7 +1,14 @@ +using System.Text.Json.Serialization; + using Asp.Versioning; + +using RestaurantPOS.API.Endpoints; +using RestaurantPOS.API.Extensions; using RestaurantPOS.API.Middleware; using RestaurantPOS.Application; using RestaurantPOS.Infrastructure; +using RestaurantPOS.Infrastructure.Persistence.Seeding; + using Serilog; #pragma warning disable CA1305 @@ -20,18 +27,29 @@ var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog(); - // CORS policy setup for frontend apps (Electron/React) + // The Electron renderer loads from file:// (origin "null") in production and from + // localhost in development, so origins are configurable rather than hard-coded. + var allowedOrigins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? []; + builder.Services.AddCors(options => { - options.AddPolicy("AllowAll", policy => + options.AddPolicy("PosClient", policy => { - policy.AllowAnyOrigin() - .AllowAnyHeader() - .AllowAnyMethod(); + if (allowedOrigins.Length > 0) + { + policy.WithOrigins(allowedOrigins); + } + else + { + policy.SetIsOriginAllowed(_ => true); + } + + policy.AllowAnyHeader().AllowAnyMethod(); }); }); - // API Versioning builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1, 0); @@ -44,14 +62,28 @@ options.SubstituteApiVersionInUrl = true; }); - // Add Layer DI builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); + builder.Services.AddApiAuthentication(); + builder.Services.AddApiRateLimiting(); + + // Enums cross the wire as their names ("Admin", "PosBilling") rather than as integers, so + // the client never has to mirror numeric values and payloads stay readable in logs. + builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + builder.Services.AddProblemDetails(); builder.Services.AddOpenApi(); var app = builder.Build(); + // Bring the database up to date and guarantee an administrator exists before serving. + await using (var scope = app.Services.CreateAsyncScope()) + { + var seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.SeedAsync(); + } + app.UseMiddleware(); if (app.Environment.IsDevelopment()) @@ -59,20 +91,45 @@ app.MapOpenApi(); } - app.UseCors("AllowAll"); - app.UseHttpsRedirection(); + app.UseCors("PosClient"); + app.UseRateLimiter(); + + app.UseAuthentication(); + app.UseAuthorization(); + + // Runs after authentication so it can read the claim, and after authorization so an + // anonymous caller gets a 401 rather than this middleware's 403. + app.UseMiddleware(); + + var versionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1, 0)) + .ReportApiVersions() + .Build(); - app.MapGet("/health", () => Results.Ok(new { status = "Healthy", timestamp = DateTime.UtcNow })); + var api = app.MapGroup("/api/v{version:apiVersion}").WithApiVersionSet(versionSet); - app.Run(); + api.MapAuthEndpoints(); + api.MapUserEndpoints(); + api.MapModuleEndpoints(); + + app.MapGet("/health", () => Results.Ok(new { status = "Healthy", timestamp = DateTime.UtcNow })) + .AllowAnonymous() + .WithTags("Diagnostics"); + + await app.RunAsync(); } -catch (Exception ex) +// HostAbortedException is how the EF Core design-time tools stop the host after building the +// service provider; it is normal control flow, not a crash. +catch (Exception ex) when (ex is not HostAbortedException) { Log.Fatal(ex, "RestaurantPOS API terminated unexpectedly"); + + // Rethrow so the process exits non-zero and a service manager notices the failure. + throw; } finally { - Log.CloseAndFlush(); + await Log.CloseAndFlushAsync(); } public partial class Program { } \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/RestaurantPOS.API.csproj b/backend/src/RestaurantPOS.API/RestaurantPOS.API.csproj index 94418c4..8bef238 100644 --- a/backend/src/RestaurantPOS.API/RestaurantPOS.API.csproj +++ b/backend/src/RestaurantPOS.API/RestaurantPOS.API.csproj @@ -18,6 +18,7 @@ + diff --git a/backend/src/RestaurantPOS.API/RestaurantPOS.API.http b/backend/src/RestaurantPOS.API/RestaurantPOS.API.http index e34aaad..4e3b093 100644 --- a/backend/src/RestaurantPOS.API/RestaurantPOS.API.http +++ b/backend/src/RestaurantPOS.API/RestaurantPOS.API.http @@ -1,6 +1,62 @@ @RestaurantPOS.API_HostAddress = http://localhost:5207 +@api = {{RestaurantPOS.API_HostAddress}}/api/v1 -GET {{RestaurantPOS.API_HostAddress}}/weatherforecast/ +### Health check +GET {{RestaurantPOS.API_HostAddress}}/health Accept: application/json -### +### Sign in as the seeded administrator (see appsettings.json > SeedAdmin) +# @name login +POST {{api}}/auth/login +Content-Type: application/json + +{ + "username": "admin", + "password": "ChangeMe!123" +} + +### Change password (required on first sign-in — swap in the accessToken from login above) +POST {{api}}/auth/change-password +Content-Type: application/json +Authorization: Bearer {{login.response.body.accessToken}} + +{ + "currentPassword": "ChangeMe!123", + "newPassword": "Lakshmi@2026" +} + +### Who am I +GET {{api}}/auth/me +Authorization: Bearer {{login.response.body.accessToken}} + +### Module catalog +GET {{api}}/modules +Authorization: Bearer {{login.response.body.accessToken}} + +### List staff accounts +GET {{api}}/users +Authorization: Bearer {{login.response.body.accessToken}} + +### Create a staff account +POST {{api}}/users +Content-Type: application/json +Authorization: Bearer {{login.response.body.accessToken}} + +{ + "username": "cashier01", + "fullName": "Ravi Kumar", + "email": null, + "password": "Cashier@2026", + "role": "User", + "modules": ["PosBilling", "KitchenOperations"] +} + +### Set the administrator's approval PIN +POST {{api}}/auth/pin +Content-Type: application/json +Authorization: Bearer {{login.response.body.accessToken}} + +{ + "currentPassword": "Lakshmi@2026", + "pin": null +} diff --git a/backend/src/RestaurantPOS.API/Security/AllowPendingPasswordChangeAttribute.cs b/backend/src/RestaurantPOS.API/Security/AllowPendingPasswordChangeAttribute.cs new file mode 100644 index 0000000..b64652b --- /dev/null +++ b/backend/src/RestaurantPOS.API/Security/AllowPendingPasswordChangeAttribute.cs @@ -0,0 +1,8 @@ +namespace RestaurantPOS.API.Security; + +/// +/// Marks an endpoint as reachable by a user who still owes a password change. Applied to the +/// handful of endpoints the reset screen itself needs. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class AllowPendingPasswordChangeAttribute : Attribute; \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs b/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs new file mode 100644 index 0000000..836413d --- /dev/null +++ b/backend/src/RestaurantPOS.API/Security/AuthorizationPolicies.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Authorization; + +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +namespace RestaurantPOS.API.Security; + +/// +/// Named authorization policies. One is registered per module so endpoints can simply say +/// which module they belong to. +/// +public static class AuthorizationPolicies +{ + /// Requires the Admin role. + public const string AdminOnly = "role:admin"; + + /// Builds the policy name guarding . + public static string ForModule(AppModule module) => $"module:{module}"; + + /// Registers the admin policy plus one policy per catalog module. + public static AuthorizationBuilder AddAppPolicies(this AuthorizationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.AddPolicy(AdminOnly, policy => policy.RequireRole(nameof(UserRole.Admin))); + + foreach (var descriptor in ModuleCatalog.All) + { + var module = descriptor.Module; + + builder.AddPolicy(ForModule(module), policy => + policy.RequireAssertion(context => + // Administrators hold every module implicitly, so their tokens carry no + // module claims; everyone else needs the specific grant. + context.User.IsInRole(nameof(UserRole.Admin)) || + context.User.HasClaim(AppClaimTypes.Module, module.ToString()))); + } + + return builder; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/Security/HttpCurrentUser.cs b/backend/src/RestaurantPOS.API/Security/HttpCurrentUser.cs new file mode 100644 index 0000000..efde737 --- /dev/null +++ b/backend/src/RestaurantPOS.API/Security/HttpCurrentUser.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.API.Security; + +/// +/// Reads the acting user out of the current request's principal. +/// +/// +/// Lives in the API project rather than infrastructure because it depends on +/// , which is a web concern. +/// +internal sealed class HttpCurrentUser(IHttpContextAccessor accessor) : ICurrentUser +{ + public Guid? UserId => + Guid.TryParse(Principal?.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; + + public string? Username => Principal?.FindFirstValue(ClaimTypes.Name); + + public bool IsAdmin => Principal?.IsInRole(nameof(UserRole.Admin)) ?? false; + + private ClaimsPrincipal? Principal => accessor.HttpContext?.User; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.API/appsettings.json b/backend/src/RestaurantPOS.API/appsettings.json index 39e398d..939b6d7 100644 --- a/backend/src/RestaurantPOS.API/appsettings.json +++ b/backend/src/RestaurantPOS.API/appsettings.json @@ -2,6 +2,21 @@ "ConnectionStrings": { "DefaultConnection": "Data Source=restaurantpos.db" }, + "Jwt": { + "Issuer": "RestaurantPOS", + "Audience": "RestaurantPOS.Client", + "SigningKey": "", + "AccessTokenMinutes": 60, + "RefreshTokenDays": 14 + }, + "SeedAdmin": { + "Username": "admin", + "FullName": "System Administrator", + "Password": "ChangeMe!123" + }, + "Cors": { + "AllowedOrigins": [] + }, "Logging": { "LogLevel": { "Default": "Information", @@ -9,4 +24,4 @@ } }, "AllowedHosts": "*" -} \ No newline at end of file +} diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/ChangePassword/ChangePasswordCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..d6a23aa --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,82 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Authentication.Common; +using RestaurantPOS.Application.Authentication.Dtos; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.ChangePassword; + +/// +/// Changes the signed-in user's own password. Also the screen shown to accounts flagged +/// MustChangePassword — the seeded administrator and anyone an admin has just created +/// or reset. +/// +public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) + : IRequest>; + +public sealed class ChangePasswordCommandValidator : AbstractValidator +{ + public ChangePasswordCommandValidator() + { + RuleFor(x => x.CurrentPassword).NotEmpty().WithMessage("Your current password is required."); + RuleFor(x => x.NewPassword).MustMeetPasswordPolicy(); + } +} + +internal sealed class ChangePasswordCommandHandler( + IAppDbContext db, + ICurrentUser currentUser, + IPasswordHasher passwordHasher, + ITokenService tokens, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + ChangePasswordCommand request, + CancellationToken cancellationToken) + { + var userId = currentUser.UserId; + if (userId is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + var user = await db.Users + .Include(u => u.ModulePermissions) + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.Id == userId.Value, cancellationToken); + + if (user is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + if (!passwordHasher.Verify(request.CurrentPassword, user.PasswordHash)) + { + return Result.Failure(AuthErrors.PasswordMismatch); + } + + if (passwordHasher.Verify(request.NewPassword, user.PasswordHash)) + { + return Result.Failure(AuthErrors.PasswordReused); + } + + user.SetPassword(passwordHasher.Hash(request.NewPassword)); + + // Every other session is invalidated, then a fresh one is issued to this caller so the + // user stays signed in on the device that made the change. + user.RevokeAllRefreshTokens(clock.UtcNow); + var session = SessionFactory.Issue(user, tokens, clock); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(session); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/ClearApprovalPin/ClearApprovalPinCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/ClearApprovalPin/ClearApprovalPinCommand.cs new file mode 100644 index 0000000..4dc6cfa --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/ClearApprovalPin/ClearApprovalPinCommand.cs @@ -0,0 +1,43 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.ClearApprovalPin; + +/// Removes the signed-in administrator's approval PIN. +public sealed record ClearApprovalPinCommand : IRequest; + +internal sealed class ClearApprovalPinCommandHandler( + IAppDbContext db, + ICurrentUser currentUser) + : IRequestHandler +{ + public async Task Handle(ClearApprovalPinCommand request, CancellationToken cancellationToken) + { + var userId = currentUser.UserId; + if (userId is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId.Value, cancellationToken); + if (user is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + if (!user.HasApprovalPin) + { + return Result.Failure(AuthErrors.PinNotSet); + } + + user.ClearApprovalPin(); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/Login/LoginCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/Login/LoginCommand.cs new file mode 100644 index 0000000..2201568 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/Login/LoginCommand.cs @@ -0,0 +1,72 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Authentication.Common; +using RestaurantPOS.Application.Authentication.Dtos; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.Login; + +/// Exchanges a username and password for a session. +public sealed record LoginCommand(string Username, string Password) : IRequest>; + +public sealed class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required."); + RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required."); + } +} + +internal sealed class LoginCommandHandler( + IAppDbContext db, + IPasswordHasher passwordHasher, + ITokenService tokens, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + LoginCommand request, + CancellationToken cancellationToken) + { + var username = request.Username.Trim().ToLowerInvariant(); + + var user = await db.Users + .Include(u => u.ModulePermissions) + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.Username == username, cancellationToken); + + if (user is null) + { + // Burn a comparable amount of time so an unknown username is not measurably + // faster than a wrong password, which would allow username enumeration. + passwordHasher.Hash(request.Password); + return Result.Failure(AuthErrors.InvalidCredentials); + } + + if (!passwordHasher.Verify(request.Password, user.PasswordHash)) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + // Checked only after the password so a deactivated account is not distinguishable + // from a wrong password to someone who does not know the credentials. + if (!user.IsActive) + { + return Result.Failure(AuthErrors.AccountDeactivated); + } + + user.RecordSuccessfulLogin(clock.UtcNow); + var session = SessionFactory.Issue(user, tokens, clock); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(session); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/Logout/LogoutCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/Logout/LogoutCommand.cs new file mode 100644 index 0000000..e31fd14 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/Logout/LogoutCommand.cs @@ -0,0 +1,44 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Authentication.Commands.Logout; + +/// +/// Ends a session by revoking its refresh token. Succeeds even for an unknown token so that +/// signing out is always safe to call and never leaks whether a token was real. +/// +public sealed record LogoutCommand(string? RefreshToken) : IRequest; + +internal sealed class LogoutCommandHandler( + IAppDbContext db, + ITokenService tokens, + IDateTimeProvider clock) + : IRequestHandler +{ + public async Task Handle(LogoutCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + { + return Result.Success(); + } + + var hash = tokens.HashRefreshToken(request.RefreshToken); + + var user = await db.Users + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.RefreshTokens.Any(t => t.TokenHash == hash), cancellationToken); + + var token = user?.FindActiveRefreshToken(hash, clock.UtcNow); + if (user is not null && token is not null) + { + user.RevokeRefreshToken(token, clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + } + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/RefreshSession/RefreshSessionCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/RefreshSession/RefreshSessionCommand.cs new file mode 100644 index 0000000..e9c14b9 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/RefreshSession/RefreshSessionCommand.cs @@ -0,0 +1,69 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Authentication.Common; +using RestaurantPOS.Application.Authentication.Dtos; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.RefreshSession; + +/// Trades a valid refresh token for a new access/refresh pair. +public sealed record RefreshSessionCommand(string RefreshToken) : IRequest>; + +public sealed class RefreshSessionCommandValidator : AbstractValidator +{ + public RefreshSessionCommandValidator() => + RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("A refresh token is required."); +} + +internal sealed class RefreshSessionCommandHandler( + IAppDbContext db, + ITokenService tokens, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + RefreshSessionCommand request, + CancellationToken cancellationToken) + { + var hash = tokens.HashRefreshToken(request.RefreshToken); + var now = clock.UtcNow; + + var user = await db.Users + .Include(u => u.ModulePermissions) + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.RefreshTokens.Any(t => t.TokenHash == hash), cancellationToken); + + if (user is null) + { + return Result.Failure(AuthErrors.InvalidRefreshToken); + } + + var existing = user.FindActiveRefreshToken(hash, now); + if (existing is null) + { + return Result.Failure(AuthErrors.InvalidRefreshToken); + } + + if (!user.IsActive) + { + // Revoke outstanding sessions so a deactivated account cannot keep refreshing. + user.RevokeAllRefreshTokens(now); + await db.SaveChangesAsync(cancellationToken); + return Result.Failure(AuthErrors.AccountDeactivated); + } + + // Rotation: the presented token is burned as the replacement is issued. + user.RevokeRefreshToken(existing, now); + var session = SessionFactory.Issue(user, tokens, clock); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(session); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/SetApprovalPin/SetApprovalPinCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/SetApprovalPin/SetApprovalPinCommand.cs new file mode 100644 index 0000000..c28c10f --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/SetApprovalPin/SetApprovalPinCommand.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using System.Security.Cryptography; + +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.SetApprovalPin; + +/// +/// Sets the signed-in administrator's 4-digit approval PIN, used to authorise privileged +/// actions taken at the till such as cancelling an order. +/// +/// Re-authenticates the admin before the PIN is changed. +/// A chosen PIN, or null to have the server generate a random one. +public sealed record SetApprovalPinCommand(string CurrentPassword, string? Pin) + : IRequest>; + +/// +/// The PIN in plaintext. Returned exactly once, at the moment it is set — only the hash is +/// stored, so it cannot be read back afterwards. +/// +public sealed record SetApprovalPinResult(string Pin); + +public sealed class SetApprovalPinCommandValidator : AbstractValidator +{ + public SetApprovalPinCommandValidator() + { + RuleFor(x => x.CurrentPassword).NotEmpty().WithMessage("Your current password is required."); + + RuleFor(x => x.Pin) + .Must(pin => pin!.Length == User.ApprovalPinLength && pin.All(char.IsAsciiDigit)) + .When(x => x.Pin is not null) + .WithMessage($"The approval PIN must be exactly {User.ApprovalPinLength} digits."); + } +} + +internal sealed class SetApprovalPinCommandHandler( + IAppDbContext db, + ICurrentUser currentUser, + IPasswordHasher passwordHasher, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + SetApprovalPinCommand request, + CancellationToken cancellationToken) + { + var userId = currentUser.UserId; + if (userId is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId.Value, cancellationToken); + if (user is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + if (user.Role != UserRole.Admin) + { + return Result.Failure(AuthErrors.PinRequiresAdmin); + } + + if (!passwordHasher.Verify(request.CurrentPassword, user.PasswordHash)) + { + return Result.Failure(AuthErrors.PasswordMismatch); + } + + var pin = request.Pin ?? GeneratePin(); + + user.SetApprovalPin(passwordHasher.Hash(pin), clock.UtcNow); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(new SetApprovalPinResult(pin)); + } + + private static string GeneratePin() => + RandomNumberGenerator + .GetInt32(0, (int)Math.Pow(10, User.ApprovalPinLength)) + .ToString($"D{User.ApprovalPinLength}", CultureInfo.InvariantCulture); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Commands/VerifyApprovalPin/VerifyApprovalPinCommand.cs b/backend/src/RestaurantPOS.Application/Authentication/Commands/VerifyApprovalPin/VerifyApprovalPinCommand.cs new file mode 100644 index 0000000..6a1032e --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Commands/VerifyApprovalPin/VerifyApprovalPinCommand.cs @@ -0,0 +1,70 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; + +/// +/// Checks a 4-digit PIN against every active administrator, identifying who authorised the +/// action. This is the shared approval gate other modules call — for example, an order +/// cancellation at the till prompts for a PIN and records the approving administrator. +/// +/// The PIN typed by the administrator standing at the till. +/// Free text describing what is being approved, for the audit trail. +public sealed record VerifyApprovalPinCommand(string Pin, string? Reason) + : IRequest>; + +/// Identifies the administrator whose PIN authorised an action. +public sealed record ApprovalResult(Guid ApprovedByUserId, string ApprovedByName, DateTime ApprovedAtUtc); + +public sealed class VerifyApprovalPinCommandValidator : AbstractValidator +{ + public VerifyApprovalPinCommandValidator() => + RuleFor(x => x.Pin) + .Must(pin => !string.IsNullOrEmpty(pin) + && pin.Length == User.ApprovalPinLength + && pin.All(char.IsAsciiDigit)) + .WithMessage($"The approval PIN must be exactly {User.ApprovalPinLength} digits."); +} + +internal sealed class VerifyApprovalPinCommandHandler( + IAppDbContext db, + IPasswordHasher passwordHasher, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle( + VerifyApprovalPinCommand request, + CancellationToken cancellationToken) + { + var admins = await db.Users + .Where(u => u.IsActive && u.Role == UserRole.Admin && u.ApprovalPinHash != null) + .Select(u => new { u.Id, u.FullName, u.ApprovalPinHash }) + .ToListAsync(cancellationToken); + + if (admins.Count == 0) + { + return Result.Failure(AuthErrors.NoAdminPinConfigured); + } + + // Every candidate is checked rather than breaking on the first match, so the time taken + // does not reveal which administrator's PIN was supplied. + var matched = admins.Aggregate( + (Id: Guid.Empty, Name: string.Empty, Found: false), + (acc, admin) => passwordHasher.Verify(request.Pin, admin.ApprovalPinHash!) + ? (admin.Id, admin.FullName, true) + : acc); + + return matched.Found + ? Result.Success(new ApprovalResult(matched.Id, matched.Name, clock.UtcNow)) + : Result.Failure(AuthErrors.InvalidPin); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Common/SessionFactory.cs b/backend/src/RestaurantPOS.Application/Authentication/Common/SessionFactory.cs new file mode 100644 index 0000000..3b4db3c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Common/SessionFactory.cs @@ -0,0 +1,34 @@ +using RestaurantPOS.Application.Authentication.Dtos; +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Authentication.Common; + +/// +/// Builds a signed-in session for a user. Shared by sign-in, token refresh and password change +/// so all three issue tokens identically. +/// +internal static class SessionFactory +{ + /// + /// Mints an access/refresh token pair and records the refresh token on the user. The caller + /// is responsible for persisting the change. + /// + public static AuthenticationResult Issue(User user, ITokenService tokens, IDateTimeProvider clock) + { + var access = tokens.CreateAccessToken(user); + var refresh = tokens.CreateRefreshToken(); + + user.IssueRefreshToken(refresh.Hash, refresh.ExpiresAtUtc, clock.UtcNow); + + return new AuthenticationResult + { + AccessToken = access.Value, + AccessTokenExpiresAtUtc = access.ExpiresAtUtc, + RefreshToken = refresh.Value, + RefreshTokenExpiresAtUtc = refresh.ExpiresAtUtc, + User = user.ToDto(), + }; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Dtos/AuthenticationResult.cs b/backend/src/RestaurantPOS.Application/Authentication/Dtos/AuthenticationResult.cs new file mode 100644 index 0000000..93590b6 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Dtos/AuthenticationResult.cs @@ -0,0 +1,23 @@ +using RestaurantPOS.Application.Users.Dtos; + +namespace RestaurantPOS.Application.Authentication.Dtos; + +/// Everything a client needs to establish a signed-in session. +public sealed record AuthenticationResult +{ + /// Short-lived signed JWT sent on every subsequent request. + public required string AccessToken { get; init; } + + public required DateTime AccessTokenExpiresAtUtc { get; init; } + + /// Long-lived opaque token used to obtain a new access token. + public required string RefreshToken { get; init; } + + public required DateTime RefreshTokenExpiresAtUtc { get; init; } + + /// + /// The signed-in user. When is true the session is + /// restricted to the password-change endpoints until a new password is chosen. + /// + public required UserDto User { get; init; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Authentication/Queries/GetCurrentUser/GetCurrentUserQuery.cs b/backend/src/RestaurantPOS.Application/Authentication/Queries/GetCurrentUser/GetCurrentUserQuery.cs new file mode 100644 index 0000000..2245044 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Authentication/Queries/GetCurrentUser/GetCurrentUserQuery.cs @@ -0,0 +1,39 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Authentication.Queries.GetCurrentUser; + +/// +/// Returns the signed-in user's profile and effective module access. The frontend calls this +/// on start-up to rebuild its navigation without trusting anything cached on the client. +/// +public sealed record GetCurrentUserQuery : IRequest>; + +internal sealed class GetCurrentUserQueryHandler(IAppDbContext db, ICurrentUser currentUser) + : IRequestHandler> +{ + public async Task> Handle(GetCurrentUserQuery request, CancellationToken cancellationToken) + { + var userId = currentUser.UserId; + if (userId is null) + { + return Result.Failure(AuthErrors.InvalidCredentials); + } + + var user = await db.Users + .AsNoTracking() + .Include(u => u.ModulePermissions) + .FirstOrDefaultAsync(u => u.Id == userId.Value, cancellationToken); + + return user is null + ? Result.Failure(AuthErrors.InvalidCredentials) + : Result.Success(user.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Behaviors/LoggingBehavior.cs b/backend/src/RestaurantPOS.Application/Common/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..17a34e1 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Behaviors/LoggingBehavior.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; + +using MediatR; + +using Microsoft.Extensions.Logging; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Application.Common.Behaviors; + +/// +/// Records who ran which use case and whether it succeeded. On a single-site install this log +/// is the primary audit trail, so it names the acting user but never the request payload, +/// which would contain passwords and PINs. +/// +public sealed partial class LoggingBehavior( + ILogger> logger, + ICurrentUser currentUser) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + var requestName = typeof(TRequest).Name; + var actor = currentUser.Username ?? "anonymous"; + var timer = Stopwatch.StartNew(); + + var response = await next(cancellationToken); + + timer.Stop(); + + if (response is Result { IsFailure: true } failure) + { + RequestFailed(logger, requestName, actor, failure.Error.Code, timer.ElapsedMilliseconds); + } + else + { + RequestSucceeded(logger, requestName, actor, timer.ElapsedMilliseconds); + } + + return response; + } + + [LoggerMessage( + EventId = 1000, + Level = LogLevel.Information, + Message = "{RequestName} handled for {Actor} in {ElapsedMs}ms")] + private static partial void RequestSucceeded( + ILogger logger, string requestName, string actor, long elapsedMs); + + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Warning, + Message = "{RequestName} rejected for {Actor} with {ErrorCode} in {ElapsedMs}ms")] + private static partial void RequestFailed( + ILogger logger, string requestName, string actor, string errorCode, long elapsedMs); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/RestaurantPOS.Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..a5009e3 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,44 @@ +using FluentValidation; + +using MediatR; + +using RestaurantPOS.Application.Common.Exceptions; + +namespace RestaurantPOS.Application.Common.Behaviors; + +/// +/// Runs every FluentValidation validator registered for a request before the handler sees it, +/// so handlers can assume well-formed input. +/// +public sealed class ValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + if (!validators.Any()) + { + return await next(cancellationToken); + } + + var context = new ValidationContext(request); + + var results = await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = results + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + return failures.Count > 0 + ? throw new ValidationAppException(failures) + : await next(cancellationToken); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Exceptions/ValidationAppException.cs b/backend/src/RestaurantPOS.Application/Common/Exceptions/ValidationAppException.cs new file mode 100644 index 0000000..8b4e730 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Exceptions/ValidationAppException.cs @@ -0,0 +1,47 @@ +using System.Collections.ObjectModel; + +using FluentValidation.Results; + +namespace RestaurantPOS.Application.Common.Exceptions; + +/// +/// Raised when request validation fails. The API surfaces this as an RFC 7807 validation +/// problem with one entry per offending field. +/// +public sealed class ValidationAppException : Exception +{ + public ValidationAppException() + : this("One or more validation errors occurred.") + { + } + + public ValidationAppException(string message) + : base(message) + { + Errors = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + } + + public ValidationAppException(string message, Exception innerException) + : base(message, innerException) + { + Errors = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + } + + public ValidationAppException(IEnumerable failures) + : this("One or more validation errors occurred.") + { + ArgumentNullException.ThrowIfNull(failures); + + var grouped = failures + .GroupBy(f => f.PropertyName, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => g.Select(f => f.ErrorMessage).Distinct(StringComparer.Ordinal).ToArray(), + StringComparer.Ordinal); + + Errors = new ReadOnlyDictionary(grouped); + } + + /// Validation messages keyed by the property that failed. + public IReadOnlyDictionary Errors { get; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs new file mode 100644 index 0000000..7624a47 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IAppDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Interfaces; + +/// +/// The persistence surface the application layer is allowed to touch. Keeping handlers on +/// this abstraction rather than the concrete AppDbContext preserves the dependency +/// rule enforced by the architecture tests. +/// +public interface IAppDbContext +{ + DbSet Users { get; } + + DbSet UserModulePermissions { get; } + + DbSet RefreshTokens { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/ICurrentUser.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/ICurrentUser.cs new file mode 100644 index 0000000..4c705d5 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/ICurrentUser.cs @@ -0,0 +1,12 @@ +namespace RestaurantPOS.Application.Common.Interfaces; + +/// Describes the user making the current request. +public interface ICurrentUser +{ + /// The authenticated user's id, or null for anonymous requests. + Guid? UserId { get; } + + string? Username { get; } + + bool IsAdmin { get; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IDateTimeProvider.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IDateTimeProvider.cs new file mode 100644 index 0000000..41d402a --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IDateTimeProvider.cs @@ -0,0 +1,7 @@ +namespace RestaurantPOS.Application.Common.Interfaces; + +/// Abstracts the system clock so time-dependent behaviour can be tested. +public interface IDateTimeProvider +{ + DateTime UtcNow { get; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/IPasswordHasher.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/IPasswordHasher.cs new file mode 100644 index 0000000..5deee47 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/IPasswordHasher.cs @@ -0,0 +1,14 @@ +namespace RestaurantPOS.Application.Common.Interfaces; + +/// Hashes and verifies passwords and approval PINs. +public interface IPasswordHasher +{ + /// Produces a salted, slow hash suitable for storage. + string Hash(string plaintext); + + /// + /// Verifies a plaintext value against a stored hash in constant time with respect to the + /// hash contents. + /// + bool Verify(string plaintext, string hash); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Interfaces/ITokenService.cs b/backend/src/RestaurantPOS.Application/Common/Interfaces/ITokenService.cs new file mode 100644 index 0000000..4a5af8d --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Interfaces/ITokenService.cs @@ -0,0 +1,27 @@ +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Interfaces; + +/// A freshly minted access token and its expiry. +/// The signed JWT. +/// When the token stops being accepted. +public readonly record struct AccessToken(string Value, DateTime ExpiresAtUtc); + +/// An opaque refresh token, held by the client, alongside the hash to persist. +/// The opaque token returned to the client. Never stored. +/// Digest of , safe to persist. +/// When the token stops being accepted. +public readonly record struct RefreshTokenPair(string Value, string Hash, DateTime ExpiresAtUtc); + +/// Issues and hashes the tokens that back a signed-in session. +public interface ITokenService +{ + /// Signs a JWT carrying the user's identity, role and granted modules. + AccessToken CreateAccessToken(User user); + + /// Generates a cryptographically random refresh token and its storage hash. + RefreshTokenPair CreateRefreshToken(); + + /// Hashes a client-supplied refresh token so it can be matched against storage. + string HashRefreshToken(string token); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Mappings/UserMappings.cs b/backend/src/RestaurantPOS.Application/Common/Mappings/UserMappings.cs new file mode 100644 index 0000000..bb7acee --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Mappings/UserMappings.cs @@ -0,0 +1,29 @@ +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Application.Common.Mappings; + +/// Projects aggregates onto their read models. +public static class UserMappings +{ + public static UserDto ToDto(this User user) + { + ArgumentNullException.ThrowIfNull(user); + + return new UserDto + { + Id = user.Id, + Username = user.Username, + FullName = user.FullName, + Email = user.Email, + Role = user.Role, + IsActive = user.IsActive, + MustChangePassword = user.MustChangePassword, + IsSystemAdmin = user.IsSystemAdmin, + HasApprovalPin = user.HasApprovalPin, + LastLoginAtUtc = user.LastLoginAtUtc, + CreatedAtUtc = user.CreatedAtUtc, + Modules = user.EffectiveModules(), + }; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Security/AppClaimTypes.cs b/backend/src/RestaurantPOS.Application/Common/Security/AppClaimTypes.cs new file mode 100644 index 0000000..1c690b6 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Security/AppClaimTypes.cs @@ -0,0 +1,17 @@ +namespace RestaurantPOS.Application.Common.Security; + +/// +/// Custom claim names carried by the access token. Shared so the issuer (infrastructure) and +/// the reader (API) cannot drift apart. +/// +public static class AppClaimTypes +{ + /// One claim per module the user may open. Absent for administrators, who hold all. + public const string Module = "module"; + + /// + /// Present and "true" while the user still owes a password change. The API refuses every + /// endpoint except the password-change flow while this is set. + /// + public const string MustChangePassword = "must_change_password"; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Common/Security/PasswordPolicy.cs b/backend/src/RestaurantPOS.Application/Common/Security/PasswordPolicy.cs new file mode 100644 index 0000000..b248030 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Common/Security/PasswordPolicy.cs @@ -0,0 +1,38 @@ +using FluentValidation; + +namespace RestaurantPOS.Application.Common.Security; + +/// +/// The single definition of what makes an acceptable password. Applied by every command that +/// accepts one so the rules cannot drift between sign-up, reset and change flows. +/// +public static class PasswordPolicy +{ + public const int MinLength = 8; + public const int MaxLength = 128; + + /// Human-readable summary, shown by the frontend next to password fields. + public const string Description = + "Password must be at least 8 characters and include an upper-case letter, a lower-case letter and a digit."; + + /// Applies the policy to a string property on a FluentValidation rule chain. + public static IRuleBuilderOptions MustMeetPasswordPolicy( + this IRuleBuilder ruleBuilder) + { + ArgumentNullException.ThrowIfNull(ruleBuilder); + + return ruleBuilder + .NotEmpty().WithMessage("Password is required.") + .MinimumLength(MinLength).WithMessage($"Password must be at least {MinLength} characters.") + .MaximumLength(MaxLength).WithMessage($"Password cannot exceed {MaxLength} characters.") + .Must(ContainsUpper).WithMessage("Password must contain an upper-case letter.") + .Must(ContainsLower).WithMessage("Password must contain a lower-case letter.") + .Must(ContainsDigit).WithMessage("Password must contain a digit."); + } + + private static bool ContainsUpper(string value) => value.Any(char.IsUpper); + + private static bool ContainsLower(string value) => value.Any(char.IsLower); + + private static bool ContainsDigit(string value) => value.Any(char.IsDigit); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/DependencyInjection.cs b/backend/src/RestaurantPOS.Application/DependencyInjection.cs index d45c517..b104fe7 100644 --- a/backend/src/RestaurantPOS.Application/DependencyInjection.cs +++ b/backend/src/RestaurantPOS.Application/DependencyInjection.cs @@ -1,17 +1,31 @@ +using FluentValidation; + using MediatR; + using Microsoft.Extensions.DependencyInjection; +using RestaurantPOS.Application.Common.Behaviors; + namespace RestaurantPOS.Application; public static class DependencyInjection { public static IServiceCollection AddApplication(this IServiceCollection services) { + var assembly = typeof(DependencyInjection).Assembly; + services.AddMediatR(cfg => { - cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly); + cfg.RegisterServicesFromAssembly(assembly); + + // Order matters: logging wraps the whole pipeline, validation runs immediately + // before the handler so handlers can assume valid input. + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); + services.AddValidatorsFromAssembly(assembly, includeInternalTypes: true); + return services; } -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/RestaurantPOS.Application.csproj b/backend/src/RestaurantPOS.Application/RestaurantPOS.Application.csproj index 5dd7e0e..aa3bb65 100644 --- a/backend/src/RestaurantPOS.Application/RestaurantPOS.Application.csproj +++ b/backend/src/RestaurantPOS.Application/RestaurantPOS.Application.csproj @@ -12,8 +12,11 @@ + + + diff --git a/backend/src/RestaurantPOS.Application/Users/Commands/CreateUser/CreateUserCommand.cs b/backend/src/RestaurantPOS.Application/Users/Commands/CreateUser/CreateUserCommand.cs new file mode 100644 index 0000000..d740470 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Commands/CreateUser/CreateUserCommand.cs @@ -0,0 +1,87 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Application.Users.Common; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Commands.CreateUser; + +/// +/// Creates a staff account. The new user is always flagged to change their password at first +/// sign-in, which is how the owner's administrator account gets its own password. +/// +/// Ignored when is Admin, who hold every module. +public sealed record CreateUserCommand( + string Username, + string FullName, + string? Email, + string Password, + UserRole Role, + IReadOnlyCollection? Modules) : IRequest>; + +public sealed class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Username) + .NotEmpty().WithMessage("Username is required.") + .Must(User.IsValidUsername) + .WithMessage( + $"Username must be {User.UsernameMinLength}-{User.UsernameMaxLength} characters and may " + + "contain only letters, digits, dots, hyphens and underscores."); + + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full name is required.") + .MaximumLength(User.FullNameMaxLength); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Enter a valid email address.") + .MaximumLength(User.EmailMaxLength) + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + RuleFor(x => x.Password).MustMeetPasswordPolicy(); + + RuleFor(x => x.Role).IsInEnum().WithMessage("Select a valid role."); + + RuleFor(x => x.Modules).MustBeAssignableModules(); + } +} + +internal sealed class CreateUserCommandHandler(IAppDbContext db, IPasswordHasher passwordHasher) + : IRequestHandler> +{ + public async Task> Handle(CreateUserCommand request, CancellationToken cancellationToken) + { + var username = request.Username.Trim().ToLowerInvariant(); + + var exists = await db.Users.AnyAsync(u => u.Username == username, cancellationToken); + if (exists) + { + return Result.Failure(UserErrors.UsernameTaken); + } + + var user = User.Create( + username, + request.FullName, + request.Email, + passwordHasher.Hash(request.Password), + request.Role, + request.Modules, + mustChangePassword: true); + + db.Users.Add(user); + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(user.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Commands/ResetUserPassword/ResetUserPasswordCommand.cs b/backend/src/RestaurantPOS.Application/Users/Commands/ResetUserPassword/ResetUserPasswordCommand.cs new file mode 100644 index 0000000..d3b366d --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Commands/ResetUserPassword/ResetUserPasswordCommand.cs @@ -0,0 +1,55 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Commands.ResetUserPassword; + +/// +/// Sets a temporary password on another user's behalf. The user must choose their own +/// password the next time they sign in. +/// +public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : IRequest; + +public sealed class ResetUserPasswordCommandValidator : AbstractValidator +{ + public ResetUserPasswordCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.NewPassword).MustMeetPasswordPolicy(); + } +} + +internal sealed class ResetUserPasswordCommandHandler( + IAppDbContext db, + IPasswordHasher passwordHasher, + IDateTimeProvider clock) + : IRequestHandler +{ + public async Task Handle(ResetUserPasswordCommand request, CancellationToken cancellationToken) + { + var user = await db.Users + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + if (user is null) + { + return Result.Failure(UserErrors.NotFound(request.UserId)); + } + + user.ResetPassword(passwordHasher.Hash(request.NewPassword)); + + // Anyone signed in as this user is pushed back to the login screen. + user.RevokeAllRefreshTokens(clock.UtcNow); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Commands/SetUserActive/SetUserActiveCommand.cs b/backend/src/RestaurantPOS.Application/Users/Commands/SetUserActive/SetUserActiveCommand.cs new file mode 100644 index 0000000..8a85ca7 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Commands/SetUserActive/SetUserActiveCommand.cs @@ -0,0 +1,82 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Commands.SetUserActive; + +/// +/// Enables or disables a staff account. Accounts are deactivated rather than deleted so that +/// historical orders, bills and stock movements keep pointing at a real user. +/// +public sealed record SetUserActiveCommand(Guid UserId, bool IsActive) : IRequest>; + +internal sealed class SetUserActiveCommandHandler( + IAppDbContext db, + ICurrentUser currentUser, + IDateTimeProvider clock) + : IRequestHandler> +{ + public async Task> Handle(SetUserActiveCommand request, CancellationToken cancellationToken) + { + var user = await db.Users + .Include(u => u.ModulePermissions) + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + if (user is null) + { + return Result.Failure(UserErrors.NotFound(request.UserId)); + } + + if (user.IsActive == request.IsActive) + { + return Result.Success(user.ToDto()); + } + + if (!request.IsActive) + { + if (user.Id == currentUser.UserId) + { + return Result.Failure(UserErrors.CannotDeactivateSelf); + } + + if (user.IsSystemAdmin) + { + return Result.Failure(UserErrors.CannotModifySystemAdmin); + } + + if (user.Role == UserRole.Admin) + { + var anotherAdmin = await db.Users.AnyAsync( + u => u.Id != user.Id && u.IsActive && u.Role == UserRole.Admin, + cancellationToken); + + if (!anotherAdmin) + { + return Result.Failure(UserErrors.LastAdmin); + } + } + + user.Deactivate(); + + // Kill outstanding sessions immediately rather than waiting for the access token + // to expire on its own. + user.RevokeAllRefreshTokens(clock.UtcNow); + } + else + { + user.Activate(); + } + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(user.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs b/backend/src/RestaurantPOS.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs new file mode 100644 index 0000000..e7bdf7b --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Commands/UpdateUser/UpdateUserCommand.cs @@ -0,0 +1,101 @@ +using FluentValidation; + +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Common; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Commands.UpdateUser; + +/// Updates a user's profile, role and module grants in one operation. +public sealed record UpdateUserCommand( + Guid UserId, + string FullName, + string? Email, + UserRole Role, + IReadOnlyCollection? Modules) : IRequest>; + +public sealed class UpdateUserCommandValidator : AbstractValidator +{ + public UpdateUserCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full name is required.") + .MaximumLength(User.FullNameMaxLength); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Enter a valid email address.") + .MaximumLength(User.EmailMaxLength) + .When(x => !string.IsNullOrWhiteSpace(x.Email)); + + RuleFor(x => x.Role).IsInEnum().WithMessage("Select a valid role."); + + RuleFor(x => x.Modules).MustBeAssignableModules(); + } +} + +internal sealed class UpdateUserCommandHandler(IAppDbContext db, ICurrentUser currentUser) + : IRequestHandler> +{ + public async Task> Handle(UpdateUserCommand request, CancellationToken cancellationToken) + { + var user = await db.Users + .Include(u => u.ModulePermissions) + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + if (user is null) + { + return Result.Failure(UserErrors.NotFound(request.UserId)); + } + + var roleIsChanging = user.Role != request.Role; + + if (roleIsChanging) + { + if (user.Id == currentUser.UserId) + { + return Result.Failure(UserErrors.CannotDemoteSelf); + } + + if (user.IsSystemAdmin) + { + return Result.Failure(UserErrors.CannotModifySystemAdmin); + } + + if (user.Role == UserRole.Admin && !await AnotherActiveAdminExistsAsync(user.Id, cancellationToken)) + { + return Result.Failure(UserErrors.LastAdmin); + } + } + + user.UpdateProfile(request.FullName, request.Email); + + if (roleIsChanging) + { + user.ChangeRole(request.Role); + } + + // ChangeRole clears grants, so modules are applied afterwards. The call is a no-op for + // administrators, whose access comes from the role itself. + user.ReplaceModuleGrants(request.Modules ?? []); + + await db.SaveChangesAsync(cancellationToken); + + return Result.Success(user.ToDto()); + } + + private Task AnotherActiveAdminExistsAsync(Guid excludingUserId, CancellationToken cancellationToken) => + db.Users.AnyAsync( + u => u.Id != excludingUserId && u.IsActive && u.Role == UserRole.Admin, + cancellationToken); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Common/ModuleRules.cs b/backend/src/RestaurantPOS.Application/Users/Common/ModuleRules.cs new file mode 100644 index 0000000..d27245c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Common/ModuleRules.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +namespace RestaurantPOS.Application.Users.Common; + +/// Validation shared by every command that assigns modules to a user. +internal static class ModuleRules +{ + /// + /// Rejects unknown modules and administrative modules, which are reserved for the + /// role rather than granted individually. + /// + public static IRuleBuilderOptions?> MustBeAssignableModules( + this IRuleBuilder?> ruleBuilder) => + ruleBuilder + .Must(modules => modules is null || modules.All(ModuleCatalog.IsDefined)) + .WithMessage("One or more of the selected modules is not recognised.") + .Must(modules => modules is null || modules.All(ModuleCatalog.IsAssignableToUser)) + .WithMessage( + "Administrative modules cannot be granted individually. Give the user the Admin role instead."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Dtos/UserDto.cs b/backend/src/RestaurantPOS.Application/Users/Dtos/UserDto.cs new file mode 100644 index 0000000..185888c --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Dtos/UserDto.cs @@ -0,0 +1,36 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Users.Dtos; + +/// A staff account as presented to administrators in the user list and editor. +public sealed record UserDto +{ + public required Guid Id { get; init; } + + public required string Username { get; init; } + + public required string FullName { get; init; } + + public string? Email { get; init; } + + public required UserRole Role { get; init; } + + public required bool IsActive { get; init; } + + public required bool MustChangePassword { get; init; } + + /// True for the built-in administrator, which the UI protects from edits. + public required bool IsSystemAdmin { get; init; } + + public required bool HasApprovalPin { get; init; } + + public DateTime? LastLoginAtUtc { get; init; } + + public required DateTime CreatedAtUtc { get; init; } + + /// + /// Modules the user can actually open. For administrators this is the whole catalog, + /// even though no explicit grants are stored. + /// + public required IReadOnlyCollection Modules { get; init; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Queries/GetModules/GetModulesQuery.cs b/backend/src/RestaurantPOS.Application/Users/Queries/GetModules/GetModulesQuery.cs new file mode 100644 index 0000000..a8dbe96 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Queries/GetModules/GetModulesQuery.cs @@ -0,0 +1,47 @@ +using MediatR; + +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +namespace RestaurantPOS.Application.Users.Queries.GetModules; + +/// +/// Returns the module catalog. The frontend builds both its navigation sidebar and the +/// per-user permission editor from this, so modules never have to be listed twice. +/// +public sealed record GetModulesQuery : IRequest>>; + +/// +/// A module as presented to the client. serialises to its name (for +/// example "PosBilling"), which is the same value that appears in a user's granted +/// module list — so the client can match the two directly. +/// +public sealed record ModuleDto( + AppModule Module, + string Name, + string Group, + string Description, + int SortOrder, + bool AdminOnly); + +internal sealed class GetModulesQueryHandler : IRequestHandler>> +{ + public Task>> Handle( + GetModulesQuery request, + CancellationToken cancellationToken) + { + IReadOnlyCollection modules = + [ + .. ModuleCatalog.All.Select(d => new ModuleDto( + d.Module, + d.Name, + d.Group, + d.Description, + d.SortOrder, + d.AdminOnly)), + ]; + + return Task.FromResult(Result.Success(modules)); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Queries/GetUserById/GetUserByIdQuery.cs b/backend/src/RestaurantPOS.Application/Users/Queries/GetUserById/GetUserByIdQuery.cs new file mode 100644 index 0000000..4fc5040 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Queries/GetUserById/GetUserByIdQuery.cs @@ -0,0 +1,30 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Errors; + +namespace RestaurantPOS.Application.Users.Queries.GetUserById; + +/// Loads a single staff account for the edit screen. +public sealed record GetUserByIdQuery(Guid UserId) : IRequest>; + +internal sealed class GetUserByIdQueryHandler(IAppDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + { + var user = await db.Users + .AsNoTracking() + .Include(u => u.ModulePermissions) + .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + + return user is null + ? Result.Failure(UserErrors.NotFound(request.UserId)) + : Result.Success(user.ToDto()); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Application/Users/Queries/GetUsers/GetUsersQuery.cs b/backend/src/RestaurantPOS.Application/Users/Queries/GetUsers/GetUsersQuery.cs new file mode 100644 index 0000000..c438d60 --- /dev/null +++ b/backend/src/RestaurantPOS.Application/Users/Queries/GetUsers/GetUsersQuery.cs @@ -0,0 +1,61 @@ +using MediatR; + +using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Mappings; +using RestaurantPOS.Application.Users.Dtos; +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Application.Users.Queries.GetUsers; + +/// +/// Lists staff accounts for the administration screen, with optional filtering. +/// +/// Matches against username or full name. +/// Restricts to a single role when supplied. +/// Restricts to active or inactive accounts when supplied. +public sealed record GetUsersQuery(string? Search, UserRole? Role, bool? IsActive) + : IRequest>>; + +internal sealed class GetUsersQueryHandler(IAppDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle( + GetUsersQuery request, + CancellationToken cancellationToken) + { + var query = db.Users + .AsNoTracking() + .Include(u => u.ModulePermissions) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(request.Search)) + { + var term = request.Search.Trim().ToLowerInvariant(); + query = query.Where(u => + u.Username.Contains(term) || + u.FullName.ToLower().Contains(term)); + } + + if (request.Role is not null) + { + query = query.Where(u => u.Role == request.Role.Value); + } + + if (request.IsActive is not null) + { + query = query.Where(u => u.IsActive == request.IsActive.Value); + } + + var users = await query + .OrderByDescending(u => u.IsActive) + .ThenBy(u => u.FullName) + .ToListAsync(cancellationToken); + + IReadOnlyCollection result = [.. users.Select(u => u.ToDto())]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Common/BaseEntity.cs b/backend/src/RestaurantPOS.Domain/Common/BaseEntity.cs index ad48015..a8723bf 100644 --- a/backend/src/RestaurantPOS.Domain/Common/BaseEntity.cs +++ b/backend/src/RestaurantPOS.Domain/Common/BaseEntity.cs @@ -12,4 +12,4 @@ public abstract class BaseEntity public void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent); public void ClearDomainEvents() => _domainEvents.Clear(); -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Common/Error.cs b/backend/src/RestaurantPOS.Domain/Common/Error.cs index 9d383f8..164930a 100644 --- a/backend/src/RestaurantPOS.Domain/Common/Error.cs +++ b/backend/src/RestaurantPOS.Domain/Common/Error.cs @@ -1,14 +1,28 @@ namespace RestaurantPOS.Domain.Common; +/// Classifies an so transport layers can pick a status code. +public enum ErrorType +{ + None = 0, + Failure = 1, + Validation = 2, + NotFound = 3, + Conflict = 4, + Unauthorized = 5, + Forbidden = 6, +} + #pragma warning disable CA1716 -public record Error(string Code, string Description) +public record Error(string Code, string Description, ErrorType Type = ErrorType.Failure) #pragma warning restore CA1716 { - public static readonly Error None = new(string.Empty, string.Empty); + public static readonly Error None = new(string.Empty, string.Empty, ErrorType.None); public static readonly Error NullValue = new("Error.NullValue", "Null value was provided."); - public static Error Failure(string code, string description) => new(code, description); - public static Error NotFound(string code, string description) => new(code, description); - public static Error Validation(string code, string description) => new(code, description); - public static Error Conflict(string code, string description) => new(code, description); -} + public static Error Failure(string code, string description) => new(code, description, ErrorType.Failure); + public static Error NotFound(string code, string description) => new(code, description, ErrorType.NotFound); + public static Error Validation(string code, string description) => new(code, description, ErrorType.Validation); + public static Error Conflict(string code, string description) => new(code, description, ErrorType.Conflict); + public static Error Unauthorized(string code, string description) => new(code, description, ErrorType.Unauthorized); + public static Error Forbidden(string code, string description) => new(code, description, ErrorType.Forbidden); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Common/IDomainEvent.cs b/backend/src/RestaurantPOS.Domain/Common/IDomainEvent.cs index c336e17..d34c14c 100644 --- a/backend/src/RestaurantPOS.Domain/Common/IDomainEvent.cs +++ b/backend/src/RestaurantPOS.Domain/Common/IDomainEvent.cs @@ -2,4 +2,4 @@ namespace RestaurantPOS.Domain.Common; public interface IDomainEvent { -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Common/Result.cs b/backend/src/RestaurantPOS.Domain/Common/Result.cs index 873750f..152ccf4 100644 --- a/backend/src/RestaurantPOS.Domain/Common/Result.cs +++ b/backend/src/RestaurantPOS.Domain/Common/Result.cs @@ -45,4 +45,4 @@ protected internal Result(TValue? value, bool isSuccess, Error error) public static implicit operator Result(TValue? value) => value is not null ? Success(value) : Failure(Error.NullValue); -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/RefreshToken.cs b/backend/src/RestaurantPOS.Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..6e357f6 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/RefreshToken.cs @@ -0,0 +1,40 @@ +namespace RestaurantPOS.Domain.Entities; + +/// +/// A single issued refresh token. Only a hash of the token is stored, so a leak of the +/// database does not hand an attacker usable sessions. +/// +public sealed class RefreshToken +{ + // EF Core materialisation. + private RefreshToken() + { + } + + internal RefreshToken(Guid userId, string tokenHash, DateTime expiresAtUtc, DateTime nowUtc) + { + Id = Guid.NewGuid(); + UserId = userId; + TokenHash = tokenHash; + ExpiresAtUtc = expiresAtUtc; + CreatedAtUtc = nowUtc; + } + + public Guid Id { get; private set; } + + public Guid UserId { get; private set; } + + /// SHA-256 hash of the opaque token handed to the client. + public string TokenHash { get; private set; } = string.Empty; + + public DateTime ExpiresAtUtc { get; private set; } + + public DateTime CreatedAtUtc { get; private set; } + + public DateTime? RevokedAtUtc { get; private set; } + + /// True when the token has neither been revoked nor expired. + public bool IsActive(DateTime nowUtc) => RevokedAtUtc is null && ExpiresAtUtc > nowUtc; + + internal void Revoke(DateTime nowUtc) => RevokedAtUtc ??= nowUtc; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/User.cs b/backend/src/RestaurantPOS.Domain/Entities/User.cs new file mode 100644 index 0000000..97dc419 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/User.cs @@ -0,0 +1,301 @@ +using System.Text.RegularExpressions; + +using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A staff account. Aggregate root owning the user's module grants and refresh tokens. +/// +/// +/// The aggregate never sees plaintext secrets: callers pass in already-hashed passwords and +/// PINs. Hashing lives in the infrastructure layer behind IPasswordHasher. +/// +public sealed partial class User : BaseEntity +{ + public const int UsernameMinLength = 3; + public const int UsernameMaxLength = 32; + public const int FullNameMaxLength = 120; + public const int EmailMaxLength = 200; + + /// Length of the numeric approval PIN used to authorise privileged actions. + public const int ApprovalPinLength = 4; + + private readonly List _modulePermissions = []; + private readonly List _refreshTokens = []; + + // EF Core materialisation. + private User() + { + } + + private User( + string username, + string fullName, + string? email, + string passwordHash, + UserRole role, + bool mustChangePassword, + bool isSystemAdmin) + { + Username = NormaliseUsername(username); + FullName = NormaliseFullName(fullName); + Email = NormaliseEmail(email); + PasswordHash = passwordHash; + Role = role; + MustChangePassword = mustChangePassword; + IsSystemAdmin = isSystemAdmin; + IsActive = true; + } + + /// Login identifier. Always stored lower-cased so lookups are case-insensitive. + public string Username { get; private set; } = string.Empty; + + public string FullName { get; private set; } = string.Empty; + + /// Optional — floor staff are not required to have an email address. + public string? Email { get; private set; } + + public string PasswordHash { get; private set; } = string.Empty; + + public UserRole Role { get; private set; } + + public bool IsActive { get; private set; } + + /// Forces the password-reset screen on next sign-in. + public bool MustChangePassword { get; private set; } + + /// Hash of the 4-digit approval PIN. Administrators only; null when unset. + public string? ApprovalPinHash { get; private set; } + + public DateTime? ApprovalPinSetAtUtc { get; private set; } + + public DateTime? LastLoginAtUtc { get; private set; } + + /// + /// True for the account created by database seeding. It cannot be deactivated or demoted, + /// which guarantees the system can always be administered. + /// + public bool IsSystemAdmin { get; private set; } + + public IReadOnlyCollection ModulePermissions => _modulePermissions.AsReadOnly(); + + public IReadOnlyCollection RefreshTokens => _refreshTokens.AsReadOnly(); + + /// Creates a staff account. Administrators implicitly hold every module. + public static User Create( + string username, + string fullName, + string? email, + string passwordHash, + UserRole role, + IEnumerable? modules = null, + bool mustChangePassword = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(passwordHash); + + var user = new User(username, fullName, email, passwordHash, role, mustChangePassword, isSystemAdmin: false); + + if (role == UserRole.User && modules is not null) + { + user.ReplaceModuleGrants(modules); + } + + return user; + } + + /// Creates the built-in administrator used to bootstrap a fresh installation. + public static User CreateSystemAdmin(string username, string fullName, string passwordHash) => + new(username, fullName, email: null, passwordHash, UserRole.Admin, + mustChangePassword: true, isSystemAdmin: true); + + /// + /// True when the user may open . Administrators always can. + /// + public bool HasAccessTo(AppModule module) => + Role == UserRole.Admin || _modulePermissions.Exists(p => p.Module == module); + + /// Modules the user may open, expanding an administrator to the full catalog. + public IReadOnlyCollection EffectiveModules() => + Role == UserRole.Admin + ? [.. ModuleCatalog.All.Select(d => d.Module)] + : [.. _modulePermissions.Select(p => p.Module).Order()]; + + public void UpdateProfile(string fullName, string? email) + { + FullName = NormaliseFullName(fullName); + Email = NormaliseEmail(email); + } + + /// Replaces the user's module grants wholesale. No-op for administrators. + public void ReplaceModuleGrants(IEnumerable modules) + { + ArgumentNullException.ThrowIfNull(modules); + + _modulePermissions.Clear(); + + if (Role == UserRole.Admin) + { + // Administrators derive access from their role, so explicit grants are redundant. + return; + } + + foreach (var module in modules.Distinct().Order()) + { + _modulePermissions.Add(new UserModulePermission(Id, module)); + } + } + + /// + /// Changes the user's role. Promoting to administrator drops the now-redundant module + /// grants; demoting leaves the user with no modules until an administrator assigns some. + /// + public void ChangeRole(UserRole role) + { + if (Role == role) + { + return; + } + + Role = role; + _modulePermissions.Clear(); + + if (role == UserRole.User) + { + // A demoted administrator also loses the approval PIN, which is admin-only. + ClearApprovalPin(); + } + } + + /// Sets a new password chosen by the user, clearing the forced-reset flag. + public void SetPassword(string passwordHash) + { + ArgumentException.ThrowIfNullOrWhiteSpace(passwordHash); + + PasswordHash = passwordHash; + MustChangePassword = false; + } + + /// + /// Sets a password on the user's behalf (administrator reset). The user is forced to + /// choose their own password at next sign-in. + /// + public void ResetPassword(string passwordHash) + { + ArgumentException.ThrowIfNullOrWhiteSpace(passwordHash); + + PasswordHash = passwordHash; + MustChangePassword = true; + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + /// Stores the hash of a newly issued approval PIN. + public void SetApprovalPin(string pinHash, DateTime nowUtc) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pinHash); + + ApprovalPinHash = pinHash; + ApprovalPinSetAtUtc = nowUtc; + } + + public void ClearApprovalPin() + { + ApprovalPinHash = null; + ApprovalPinSetAtUtc = null; + } + + public bool HasApprovalPin => ApprovalPinHash is not null; + + public void RecordSuccessfulLogin(DateTime nowUtc) => LastLoginAtUtc = nowUtc; + + /// Records a newly issued refresh token and prunes ones that are no longer usable. + public RefreshToken IssueRefreshToken(string tokenHash, DateTime expiresAtUtc, DateTime nowUtc) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tokenHash); + + _refreshTokens.RemoveAll(t => !t.IsActive(nowUtc)); + + var token = new RefreshToken(Id, tokenHash, expiresAtUtc, nowUtc); + _refreshTokens.Add(token); + return token; + } + + /// Finds a usable refresh token by its hash. + public RefreshToken? FindActiveRefreshToken(string tokenHash, DateTime nowUtc) => + _refreshTokens.Find(t => t.TokenHash == tokenHash && t.IsActive(nowUtc)); + + public void RevokeRefreshToken(RefreshToken token, DateTime nowUtc) + { + ArgumentNullException.ThrowIfNull(token); + token.Revoke(nowUtc); + } + + /// Revokes every outstanding session, e.g. on sign-out or password change. + public void RevokeAllRefreshTokens(DateTime nowUtc) + { + foreach (var token in _refreshTokens) + { + token.Revoke(nowUtc); + } + } + + /// + /// True when is a syntactically valid login name. Applies the + /// same trim-and-lower-case normalisation as , so anything this accepts + /// the aggregate will too — otherwise validators would reject names the domain allows. + /// + public static bool IsValidUsername(string? username) => + !string.IsNullOrWhiteSpace(username) && UsernamePattern().IsMatch(username.Trim().ToLowerInvariant()); + + private static string NormaliseUsername(string username) + { + ArgumentException.ThrowIfNullOrWhiteSpace(username); + + var normalised = username.Trim().ToLowerInvariant(); + + if (!UsernamePattern().IsMatch(normalised)) + { + throw new ArgumentException( + $"'{username}' is not a valid username. Use {UsernameMinLength}-{UsernameMaxLength} " + + "letters, digits, dots, hyphens or underscores.", + nameof(username)); + } + + return normalised; + } + + private static string NormaliseFullName(string fullName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fullName); + + var trimmed = fullName.Trim(); + + return trimmed.Length > FullNameMaxLength + ? throw new ArgumentException($"Full name cannot exceed {FullNameMaxLength} characters.", nameof(fullName)) + : trimmed; + } + + private static string? NormaliseEmail(string? email) + { + if (string.IsNullOrWhiteSpace(email)) + { + return null; + } + + var trimmed = email.Trim().ToLowerInvariant(); + + return trimmed.Length > EmailMaxLength + ? throw new ArgumentException($"Email cannot exceed {EmailMaxLength} characters.", nameof(email)) + : trimmed; + } + + // Attribute arguments must be compile-time literals, so the {3,32} bound is spelled out + // here rather than interpolated. Keep it in step with UsernameMinLength/UsernameMaxLength. + [GeneratedRegex("^[a-z0-9._-]{3,32}$", RegexOptions.CultureInvariant)] + private static partial Regex UsernamePattern(); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Entities/UserModulePermission.cs b/backend/src/RestaurantPOS.Domain/Entities/UserModulePermission.cs new file mode 100644 index 0000000..8f2f4a2 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Entities/UserModulePermission.cs @@ -0,0 +1,33 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Entities; + +/// +/// A grant of one module to one user. Access is currently all-or-nothing per module: +/// the presence of a row means the user may open that module. +/// +/// +/// Modelled as its own row (rather than, say, a bit mask on ) so that +/// action-level flags such as CanCreate or CanApprove can later be added as +/// nullable columns without restructuring existing data. +/// +public sealed class UserModulePermission +{ + // EF Core materialisation. + private UserModulePermission() + { + } + + internal UserModulePermission(Guid userId, AppModule module) + { + UserId = userId; + Module = module; + GrantedAtUtc = DateTime.UtcNow; + } + + public Guid UserId { get; private set; } + + public AppModule Module { get; private set; } + + public DateTime GrantedAtUtc { get; private set; } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/AppModule.cs b/backend/src/RestaurantPOS.Domain/Enums/AppModule.cs new file mode 100644 index 0000000..7601017 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/AppModule.cs @@ -0,0 +1,21 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// Assignable functional areas of the POS. Values are persisted, so they must remain stable: +/// append new members with new numbers, never renumber existing ones. +/// +public enum AppModule +{ + PosBilling = 1, + RecipeManagement = 2, + StoreStockManagement = 3, + KitchenStockRelease = 4, + KitchenStockTracking = 5, + KitchenOperations = 6, + ReportsAnalytics = 7, + UserManagement = 8, + Notifications = 9, + SupplierManagement = 10, + ExpensesManagement = 11, + SystemSettings = 12, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Enums/UserRole.cs b/backend/src/RestaurantPOS.Domain/Enums/UserRole.cs new file mode 100644 index 0000000..d95962b --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Enums/UserRole.cs @@ -0,0 +1,14 @@ +namespace RestaurantPOS.Domain.Enums; + +/// +/// Coarse-grained role. Fine-grained access is driven by per-user module grants; +/// see and UserModulePermission. +/// +public enum UserRole +{ + /// Full access to every module regardless of explicit grants. + Admin = 1, + + /// Access limited to the modules explicitly granted by an administrator. + User = 2, +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/AuthErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/AuthErrors.cs new file mode 100644 index 0000000..2f5aa5e --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/AuthErrors.cs @@ -0,0 +1,40 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by authentication and approval-PIN use cases. +public static class AuthErrors +{ + /// + /// Deliberately identical for an unknown username and a wrong password so the API + /// does not reveal which usernames exist. + /// + public static readonly Error InvalidCredentials = + Error.Unauthorized("Auth.InvalidCredentials", "The username or password is incorrect."); + + public static readonly Error AccountDeactivated = + Error.Forbidden("Auth.AccountDeactivated", "This account has been deactivated. Contact an administrator."); + + public static readonly Error InvalidRefreshToken = + Error.Unauthorized("Auth.InvalidRefreshToken", "Your session has expired. Please sign in again."); + + public static readonly Error PasswordMismatch = + Error.Validation("Auth.PasswordMismatch", "The current password is incorrect."); + + public static readonly Error PasswordReused = + Error.Validation("Auth.PasswordReused", "The new password must be different from the current password."); + + public static readonly Error PinNotSet = + Error.NotFound("Auth.PinNotSet", "No approval PIN has been set for this account."); + + public static readonly Error InvalidPin = + Error.Validation("Auth.InvalidPin", "That approval PIN is not valid."); + + public static readonly Error PinRequiresAdmin = + Error.Validation("Auth.PinRequiresAdmin", "Only administrators can hold an approval PIN."); + + public static readonly Error NoAdminPinConfigured = + Error.Conflict( + "Auth.NoAdminPinConfigured", + "No administrator has configured an approval PIN yet."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Errors/UserErrors.cs b/backend/src/RestaurantPOS.Domain/Errors/UserErrors.cs new file mode 100644 index 0000000..ce39741 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Errors/UserErrors.cs @@ -0,0 +1,37 @@ +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Domain.Errors; + +/// Errors raised by user administration use cases. +public static class UserErrors +{ + public static Error NotFound(Guid id) => + Error.NotFound("User.NotFound", $"No user was found with id '{id}'."); + + public static readonly Error UsernameTaken = + Error.Conflict("User.UsernameTaken", "That username is already in use."); + + public static readonly Error CannotDeactivateSelf = + Error.Conflict("User.CannotDeactivateSelf", "You cannot deactivate your own account."); + + public static readonly Error CannotDemoteSelf = + Error.Conflict("User.CannotDemoteSelf", "You cannot change your own role."); + + public static readonly Error CannotModifySystemAdmin = + Error.Conflict( + "User.CannotModifySystemAdmin", + "The built-in administrator account cannot be deactivated or have its role changed."); + + public static readonly Error LastAdmin = + Error.Conflict( + "User.LastAdmin", + "At least one active administrator must remain. Promote another user first."); + + public static readonly Error ModulesNotApplicableToAdmin = + Error.Validation( + "User.ModulesNotApplicableToAdmin", + "Administrators already have access to every module, so module grants cannot be set for them."); + + public static readonly Error UnknownModule = + Error.Validation("User.UnknownModule", "One or more of the selected modules is not recognised."); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Modules/ModuleCatalog.cs b/backend/src/RestaurantPOS.Domain/Modules/ModuleCatalog.cs new file mode 100644 index 0000000..0e05ae8 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Modules/ModuleCatalog.cs @@ -0,0 +1,80 @@ +using System.Collections.ObjectModel; + +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Modules; + +/// +/// The authoritative list of assignable modules. The frontend renders its navigation and +/// permission editor from this catalog, so adding a module here is all that is needed to +/// make it grantable. +/// +public static class ModuleCatalog +{ + private const string InventoryGroup = "Inventory Management"; + private const string OperationsGroup = "Operations"; + private const string AdministrationGroup = "Administration"; + + private static readonly ModuleDescriptor[] Descriptors = + [ + new(AppModule.PosBilling, "POS & Billing", OperationsGroup, + "Take orders, split and settle bills, and print receipts.", 10), + + new(AppModule.RecipeManagement, "Recipe Management", OperationsGroup, + "Define dishes and the ingredient quantities each one consumes.", 20), + + new(AppModule.StoreStockManagement, "Store Stock Management", InventoryGroup, + "Receive goods into the main store and maintain stock levels.", 30), + + new(AppModule.KitchenStockRelease, "Kitchen Stock Release", InventoryGroup, + "Issue stock from the main store to the kitchen.", 40), + + new(AppModule.KitchenStockTracking, "Kitchen Stock Tracking", InventoryGroup, + "Track consumption and wastage of stock held by the kitchen.", 50), + + new(AppModule.KitchenOperations, "Kitchen Operations", OperationsGroup, + "Kitchen display, ticket queue and preparation status.", 60), + + new(AppModule.SupplierManagement, "Supplier Management", OperationsGroup, + "Maintain suppliers, purchase orders and goods received notes.", 70), + + new(AppModule.ExpensesManagement, "Expenses Management", OperationsGroup, + "Record and categorise day-to-day operating expenses.", 80), + + new(AppModule.ReportsAnalytics, "Reports & Analytics", AdministrationGroup, + "Sales, inventory, expense and staff performance reporting.", 90), + + new(AppModule.Notifications, "Notifications", AdministrationGroup, + "Low-stock, approval and operational alerts.", 100), + + new(AppModule.UserManagement, "User Management & Roles", AdministrationGroup, + "Create staff accounts and control which modules they can open.", 110, AdminOnly: true), + + new(AppModule.SystemSettings, "System Settings & Backup", AdministrationGroup, + "Restaurant details, tax and printer settings, and database backups.", 120, AdminOnly: true), + ]; + + /// All assignable modules in display order. + public static IReadOnlyList All { get; } = + new ReadOnlyCollection( + [.. Descriptors.OrderBy(d => d.SortOrder)]); + + /// Modules an administrator may grant to a non-admin user. + public static IReadOnlyList Assignable { get; } = + new ReadOnlyCollection( + [.. Descriptors.Where(d => !d.AdminOnly).OrderBy(d => d.SortOrder)]); + + private static readonly Dictionary ByModule = + Descriptors.ToDictionary(d => d.Module); + + /// Returns true when the value maps to a module in the catalog. + public static bool IsDefined(AppModule module) => ByModule.ContainsKey(module); + + /// True when the module may be granted to a non-admin user. + public static bool IsAssignableToUser(AppModule module) => + ByModule.TryGetValue(module, out var descriptor) && !descriptor.AdminOnly; + + /// Looks up display metadata for a module. + /// The module is not in the catalog. + public static ModuleDescriptor Describe(AppModule module) => ByModule[module]; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Domain/Modules/ModuleDescriptor.cs b/backend/src/RestaurantPOS.Domain/Modules/ModuleDescriptor.cs new file mode 100644 index 0000000..8176d77 --- /dev/null +++ b/backend/src/RestaurantPOS.Domain/Modules/ModuleDescriptor.cs @@ -0,0 +1,24 @@ +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Domain.Modules; + +/// +/// Display metadata for an . Kept in the domain so the API, the +/// permission editor and the navigation sidebar all describe modules identically. +/// +/// The module being described. +/// Human readable module name. +/// Grouping label used to nest related modules in the UI. +/// Short explanation of what the module allows. +/// Stable ordering for menus and permission lists. +/// +/// When true the module carries administrative authority and is reserved for +/// ; it is never offered in the per-user permission editor. +/// +public sealed record ModuleDescriptor( + AppModule Module, + string Name, + string Group, + string Description, + int SortOrder, + bool AdminOnly = false); \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Clock/SystemDateTimeProvider.cs b/backend/src/RestaurantPOS.Infrastructure/Clock/SystemDateTimeProvider.cs new file mode 100644 index 0000000..6598501 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Clock/SystemDateTimeProvider.cs @@ -0,0 +1,8 @@ +using RestaurantPOS.Application.Common.Interfaces; + +namespace RestaurantPOS.Infrastructure.Clock; + +internal sealed class SystemDateTimeProvider : IDateTimeProvider +{ + public DateTime UtcNow => DateTime.UtcNow; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs b/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs index 3fc01c1..f67b605 100644 --- a/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs +++ b/backend/src/RestaurantPOS.Infrastructure/DependencyInjection.cs @@ -1,7 +1,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Infrastructure.Clock; +using RestaurantPOS.Infrastructure.Identity; using RestaurantPOS.Infrastructure.Persistence; +using RestaurantPOS.Infrastructure.Persistence.Interceptors; +using RestaurantPOS.Infrastructure.Persistence.Seeding; namespace RestaurantPOS.Infrastructure; @@ -9,9 +15,37 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { - services.AddDbContext(options => - options.UseSqlite(configuration.GetConnectionString("DefaultConnection"))); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddOptions() + .Bind(configuration.GetSection(JwtOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddOptions() + .Bind(configuration.GetSection(SeedAdminOptions.SectionName)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + services.AddScoped(); + + services.AddDbContext((serviceProvider, options) => + options + .UseSqlite( + configuration.GetConnectionString("DefaultConnection"), + // The collections hanging off a user are tiny, so one round trip beats the + // extra queries splitting would cost. + sqlite => sqlite.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery)) + .AddInterceptors(serviceProvider.GetRequiredService())); + + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddScoped(); return services; } -} +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Identity/BCryptPasswordHasher.cs b/backend/src/RestaurantPOS.Infrastructure/Identity/BCryptPasswordHasher.cs new file mode 100644 index 0000000..6dc3b56 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Identity/BCryptPasswordHasher.cs @@ -0,0 +1,41 @@ +using RestaurantPOS.Application.Common.Interfaces; + +namespace RestaurantPOS.Infrastructure.Identity; + +/// +/// BCrypt-based hashing for passwords and approval PINs. The work factor makes offline +/// guessing expensive, which matters most for the 4-digit PIN with its tiny key space. +/// +internal sealed class BCryptPasswordHasher : IPasswordHasher +{ + /// + /// Cost 12 is roughly 250ms per hash on typical till hardware — slow enough to blunt + /// brute force, fast enough to keep sign-in snappy. + /// + private const int WorkFactor = 12; + + public string Hash(string plaintext) + { + ArgumentException.ThrowIfNullOrEmpty(plaintext); + + return BCrypt.Net.BCrypt.HashPassword(plaintext, WorkFactor); + } + + public bool Verify(string plaintext, string hash) + { + if (string.IsNullOrEmpty(plaintext) || string.IsNullOrEmpty(hash)) + { + return false; + } + + try + { + return BCrypt.Net.BCrypt.Verify(plaintext, hash); + } + catch (BCrypt.Net.SaltParseException) + { + // A malformed stored hash must read as "wrong password", never as an unhandled 500. + return false; + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Identity/JwtOptions.cs b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtOptions.cs new file mode 100644 index 0000000..1d2bc33 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtOptions.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace RestaurantPOS.Infrastructure.Identity; + +/// Token issuing and validation settings, bound from the Jwt configuration section. +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; set; } = "RestaurantPOS"; + + public string Audience { get; set; } = "RestaurantPOS.Client"; + + /// + /// Base64 signing key. Left unset for a local install, in which case a random key is + /// generated on first run and kept in . + /// + public string? SigningKey { get; set; } + + /// + /// Where the auto-generated signing key is stored. Relative paths resolve against the + /// application directory. Deleting this file signs everyone out. + /// + public string KeyFilePath { get; set; } = Path.Combine("keys", "jwt-signing.key"); + + /// + /// Access token lifetime. Short by design — the client silently refreshes, and a shorter + /// window limits how long a revoked user keeps working. + /// + [Range(5, 720)] + public int AccessTokenMinutes { get; set; } = 60; + + /// How long a till stays signed in without re-entering a password. + [Range(1, 90)] + public int RefreshTokenDays { get; set; } = 14; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Identity/JwtSigningKeyProvider.cs b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtSigningKeyProvider.cs new file mode 100644 index 0000000..490e025 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtSigningKeyProvider.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; + +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace RestaurantPOS.Infrastructure.Identity; + +/// +/// Supplies the symmetric key used to sign and validate access tokens. +/// +/// +/// This system is installed on a single machine in the restaurant, so requiring the installer +/// to invent and configure a secret would be friction that ends in a weak shared default. +/// Instead a strong key is generated on first run and persisted next to the application. An +/// explicitly configured always wins, which is what a +/// multi-machine or containerised deployment would use. +/// +public sealed class JwtSigningKeyProvider +{ + private const int KeySizeBytes = 64; + + public JwtSigningKeyProvider(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var settings = options.Value; + + var keyMaterial = !string.IsNullOrWhiteSpace(settings.SigningKey) + ? Convert.FromBase64String(settings.SigningKey) + : LoadOrCreatePersistedKey(settings.KeyFilePath); + + if (keyMaterial.Length < 32) + { + throw new InvalidOperationException( + "The configured JWT signing key is too short. Supply at least 32 bytes of base64-encoded material."); + } + + SecurityKey = new SymmetricSecurityKey(keyMaterial); + } + + public SymmetricSecurityKey SecurityKey { get; } + + private static byte[] LoadOrCreatePersistedKey(string keyFilePath) + { + var path = Path.IsPathRooted(keyFilePath) + ? keyFilePath + : Path.Combine(AppContext.BaseDirectory, keyFilePath); + + if (File.Exists(path)) + { + return Convert.FromBase64String(File.ReadAllText(path).Trim()); + } + + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var generated = RandomNumberGenerator.GetBytes(KeySizeBytes); + File.WriteAllText(path, Convert.ToBase64String(generated)); + + return generated; + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Identity/JwtTokenService.cs b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtTokenService.cs new file mode 100644 index 0000000..04d54fa --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Identity/JwtTokenService.cs @@ -0,0 +1,93 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Application.Common.Security; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Infrastructure.Identity; + +/// Issues signed access tokens and opaque refresh tokens. +internal sealed class JwtTokenService( + IOptions options, + JwtSigningKeyProvider keyProvider, + IDateTimeProvider clock) : ITokenService +{ + private const int RefreshTokenSizeBytes = 32; + + private readonly JwtOptions _options = options.Value; + + public AccessToken CreateAccessToken(User user) + { + ArgumentNullException.ThrowIfNull(user); + + var now = clock.UtcNow; + var expires = now.AddMinutes(_options.AccessTokenMinutes); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.UniqueName, user.Username), + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.Username), + new(ClaimTypes.Role, user.Role.ToString()), + }; + + if (user.MustChangePassword) + { + claims.Add(new Claim(AppClaimTypes.MustChangePassword, "true")); + } + + // Administrators are authorised by role, so listing every module would only bloat the + // token. Regular users carry one claim per granted module. + if (user.Role != UserRole.Admin) + { + claims.AddRange(user + .EffectiveModules() + .Select(m => new Claim(AppClaimTypes.Module, m.ToString()))); + } + + var credentials = new SigningCredentials(keyProvider.SecurityKey, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + notBefore: now, + expires: expires, + signingCredentials: credentials); + + return new AccessToken(new JwtSecurityTokenHandler().WriteToken(token), expires); + } + + public RefreshTokenPair CreateRefreshToken() + { + var value = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(RefreshTokenSizeBytes)); + + return new RefreshTokenPair( + value, + HashRefreshToken(value), + clock.UtcNow.AddDays(_options.RefreshTokenDays)); + } + + /// + /// A plain SHA-256 digest is enough here: the token is 256 bits of cryptographic randomness, + /// so there is no low-entropy input for an attacker to brute force the way there is with a + /// password. Using BCrypt instead would only slow every request down. + /// + public string HashRefreshToken(string token) + { + ArgumentException.ThrowIfNullOrEmpty(token); + + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + + return Convert.ToHexString(digest).ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs index b8cea10..249c9be 100644 --- a/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/AppDbContext.cs @@ -1,10 +1,14 @@ using MediatR; + using Microsoft.EntityFrameworkCore; + +using RestaurantPOS.Application.Common.Interfaces; using RestaurantPOS.Domain.Common; +using RestaurantPOS.Domain.Entities; namespace RestaurantPOS.Infrastructure.Persistence; -public class AppDbContext : DbContext +public class AppDbContext : DbContext, IAppDbContext { private readonly IPublisher? _publisher; @@ -13,6 +17,12 @@ public AppDbContext(DbContextOptions options, IPublisher? publishe _publisher = publisher; } + public DbSet Users => Set(); + + public DbSet UserModulePermissions => Set(); + + public DbSet RefreshTokens => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..f67c64c --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("RefreshTokens"); + + builder.HasKey(t => t.Id); + + // The aggregate assigns the id, so the key is already populated by the time EF sees a + // new token. Saying so explicitly stops EF from assuming a set key means an existing + // row and issuing an UPDATE for a token that was only just created. + builder.Property(t => t.Id).ValueGeneratedNever(); + + builder.Property(t => t.TokenHash) + .IsRequired() + .HasMaxLength(128); + + // Token lookup on refresh goes through this index. + builder.HasIndex(t => t.TokenHash).IsUnique(); + + builder.Property(t => t.ExpiresAtUtc).IsRequired(); + builder.Property(t => t.CreatedAtUtc).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..e7e8d63 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("Users"); + + builder.HasKey(u => u.Id); + + // Ids are generated by the domain, not the database. + builder.Property(u => u.Id).ValueGeneratedNever(); + + builder.Property(u => u.Username) + .IsRequired() + .HasMaxLength(User.UsernameMaxLength); + + // Usernames are normalised to lower case by the aggregate, so a plain unique index is + // enough to make lookups case-insensitive without relying on collation. + builder.HasIndex(u => u.Username).IsUnique(); + + builder.Property(u => u.FullName) + .IsRequired() + .HasMaxLength(User.FullNameMaxLength); + + builder.Property(u => u.Email) + .HasMaxLength(User.EmailMaxLength); + + builder.Property(u => u.PasswordHash) + .IsRequired() + .HasMaxLength(200); + + builder.Property(u => u.ApprovalPinHash) + .HasMaxLength(200); + + builder.Property(u => u.Role) + .IsRequired() + .HasConversion(); + + builder.Property(u => u.IsActive).IsRequired(); + builder.Property(u => u.MustChangePassword).IsRequired(); + builder.Property(u => u.IsSystemAdmin).IsRequired(); + + // The aggregate exposes read-only views over private lists, so EF must read and write + // the backing fields rather than the properties. + builder.Metadata + .FindNavigation(nameof(User.ModulePermissions))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.Metadata + .FindNavigation(nameof(User.RefreshTokens))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(u => u.ModulePermissions) + .WithOne() + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(u => u.RefreshTokens) + .WithOne() + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserModulePermissionConfiguration.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserModulePermissionConfiguration.cs new file mode 100644 index 0000000..21703be --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Configurations/UserModulePermissionConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using RestaurantPOS.Domain.Entities; + +namespace RestaurantPOS.Infrastructure.Persistence.Configurations; + +internal sealed class UserModulePermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("UserModulePermissions"); + + // Composite key: a user can hold a given module at most once. + builder.HasKey(p => new { p.UserId, p.Module }); + + builder.Property(p => p.Module) + .IsRequired() + .HasConversion(); + + builder.Property(p => p.GrantedAtUtc).IsRequired(); + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..33f6de4 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Common; + +namespace RestaurantPOS.Infrastructure.Persistence.Interceptors; + +/// +/// Stamps and so +/// individual handlers never have to remember to. +/// +internal sealed class AuditableEntityInterceptor(IDateTimeProvider clock) : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges( + DbContextEventData eventData, + InterceptionResult result) + { + ArgumentNullException.ThrowIfNull(eventData); + + Stamp(eventData.Context); + + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(eventData); + + Stamp(eventData.Context); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private void Stamp(DbContext? context) + { + if (context is null) + { + return; + } + + var now = clock.UtcNow; + + foreach (var entry in context.ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedAtUtc = now; + break; + + case EntityState.Modified: + entry.Entity.UpdatedAtUtc = now; + break; + + default: + break; + } + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.Designer.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.Designer.cs new file mode 100644 index 0000000..884d6a9 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.Designer.cs @@ -0,0 +1,158 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RestaurantPOS.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260802081447_InitialUserManagement")] + partial class InitialUserManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.cs new file mode 100644 index 0000000..988a5ed --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/20260802081447_InitialUserManagement.cs @@ -0,0 +1,111 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialUserManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Username = table.Column(type: "TEXT", maxLength: 32, nullable: false), + FullName = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Email = table.Column(type: "TEXT", maxLength: 200, nullable: true), + PasswordHash = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Role = table.Column(type: "INTEGER", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + MustChangePassword = table.Column(type: "INTEGER", nullable: false), + ApprovalPinHash = table.Column(type: "TEXT", maxLength: 200, nullable: true), + ApprovalPinSetAtUtc = table.Column(type: "TEXT", nullable: true), + LastLoginAtUtc = table.Column(type: "TEXT", nullable: true), + IsSystemAdmin = table.Column(type: "INTEGER", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + UpdatedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + TokenHash = table.Column(type: "TEXT", maxLength: 128, nullable: false), + ExpiresAtUtc = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + RevokedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserModulePermissions", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + Module = table.Column(type: "INTEGER", nullable: false), + GrantedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserModulePermissions", x => new { x.UserId, x.Module }); + table.ForeignKey( + name: "FK_UserModulePermissions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + table: "RefreshTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "UserModulePermissions"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..46ffd44 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,155 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RestaurantPOS.Infrastructure.Persistence; + +#nullable disable + +namespace RestaurantPOS.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.18"); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApprovalPinHash") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApprovalPinSetAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsSystemAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasColumnType("INTEGER"); + + b.Property("GrantedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Module"); + + b.ToTable("UserModulePermissions", (string)null); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.RefreshToken", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.UserModulePermission", b => + { + b.HasOne("RestaurantPOS.Domain.Entities.User", null) + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RestaurantPOS.Domain.Entities.User", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs new file mode 100644 index 0000000..b72050f --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/DatabaseSeeder.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using RestaurantPOS.Application.Common.Interfaces; +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; + +namespace RestaurantPOS.Infrastructure.Persistence.Seeding; + +/// +/// Brings a fresh database up to a usable state: applies migrations and guarantees that at +/// least one administrator exists. +/// +public sealed partial class DatabaseSeeder( + AppDbContext db, + IPasswordHasher passwordHasher, + IOptions options, + ILogger logger) +{ + private readonly SeedAdminOptions _options = options.Value; + + /// + /// Applies pending migrations, then creates the built-in administrator if no administrator + /// account exists. Safe to run on every start-up. + /// + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + await db.Database.MigrateAsync(cancellationToken); + + var anyAdmin = await db.Users.AnyAsync(u => u.Role == UserRole.Admin, cancellationToken); + if (anyAdmin) + { + return; + } + + var username = _options.Username.Trim().ToLowerInvariant(); + + // Guard against a non-admin already holding the configured name, which would otherwise + // fail the unique index and stop the application from starting. + var nameTaken = await db.Users.AnyAsync(u => u.Username == username, cancellationToken); + if (nameTaken) + { + SeedUsernameTaken(logger, username); + return; + } + + var admin = User.CreateSystemAdmin( + username, + _options.FullName, + passwordHasher.Hash(_options.Password)); + + db.Users.Add(admin); + await db.SaveChangesAsync(cancellationToken); + + SeededAdmin(logger, username); + } + + [LoggerMessage( + EventId = 2000, + Level = LogLevel.Warning, + Message = "Seeded the built-in administrator '{Username}'. " + + "It must change its password at first sign-in.")] + private static partial void SeededAdmin(ILogger logger, string username); + + [LoggerMessage( + EventId = 2001, + Level = LogLevel.Error, + Message = "Cannot seed an administrator: the username '{Username}' is already taken by a " + + "non-administrator. Set SeedAdmin:Username to a free name.")] + private static partial void SeedUsernameTaken(ILogger logger, string username); +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/SeedAdminOptions.cs b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/SeedAdminOptions.cs new file mode 100644 index 0000000..9d63587 --- /dev/null +++ b/backend/src/RestaurantPOS.Infrastructure/Persistence/Seeding/SeedAdminOptions.cs @@ -0,0 +1,24 @@ +namespace RestaurantPOS.Infrastructure.Persistence.Seeding; + +/// +/// Credentials for the built-in administrator created on a fresh database, bound from the +/// SeedAdmin configuration section. +/// +/// +/// The seeded account is always flagged to change its password at first sign-in, so the value +/// configured here is a one-time bootstrap credential rather than a lasting password. +/// +public sealed class SeedAdminOptions +{ + public const string SectionName = "SeedAdmin"; + + public string Username { get; set; } = "admin"; + + public string FullName { get; set; } = "System Administrator"; + + /// + /// Bootstrap password. Override it per installation via configuration or the + /// SeedAdmin__Password environment variable. + /// + public string Password { get; set; } = "ChangeMe!123"; +} \ No newline at end of file diff --git a/backend/src/RestaurantPOS.Infrastructure/RestaurantPOS.Infrastructure.csproj b/backend/src/RestaurantPOS.Infrastructure/RestaurantPOS.Infrastructure.csproj index 8a5aa3f..177e4f7 100644 --- a/backend/src/RestaurantPOS.Infrastructure/RestaurantPOS.Infrastructure.csproj +++ b/backend/src/RestaurantPOS.Infrastructure/RestaurantPOS.Infrastructure.csproj @@ -20,6 +20,10 @@ + + + + diff --git a/backend/tests/RestaurantPOS.ArchitectureTests/LayerDependencyTests.cs b/backend/tests/RestaurantPOS.ArchitectureTests/LayerDependencyTests.cs index 00fd8c8..4e30278 100644 --- a/backend/tests/RestaurantPOS.ArchitectureTests/LayerDependencyTests.cs +++ b/backend/tests/RestaurantPOS.ArchitectureTests/LayerDependencyTests.cs @@ -1,5 +1,7 @@ using FluentAssertions; + using NetArchTest.Rules; + using Xunit; namespace RestaurantPOS.ArchitectureTests; diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Authentication/ApprovalPinTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Authentication/ApprovalPinTests.cs new file mode 100644 index 0000000..5cbe180 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Authentication/ApprovalPinTests.cs @@ -0,0 +1,154 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Authentication; + +/// +/// Covers the approval PIN: the mechanism other modules will call when a member of staff needs +/// an administrator to authorise something at the till, such as cancelling an order. +/// +public class ApprovalPinTests : IntegrationTestBase +{ + [Fact] + public async Task AdminCanGenerateAPin_AndItIsReturnedExactlyOnce() + { + var session = await SignInAsAdminAsync(); + session.User.HasApprovalPin.Should().BeFalse(); + + var response = await Client.SetApprovalPinAsync(AdminPassword); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var pin = (await PosApiClient.ReadAsync(response)).Pin; + pin.Should().HaveLength(4).And.MatchRegex("^[0-9]{4}$"); + + // The PIN itself is never readable again — only the fact that one exists. + var me = await PosApiClient.ReadAsync(await Client.GetMeAsync()); + me.HasApprovalPin.Should().BeTrue(); + } + + [Fact] + public async Task AdminCanChooseTheirOwnPin() + { + await SignInAsAdminAsync(); + + var response = await Client.SetApprovalPinAsync(AdminPassword, "4821"); + + (await PosApiClient.ReadAsync(response)).Pin.Should().Be("4821"); + } + + [Fact] + public async Task SettingAPinRequiresTheCurrentPassword() + { + await SignInAsAdminAsync(); + + var response = await Client.SetApprovalPinAsync("WrongPassword1", "4821"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.PasswordMismatch"); + } + + [Theory] + [InlineData("123")] + [InlineData("12345")] + [InlineData("abcd")] + public async Task APinMustBeFourDigits(string pin) + { + await SignInAsAdminAsync(); + + (await Client.SetApprovalPinAsync(AdminPassword, pin)) + .StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task StaffCanPresentAnAdminPinToAuthoriseAnAction() + { + var admin = await SignInAsAdminAsync(); + var pin = (await PosApiClient.ReadAsync( + await Client.SetApprovalPinAsync(AdminPassword, "4821"))).Pin; + + var (staff, _) = await CreateAndSignInStaffAsync(modules: "PosBilling"); + + var response = await staff.VerifyApprovalPinAsync(pin, "Cancel order #1042"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var approval = await PosApiClient.ReadAsync(response); + approval.ApprovedByUserId.Should().Be(admin.User.Id); + approval.ApprovedByName.Should().Be(admin.User.FullName); + } + + [Fact] + public async Task AWrongPinIsRejected() + { + await SignInAsAdminAsync(); + await Client.SetApprovalPinAsync(AdminPassword, "4821"); + var (staff, _) = await CreateAndSignInStaffAsync(); + + var response = await staff.VerifyApprovalPinAsync("1111"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.InvalidPin"); + } + + [Fact] + public async Task VerifyingReportsClearlyWhenNoAdminHasConfiguredAPin() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(); + + var response = await staff.VerifyApprovalPinAsync("4821"); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.NoAdminPinConfigured"); + } + + [Fact] + public async Task StaffCannotHoldAPinOfTheirOwn() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(); + + (await staff.SetApprovalPinAsync("Ravi@2026x", "4821")) + .StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task APinCanBeCleared() + { + await SignInAsAdminAsync(); + await Client.SetApprovalPinAsync(AdminPassword, "4821"); + + (await Client.ClearApprovalPinAsync()).StatusCode.Should().Be(HttpStatusCode.NoContent); + + var me = await PosApiClient.ReadAsync(await Client.GetMeAsync()); + me.HasApprovalPin.Should().BeFalse(); + + // Clearing a PIN that is not set is reported rather than silently succeeding. + (await Client.ClearApprovalPinAsync()).StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DemotingAnAdministratorRemovesTheirApprovalPin() + { + await SignInAsAdminAsync(); + + var created = await Client.CreateUserAsync("owner", "Owner@2026", "Admin", "Restaurant Owner"); + var owner = await PosApiClient.ReadAsync(created); + + var ownerClient = NewClient(); + var login = await ownerClient.LoginAsync("owner", "Owner@2026"); + ownerClient.Authenticate((await PosApiClient.ReadAsync(login)).AccessToken); + var changed = await ownerClient.ChangePasswordAsync("Owner@2026", "Owner@2026New"); + ownerClient.Authenticate((await PosApiClient.ReadAsync(changed)).AccessToken); + await ownerClient.SetApprovalPinAsync("Owner@2026New", "7777"); + + await Client.UpdateUserAsync(owner.Id, "Restaurant Owner", "User", ["PosBilling"]); + + var demoted = await PosApiClient.ReadAsync(await Client.GetUserAsync(owner.Id)); + demoted.HasApprovalPin.Should().BeFalse("an approval PIN carries administrator authority"); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Authentication/AuthenticationTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Authentication/AuthenticationTests.cs new file mode 100644 index 0000000..7f9247a --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Authentication/AuthenticationTests.cs @@ -0,0 +1,164 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Authentication; + +public class AuthenticationTests : IntegrationTestBase +{ + [Fact] + public async Task SeededAdmin_CanSignIn_ButIsFlaggedToChangeItsPassword() + { + var response = await Client.LoginAsync( + CustomWebApplicationFactory.SeedAdminUsername, + CustomWebApplicationFactory.SeedAdminPassword); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var session = await PosApiClient.ReadAsync(response); + session.AccessToken.Should().NotBeNullOrWhiteSpace(); + session.RefreshToken.Should().NotBeNullOrWhiteSpace(); + session.User.MustChangePassword.Should().BeTrue(); + session.User.IsSystemAdmin.Should().BeTrue(); + session.User.Role.Should().Be("Admin"); + } + + [Fact] + public async Task Administrators_HoldEveryModule() + { + var session = await SignInAsAdminAsync(); + + var modules = await PosApiClient.ReadAsync>(await Client.GetModulesAsync()); + + session.User.Modules.Should().BeEquivalentTo(modules.Select(m => m.Module)); + } + + [Theory] + [InlineData("admin", "WrongPassword1")] + [InlineData("does-not-exist", "AnyPassword1")] + public async Task BadCredentials_AreRejectedIndistinguishably(string username, string password) + { + var response = await Client.LoginAsync(username, password); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.InvalidCredentials"); + } + + [Fact] + public async Task PendingPasswordChange_ConfinesTheSessionToTheResetFlow() + { + var login = await Client.LoginAsync( + CustomWebApplicationFactory.SeedAdminUsername, + CustomWebApplicationFactory.SeedAdminPassword); + + Client.Authenticate((await PosApiClient.ReadAsync(login)).AccessToken); + + // Ordinary work is refused with a code the client uses to route to the reset screen... + var blocked = await Client.GetUsersAsync(); + blocked.StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await PosApiClient.ReadErrorCodeAsync(blocked)).Should().Be("Auth.PasswordChangeRequired"); + + // ...while the endpoints the reset screen itself needs stay reachable. + (await Client.GetMeAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ChangingThePassword_LiftsTheRestriction() + { + var session = await SignInAsAdminAsync(); + + session.User.MustChangePassword.Should().BeFalse(); + (await Client.GetUsersAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ChangePassword_RejectsAWrongCurrentPasswordAndAReusedOne() + { + await SignInAsAdminAsync(); + + var wrongCurrent = await Client.ChangePasswordAsync("NotMyPassword1", "Another@2026"); + wrongCurrent.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(wrongCurrent)).Should().Be("Auth.PasswordMismatch"); + + var reused = await Client.ChangePasswordAsync(AdminPassword, AdminPassword); + reused.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await PosApiClient.ReadErrorCodeAsync(reused)).Should().Be("Auth.PasswordReused"); + } + + [Fact] + public async Task ChangePassword_EnforcesThePasswordPolicy() + { + await SignInAsAdminAsync(); + + var response = await Client.ChangePasswordAsync(AdminPassword, "weak"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task RefreshToken_IsRotatedAndTheOldOneStopsWorking() + { + var first = await SignInAsAdminAsync(); + + var refreshed = await Client.RefreshAsync(first.RefreshToken); + refreshed.StatusCode.Should().Be(HttpStatusCode.OK); + + var second = await PosApiClient.ReadAsync(refreshed); + second.RefreshToken.Should().NotBe(first.RefreshToken); + + // Replaying the consumed token must fail, so a stolen copy has a short useful life. + (await Client.RefreshAsync(first.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await Client.RefreshAsync(second.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Refresh_RejectsATokenItNeverIssued() + { + var response = await Client.RefreshAsync("not-a-real-token"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("Auth.InvalidRefreshToken"); + } + + [Fact] + public async Task Logout_RevokesTheRefreshTokenAndIsSafeToRepeat() + { + var session = await SignInAsAdminAsync(); + + (await Client.LogoutAsync(session.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await Client.RefreshAsync(session.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // Signing out twice, or with nothing at all, must never fail. + (await Client.LogoutAsync(session.RefreshToken)).StatusCode.Should().Be(HttpStatusCode.NoContent); + (await Client.LogoutAsync(null)).StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task ChangingPassword_EndsEveryOtherSession() + { + await SignInAsAdminAsync(); + + // A second till signed in as the same account. + var otherTill = NewClient(); + var otherLogin = await otherTill.LoginAsync( + CustomWebApplicationFactory.SeedAdminUsername, AdminPassword); + var otherSession = await PosApiClient.ReadAsync(otherLogin); + + await Client.ChangePasswordAsync(AdminPassword, "Rotated@2026"); + + (await otherTill.RefreshAsync(otherSession.RefreshToken)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ProtectedEndpoints_RejectAnonymousCallers() + { + (await Client.GetMeAsync()).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await Client.GetUsersAsync()).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await Client.GetModulesAsync()).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/IntegrationTestBase.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/IntegrationTestBase.cs new file mode 100644 index 0000000..484613e --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/IntegrationTestBase.cs @@ -0,0 +1,86 @@ +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Common; + +/// +/// Gives every test its own application instance and database. +/// +/// +/// These tests deliberately mutate global state — the administrator's password, whether an +/// account is active, whether a PIN exists — so sharing one instance across a class would make +/// them order-dependent. xUnit constructs a fresh test class instance per test, so +/// gives each one a clean install to work against. +/// +public abstract class IntegrationTestBase : IAsyncLifetime, IDisposable +{ + /// Password the seeded administrator is moved to during provisioning. + protected const string AdminPassword = "Lakshmi@2026"; + + private CustomWebApplicationFactory _factory = null!; + + /// An unauthenticated client against this test's own application instance. + protected PosApiClient Client { get; private set; } = null!; + + public Task InitializeAsync() + { + _factory = new CustomWebApplicationFactory(); + Client = new PosApiClient(_factory.CreateClient()); + + return Task.CompletedTask; + } + + public Task DisposeAsync() => Task.CompletedTask; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _factory?.Dispose(); + } + } + + /// Creates a second, independent client against the same application instance. + protected PosApiClient NewClient() => new(_factory.CreateClient()); + + /// + /// Signs in as a fully provisioned administrator: seeded credentials + /// exchanged for a real password, leaving an unrestricted session. + /// + protected Task SignInAsAdminAsync() => + Client.SignInAsProvisionedAdminAsync(AdminPassword); + + /// + /// Creates a staff account and signs a separate client in as it, completing the mandatory + /// first password change. Returns that client and the account's id. + /// + protected async Task<(PosApiClient StaffClient, Guid UserId)> CreateAndSignInStaffAsync( + string username = "cashier01", + string finalPassword = "Ravi@2026x", + params string[] modules) + { + const string temporaryPassword = "Temp@2026aa"; + + var created = await Client.CreateUserAsync( + username, temporaryPassword, "User", "Ravi Kumar", null, modules); + + created.EnsureSuccessStatusCode(); + var user = await PosApiClient.ReadAsync(created); + + var staff = NewClient(); + var login = await staff.LoginAsync(username, temporaryPassword); + login.EnsureSuccessStatusCode(); + staff.Authenticate((await PosApiClient.ReadAsync(login)).AccessToken); + + var changed = await staff.ChangePasswordAsync(temporaryPassword, finalPassword); + changed.EnsureSuccessStatusCode(); + staff.Authenticate((await PosApiClient.ReadAsync(changed)).AccessToken); + + return (staff, user.Id); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs new file mode 100644 index 0000000..0f6f0a6 --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Common/PosApiClient.cs @@ -0,0 +1,161 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RestaurantPOS.IntegrationTests.Common; + +/// Session payload returned by sign-in, refresh and password change. +public sealed record SessionResponse( + string AccessToken, + string RefreshToken, + UserResponse User); + +/// A staff account as the API returns it. +public sealed record UserResponse( + Guid Id, + string Username, + string FullName, + string? Email, + string Role, + bool IsActive, + bool MustChangePassword, + bool IsSystemAdmin, + bool HasApprovalPin, + IReadOnlyCollection Modules); + +/// A module catalog entry. +public sealed record ModuleResponse(string Module, string Name, string Group, bool AdminOnly); + +/// Who authorised a PIN-gated action. +public sealed record ApprovalResponse(Guid ApprovedByUserId, string ApprovedByName); + +/// The plaintext PIN, returned once when it is set. +public sealed record PinResponse(string Pin); + +/// +/// Thin wrapper over that keeps the tests focused on behaviour rather +/// than on URL and JSON plumbing. +/// +public sealed class PosApiClient(HttpClient http) +{ + private const string BaseUrl = "/api/v1"; + + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() }, + }; + + public HttpClient Http { get; } = http; + + /// Attaches a bearer token to every subsequent request, or clears it when null. + public PosApiClient Authenticate(string? accessToken) + { + Http.DefaultRequestHeaders.Authorization = accessToken is null + ? null + : new AuthenticationHeaderValue("Bearer", accessToken); + + return this; + } + + public Task LoginAsync(string username, string password) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/login", new { username, password }, Json); + + public Task RefreshAsync(string refreshToken) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/refresh", new { refreshToken }, Json); + + public Task LogoutAsync(string? refreshToken) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/logout", new { refreshToken }, Json); + + public Task ChangePasswordAsync(string currentPassword, string newPassword) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/change-password", new { currentPassword, newPassword }, Json); + + public Task GetMeAsync() => Http.GetAsync($"{BaseUrl}/auth/me"); + + public Task GetModulesAsync() => Http.GetAsync($"{BaseUrl}/modules"); + + public Task GetUsersAsync(string? query = null) => + Http.GetAsync($"{BaseUrl}/users{query}"); + + public Task GetUserAsync(Guid id) => Http.GetAsync($"{BaseUrl}/users/{id}"); + + public Task CreateUserAsync( + string username, + string password, + string role = "User", + string fullName = "Test Staff", + string? email = null, + params string[] modules) => + Http.PostAsJsonAsync( + $"{BaseUrl}/users", + new { username, fullName, email, password, role, modules }, + Json); + + public Task UpdateUserAsync( + Guid id, + string fullName, + string role, + string[] modules, + string? email = null) => + Http.PutAsJsonAsync($"{BaseUrl}/users/{id}", new { fullName, email, role, modules }, Json); + + public Task SetUserActiveAsync(Guid id, bool isActive) => + Http.PutAsJsonAsync($"{BaseUrl}/users/{id}/status", new { isActive }, Json); + + public Task ResetUserPasswordAsync(Guid id, string newPassword) => + Http.PostAsJsonAsync($"{BaseUrl}/users/{id}/password", new { newPassword }, Json); + + public Task SetApprovalPinAsync(string currentPassword, string? pin = null) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/pin", new { currentPassword, pin }, Json); + + public Task ClearApprovalPinAsync() => Http.DeleteAsync($"{BaseUrl}/auth/pin"); + + public Task VerifyApprovalPinAsync(string pin, string? reason = null) => + Http.PostAsJsonAsync($"{BaseUrl}/auth/pin/verify", new { pin, reason }, Json); + + /// Deserialises a response body, failing loudly if it is empty. + public static async Task ReadAsync(HttpResponseMessage response) + { + ArgumentNullException.ThrowIfNull(response); + + var value = await response.Content.ReadFromJsonAsync(Json); + + return value ?? throw new InvalidOperationException( + $"Expected a {typeof(T).Name} body but the response was empty. Status: {response.StatusCode}."); + } + + /// Reads the machine-readable code out of an RFC 7807 problem response. + public static async Task ReadErrorCodeAsync(HttpResponseMessage response) + { + ArgumentNullException.ThrowIfNull(response); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + return document.RootElement.TryGetProperty("code", out var code) + ? code.GetString() + : null; + } + + /// + /// Signs in as the seeded administrator and completes the mandatory first password change, + /// leaving the client authenticated with a fully privileged session. + /// + public async Task SignInAsProvisionedAdminAsync(string newPassword) + { + var login = await LoginAsync( + CustomWebApplicationFactory.SeedAdminUsername, + CustomWebApplicationFactory.SeedAdminPassword); + + login.EnsureSuccessStatusCode(); + Authenticate((await ReadAsync(login)).AccessToken); + + var changed = await ChangePasswordAsync( + CustomWebApplicationFactory.SeedAdminPassword, newPassword); + + changed.EnsureSuccessStatusCode(); + var session = await ReadAsync(changed); + Authenticate(session.AccessToken); + + return session; + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.IntegrationTests/CustomWebApplicationFactory.cs b/backend/tests/RestaurantPOS.IntegrationTests/CustomWebApplicationFactory.cs index a3bafaa..6192d9d 100644 --- a/backend/tests/RestaurantPOS.IntegrationTests/CustomWebApplicationFactory.cs +++ b/backend/tests/RestaurantPOS.IntegrationTests/CustomWebApplicationFactory.cs @@ -1,52 +1,71 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using RestaurantPOS.Infrastructure.Persistence; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; namespace RestaurantPOS.IntegrationTests; +/// +/// Boots the real API against a throwaway SQLite file. +/// +/// +/// The application's own start-up path runs the migrations and seeds the administrator, so +/// these tests exercise the same bootstrap a fresh install goes through rather than a +/// test-only shortcut. +/// public class CustomWebApplicationFactory : WebApplicationFactory { - private readonly string _dbFilePath = Path.Combine(Path.GetTempPath(), $"restaurantpos-test-{Guid.NewGuid()}.db"); + public const string SeedAdminUsername = "admin"; + + /// The bootstrap password the seeded administrator is created with. + public const string SeedAdminPassword = "Bootstrap@2026"; + + private readonly string _dbFilePath = + Path.Combine(Path.GetTempPath(), $"restaurantpos-test-{Guid.NewGuid()}.db"); + + private readonly string _keyFilePath = + Path.Combine(Path.GetTempPath(), $"restaurantpos-test-{Guid.NewGuid()}.key"); protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.ConfigureServices(services => - { - var descriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(DbContextOptions)); + ArgumentNullException.ThrowIfNull(builder); - if (descriptor is not null) - { - services.Remove(descriptor); - } + builder.UseEnvironment(Environments.Development); - services.AddDbContext(options => - options.UseSqlite($"Data Source={_dbFilePath}")); - - var sp = services.BuildServiceProvider(); - using var scope = sp.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); - }); + builder.ConfigureAppConfiguration((_, config) => + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = $"Data Source={_dbFilePath}", + ["SeedAdmin:Username"] = SeedAdminUsername, + ["SeedAdmin:Password"] = SeedAdminPassword, + ["SeedAdmin:FullName"] = "System Administrator", + // Each factory gets its own signing key so tokens never leak between test classes. + ["Jwt:KeyFilePath"] = _keyFilePath, + })); } protected override void Dispose(bool disposing) { base.Dispose(disposing); - if (disposing) + + if (!disposing) + { + return; + } + + foreach (var path in new[] { _dbFilePath, _keyFilePath }) { - if (File.Exists(_dbFilePath)) + try + { + File.Delete(path); + } + catch (IOException) + { + // Transient file locks on Windows are not worth failing a test run over. + } + catch (UnauthorizedAccessException) { - try - { - File.Delete(_dbFilePath); - } - catch - { - // Ignore transient lock cleanup errors - } + // As above. } } } diff --git a/backend/tests/RestaurantPOS.IntegrationTests/HealthCheckTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/HealthCheckTests.cs index a4a4109..4ce354e 100644 --- a/backend/tests/RestaurantPOS.IntegrationTests/HealthCheckTests.cs +++ b/backend/tests/RestaurantPOS.IntegrationTests/HealthCheckTests.cs @@ -1,6 +1,9 @@ using System.Net; + using FluentAssertions; + using Microsoft.AspNetCore.Mvc.Testing; + using Xunit; namespace RestaurantPOS.IntegrationTests; diff --git a/backend/tests/RestaurantPOS.IntegrationTests/RestaurantPOS.IntegrationTests.csproj b/backend/tests/RestaurantPOS.IntegrationTests/RestaurantPOS.IntegrationTests.csproj index ad94696..0239633 100644 --- a/backend/tests/RestaurantPOS.IntegrationTests/RestaurantPOS.IntegrationTests.csproj +++ b/backend/tests/RestaurantPOS.IntegrationTests/RestaurantPOS.IntegrationTests.csproj @@ -12,7 +12,6 @@ - diff --git a/backend/tests/RestaurantPOS.IntegrationTests/Users/UserManagementTests.cs b/backend/tests/RestaurantPOS.IntegrationTests/Users/UserManagementTests.cs new file mode 100644 index 0000000..1a7083b --- /dev/null +++ b/backend/tests/RestaurantPOS.IntegrationTests/Users/UserManagementTests.cs @@ -0,0 +1,235 @@ +using System.Net; + +using FluentAssertions; + +using RestaurantPOS.IntegrationTests.Common; + +using Xunit; + +namespace RestaurantPOS.IntegrationTests.Users; + +public class UserManagementTests : IntegrationTestBase +{ + [Fact] + public async Task Admin_CreatesAStaffAccountWithTheChosenModules() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateUserAsync( + "cashier01", "Cashier@2026", "User", "Ravi Kumar", "Ravi@SriLakshmi.LK", + "PosBilling", "KitchenOperations"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var user = await PosApiClient.ReadAsync(response); + user.Username.Should().Be("cashier01"); + user.Email.Should().Be("ravi@srilakshmi.lk", "emails are normalised to lower case"); + user.Role.Should().Be("User"); + user.Modules.Should().BeEquivalentTo(["PosBilling", "KitchenOperations"]); + user.MustChangePassword.Should().BeTrue(); + user.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task UsernamesAreUniqueRegardlessOfCasing() + { + await SignInAsAdminAsync(); + await Client.CreateUserAsync("cashier01", "Cashier@2026"); + + var duplicate = await Client.CreateUserAsync("CASHIER01", "Another@2026"); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(duplicate)).Should().Be("User.UsernameTaken"); + } + + [Fact] + public async Task AdministrativeModulesCannotBeGrantedToAStaffAccount() + { + await SignInAsAdminAsync(); + + var response = await Client.CreateUserAsync( + "sneaky", "Sneaky@2026", "User", "Test", null, "UserManagement"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task StaffAreConfinedToTheirGrantedModules() + { + await SignInAsAdminAsync(); + var (staff, _) = await CreateAndSignInStaffAsync(modules: "PosBilling"); + + var me = await PosApiClient.ReadAsync(await staff.GetMeAsync()); + me.Modules.Should().BeEquivalentTo(["PosBilling"]); + + // User administration is an admin power, not a grantable module. + (await staff.GetUsersAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + + // The catalog itself stays readable, because the client renders its sidebar from it. + (await staff.GetModulesAsync()).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UpdatingAUserReplacesItsModuleGrants() + { + await SignInAsAdminAsync(); + var created = await Client.CreateUserAsync( + "cashier01", "Cashier@2026", "User", "Ravi Kumar", null, "PosBilling"); + var user = await PosApiClient.ReadAsync(created); + + var updated = await Client.UpdateUserAsync( + user.Id, "Ravi K. Kumar", "User", ["ReportsAnalytics", "ExpensesManagement"]); + + updated.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await PosApiClient.ReadAsync(updated); + result.FullName.Should().Be("Ravi K. Kumar"); + result.Modules.Should().BeEquivalentTo(["ReportsAnalytics", "ExpensesManagement"]); + } + + [Fact] + public async Task PromotingToAdmin_GrantsEveryModule() + { + await SignInAsAdminAsync(); + var created = await Client.CreateUserAsync( + "manager", "Manager@2026", "User", "Priya", null, "PosBilling"); + var user = await PosApiClient.ReadAsync(created); + + var promoted = await PosApiClient.ReadAsync( + await Client.UpdateUserAsync(user.Id, "Priya", "Admin", [])); + + var catalog = await PosApiClient.ReadAsync>(await Client.GetModulesAsync()); + promoted.Role.Should().Be("Admin"); + promoted.Modules.Should().BeEquivalentTo(catalog.Select(m => m.Module)); + } + + [Fact] + public async Task DeactivatingAUser_BlocksSignInAndKillsExistingSessions() + { + var admin = await SignInAsAdminAsync(); + var (staff, staffId) = await CreateAndSignInStaffAsync(); + + var staffSession = await PosApiClient.ReadAsync( + await NewClient().LoginAsync("cashier01", "Ravi@2026x")); + + (await Client.SetUserActiveAsync(staffId, false)).StatusCode.Should().Be(HttpStatusCode.OK); + + // Outstanding sessions are revoked rather than left to expire on their own. + (await staff.RefreshAsync(staffSession.RefreshToken)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + var blockedLogin = await NewClient().LoginAsync("cashier01", "Ravi@2026x"); + blockedLogin.StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await PosApiClient.ReadErrorCodeAsync(blockedLogin)).Should().Be("Auth.AccountDeactivated"); + + admin.User.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task ReactivatingAUser_RestoresSignIn() + { + await SignInAsAdminAsync(); + var (_, staffId) = await CreateAndSignInStaffAsync(); + + await Client.SetUserActiveAsync(staffId, false); + await Client.SetUserActiveAsync(staffId, true); + + (await NewClient().LoginAsync("cashier01", "Ravi@2026x")) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task AnAdminCannotDeactivateOrDemoteThemselves() + { + var session = await SignInAsAdminAsync(); + + var deactivate = await Client.SetUserActiveAsync(session.User.Id, false); + deactivate.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(deactivate)).Should().Be("User.CannotDeactivateSelf"); + + var demote = await Client.UpdateUserAsync(session.User.Id, session.User.FullName, "User", []); + demote.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(demote)).Should().Be("User.CannotDemoteSelf"); + } + + [Fact] + public async Task TheBuiltInAdministratorCannotBeDeactivatedByAnotherAdmin() + { + var seeded = await SignInAsAdminAsync(); + + // Promote a second administrator and sign in as them. + var created = await Client.CreateUserAsync("owner", "Owner@2026", "Admin", "Restaurant Owner"); + var owner = await PosApiClient.ReadAsync(created); + + var ownerClient = NewClient(); + var login = await ownerClient.LoginAsync("owner", "Owner@2026"); + ownerClient.Authenticate((await PosApiClient.ReadAsync(login)).AccessToken); + var changed = await ownerClient.ChangePasswordAsync("Owner@2026", "Owner@2026New"); + ownerClient.Authenticate((await PosApiClient.ReadAsync(changed)).AccessToken); + + var response = await ownerClient.SetUserActiveAsync(seeded.User.Id, false); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await PosApiClient.ReadErrorCodeAsync(response)).Should().Be("User.CannotModifySystemAdmin"); + owner.Role.Should().Be("Admin"); + } + + [Fact] + public async Task ResettingAPassword_ForcesTheUserToChooseANewOneAndEndsTheirSessions() + { + await SignInAsAdminAsync(); + var (staff, staffId) = await CreateAndSignInStaffAsync(); + + (await Client.ResetUserPasswordAsync(staffId, "Reset@2026x")) + .StatusCode.Should().Be(HttpStatusCode.NoContent); + + // The old password no longer works. + (await NewClient().LoginAsync("cashier01", "Ravi@2026x")) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // The temporary one does, but lands the user straight back on the reset screen. + var relogin = NewClient(); + var session = await PosApiClient.ReadAsync( + await relogin.LoginAsync("cashier01", "Reset@2026x")); + session.User.MustChangePassword.Should().BeTrue(); + + relogin.Authenticate(session.AccessToken); + (await relogin.GetModulesAsync()).StatusCode.Should().Be(HttpStatusCode.Forbidden); + + staff.Should().NotBeNull(); + } + + [Fact] + public async Task UsersCanBeFilteredBySearchRoleAndStatus() + { + await SignInAsAdminAsync(); + await Client.CreateUserAsync("cashier01", "Cashier@2026", "User", "Ravi Kumar"); + await Client.CreateUserAsync("chef01", "Chef@2026aa", "User", "Nimal Perera"); + + var byName = await PosApiClient.ReadAsync>( + await Client.GetUsersAsync("?search=Nimal")); + byName.Should().ContainSingle().Which.Username.Should().Be("chef01"); + + var byUsername = await PosApiClient.ReadAsync>( + await Client.GetUsersAsync("?search=cashier")); + byUsername.Should().ContainSingle().Which.Username.Should().Be("cashier01"); + + var admins = await PosApiClient.ReadAsync>( + await Client.GetUsersAsync("?role=Admin")); + admins.Should().OnlyContain(u => u.Role == "Admin"); + + var active = await PosApiClient.ReadAsync>( + await Client.GetUsersAsync("?isActive=true")); + active.Should().OnlyContain(u => u.IsActive); + } + + [Fact] + public async Task RequestingAnUnknownUserReturnsNotFound() + { + await SignInAsAdminAsync(); + + var response = await Client.GetUserAsync(Guid.NewGuid()); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Application/ApprovalPinValidationTests.cs b/backend/tests/RestaurantPOS.UnitTests/Application/ApprovalPinValidationTests.cs new file mode 100644 index 0000000..143de4c --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Application/ApprovalPinValidationTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; + +using RestaurantPOS.Application.Authentication.Commands.SetApprovalPin; +using RestaurantPOS.Application.Authentication.Commands.VerifyApprovalPin; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Application; + +public class ApprovalPinValidationTests +{ + private readonly SetApprovalPinCommandValidator _setValidator = new(); + private readonly VerifyApprovalPinCommandValidator _verifyValidator = new(); + + [Fact] + public void ANullPinIsAllowedOnSet_MeaningGenerateOneForMe() + { + _setValidator.Validate(new SetApprovalPinCommand("Current@2026", null)).IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("123")] // too short + [InlineData("12345")] // too long + [InlineData("12a4")] // not all digits + [InlineData("")] + public void RejectsPinsThatAreNotFourDigits(string pin) + { + _setValidator.Validate(new SetApprovalPinCommand("Current@2026", pin)).IsValid.Should().BeFalse(); + _verifyValidator.Validate(new VerifyApprovalPinCommand(pin, null)).IsValid.Should().BeFalse(); + } + + [Theory] + [InlineData("0000")] + [InlineData("4821")] + [InlineData("9999")] + public void AcceptsAnyFourDigitPin(string pin) + { + _setValidator.Validate(new SetApprovalPinCommand("Current@2026", pin)).IsValid.Should().BeTrue(); + _verifyValidator.Validate(new VerifyApprovalPinCommand(pin, null)).IsValid.Should().BeTrue(); + } + + [Fact] + public void SettingAPinRequiresTheCurrentPassword() + { + _setValidator.Validate(new SetApprovalPinCommand("", "1234")).IsValid.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Application/CreateUserCommandValidatorTests.cs b/backend/tests/RestaurantPOS.UnitTests/Application/CreateUserCommandValidatorTests.cs new file mode 100644 index 0000000..446bf8f --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Application/CreateUserCommandValidatorTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; + +using RestaurantPOS.Application.Users.Commands.CreateUser; +using RestaurantPOS.Domain.Enums; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Application; + +public class CreateUserCommandValidatorTests +{ + private readonly CreateUserCommandValidator _validator = new(); + + private static CreateUserCommand Valid( + string username = "cashier01", + string password = "Cashier@2026", + UserRole role = UserRole.User, + string? email = null, + IReadOnlyCollection? modules = null) => + new(username, "Ravi Kumar", email, password, role, modules ?? [AppModule.PosBilling]); + + [Fact] + public void AcceptsAWellFormedCommand() + { + _validator.Validate(Valid()).IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("short1A")] // under 8 characters + [InlineData("alllowercase1")] // no upper-case letter + [InlineData("ALLUPPERCASE1")] // no lower-case letter + [InlineData("NoDigitsHere")] // no digit + public void RejectsPasswordsThatFailThePolicy(string password) + { + var result = _validator.Validate(Valid(password: password)); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Password)); + } + + [Theory] + [InlineData("ab")] + [InlineData("has spaces")] + [InlineData("bad!char")] + public void RejectsMalformedUsernames(string username) + { + var result = _validator.Validate(Valid(username: username)); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Username)); + } + + [Fact] + public void RejectsAMalformedEmailButAllowsNone() + { + _validator.Validate(Valid(email: "not-an-email")).IsValid.Should().BeFalse(); + _validator.Validate(Valid(email: null)).IsValid.Should().BeTrue(); + _validator.Validate(Valid(email: "ravi@srilakshmi.lk")).IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData(AppModule.UserManagement)] + [InlineData(AppModule.SystemSettings)] + public void RejectsGrantingAdministrativeModulesDirectly(AppModule module) + { + var result = _validator.Validate(Valid(modules: [module])); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateUserCommand.Modules)); + } + + [Fact] + public void RejectsModuleValuesOutsideTheCatalog() + { + var result = _validator.Validate(Valid(modules: [(AppModule)999])); + + result.IsValid.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/ModuleCatalogTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/ModuleCatalogTests.cs new file mode 100644 index 0000000..37366eb --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/ModuleCatalogTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class ModuleCatalogTests +{ + [Fact] + public void EveryDeclaredModuleIsDescribed() + { + var declared = Enum.GetValues(); + + declared.Should().OnlyContain(m => ModuleCatalog.IsDefined(m), + "the catalog drives navigation and the permission editor, so a module missing from " + + "it would be unreachable in the UI"); + + ModuleCatalog.All.Should().HaveCount(declared.Length); + } + + [Fact] + public void AdministrativeModulesAreNotIndividuallyGrantable() + { + ModuleCatalog.IsAssignableToUser(AppModule.UserManagement).Should().BeFalse(); + ModuleCatalog.IsAssignableToUser(AppModule.SystemSettings).Should().BeFalse(); + ModuleCatalog.IsAssignableToUser(AppModule.PosBilling).Should().BeTrue(); + } + + [Fact] + public void AssignableExcludesExactlyTheAdminOnlyModules() + { + ModuleCatalog.Assignable.Should().BeEquivalentTo(ModuleCatalog.All.Where(d => !d.AdminOnly)); + } + + [Fact] + public void ModulesAreOrderedAndCarryDisplayMetadata() + { + ModuleCatalog.All.Should().BeInAscendingOrder(d => d.SortOrder); + ModuleCatalog.All.Should().OnlyContain(d => + !string.IsNullOrWhiteSpace(d.Name) && + !string.IsNullOrWhiteSpace(d.Group) && + !string.IsNullOrWhiteSpace(d.Description)); + } + + [Fact] + public void EnumValuesAreStableBecauseTheyArePersisted() + { + // Renumbering these would silently repoint every stored permission at a different + // module, so the expected values are pinned here deliberately. + ((int)AppModule.PosBilling).Should().Be(1); + ((int)AppModule.RecipeManagement).Should().Be(2); + ((int)AppModule.StoreStockManagement).Should().Be(3); + ((int)AppModule.KitchenStockRelease).Should().Be(4); + ((int)AppModule.KitchenStockTracking).Should().Be(5); + ((int)AppModule.KitchenOperations).Should().Be(6); + ((int)AppModule.ReportsAnalytics).Should().Be(7); + ((int)AppModule.UserManagement).Should().Be(8); + ((int)AppModule.Notifications).Should().Be(9); + ((int)AppModule.SupplierManagement).Should().Be(10); + ((int)AppModule.ExpensesManagement).Should().Be(11); + ((int)AppModule.SystemSettings).Should().Be(12); + + ((int)UserRole.Admin).Should().Be(1); + ((int)UserRole.User).Should().Be(2); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/Domain/UserTests.cs b/backend/tests/RestaurantPOS.UnitTests/Domain/UserTests.cs new file mode 100644 index 0000000..b2726fc --- /dev/null +++ b/backend/tests/RestaurantPOS.UnitTests/Domain/UserTests.cs @@ -0,0 +1,193 @@ +using FluentAssertions; + +using RestaurantPOS.Domain.Entities; +using RestaurantPOS.Domain.Enums; +using RestaurantPOS.Domain.Modules; + +using Xunit; + +namespace RestaurantPOS.UnitTests.Domain; + +public class UserTests +{ + private const string AnyHash = "hashed-password"; + + private static User NewStaffUser(params AppModule[] modules) => + User.Create("cashier01", "Ravi Kumar", null, AnyHash, UserRole.User, modules); + + [Theory] + [InlineData("Cashier01", "cashier01")] + [InlineData(" ADMIN ", "admin")] + [InlineData("front.desk_2", "front.desk_2")] + public void Create_NormalisesUsernameToLowerCase(string input, string expected) + { + var user = User.Create(input, "Someone", null, AnyHash, UserRole.User); + + user.Username.Should().Be(expected); + } + + [Theory] + [InlineData("ab")] // shorter than the minimum + [InlineData("has spaces")] + [InlineData("bad!char")] + [InlineData("thisusernameisfartoolongtobeacceptedbythedomain")] + public void Create_RejectsMalformedUsernames(string username) + { + var act = () => User.Create(username, "Someone", null, AnyHash, UserRole.User); + + act.Should().Throw(); + } + + [Fact] + public void Create_NormalisesEmailAndTreatsBlankAsAbsent() + { + var withEmail = User.Create("a.user", "A User", " Ravi@Example.COM ", AnyHash, UserRole.User); + var withBlank = User.Create("b.user", "B User", " ", AnyHash, UserRole.User); + + withEmail.Email.Should().Be("ravi@example.com"); + withBlank.Email.Should().BeNull(); + } + + [Fact] + public void Create_FlagsNewAccountsForPasswordChange() + { + var user = NewStaffUser(); + + user.MustChangePassword.Should().BeTrue(); + user.IsActive.Should().BeTrue(); + user.IsSystemAdmin.Should().BeFalse(); + } + + [Fact] + public void HasAccessTo_IsLimitedToGrantedModulesForStaff() + { + var user = NewStaffUser(AppModule.PosBilling); + + user.HasAccessTo(AppModule.PosBilling).Should().BeTrue(); + user.HasAccessTo(AppModule.ReportsAnalytics).Should().BeFalse(); + } + + [Fact] + public void HasAccessTo_IsUnconditionalForAdministrators() + { + var admin = User.Create("owner", "Owner", null, AnyHash, UserRole.Admin); + + admin.ModulePermissions.Should().BeEmpty("administrators derive access from their role"); + ModuleCatalog.All.Should().OnlyContain(d => admin.HasAccessTo(d.Module)); + admin.EffectiveModules().Should().HaveCount(ModuleCatalog.All.Count); + } + + [Fact] + public void Create_IgnoresModuleGrantsForAdministrators() + { + var admin = User.Create("owner", "Owner", null, AnyHash, UserRole.Admin, [AppModule.PosBilling]); + + admin.ModulePermissions.Should().BeEmpty(); + } + + [Fact] + public void ReplaceModuleGrants_OverwritesAndDeduplicates() + { + var user = NewStaffUser(AppModule.PosBilling, AppModule.KitchenOperations); + + user.ReplaceModuleGrants([AppModule.ReportsAnalytics, AppModule.ReportsAnalytics]); + + user.EffectiveModules().Should().ContainSingle().Which.Should().Be(AppModule.ReportsAnalytics); + } + + [Fact] + public void ChangeRole_ToUser_DropsTheApprovalPin() + { + var admin = User.Create("owner", "Owner", null, AnyHash, UserRole.Admin); + admin.SetApprovalPin("pin-hash", DateTime.UtcNow); + + admin.ChangeRole(UserRole.User); + + admin.HasApprovalPin.Should().BeFalse("an approval PIN is an administrator's authority"); + admin.EffectiveModules().Should().BeEmpty(); + } + + [Fact] + public void ChangeRole_ToAdmin_ClearsNowRedundantGrants() + { + var user = NewStaffUser(AppModule.PosBilling); + + user.ChangeRole(UserRole.Admin); + + user.ModulePermissions.Should().BeEmpty(); + user.HasAccessTo(AppModule.SystemSettings).Should().BeTrue(); + } + + [Fact] + public void SetPassword_ClearsTheForcedChangeFlag() + { + var user = NewStaffUser(); + + user.SetPassword("new-hash"); + + user.PasswordHash.Should().Be("new-hash"); + user.MustChangePassword.Should().BeFalse(); + } + + [Fact] + public void ResetPassword_ForcesTheUserToChooseTheirOwn() + { + var user = NewStaffUser(); + user.SetPassword("chosen-by-user"); + + user.ResetPassword("temporary-hash"); + + user.PasswordHash.Should().Be("temporary-hash"); + user.MustChangePassword.Should().BeTrue(); + } + + [Fact] + public void IssueRefreshToken_PrunesTokensThatAreNoLongerUsable() + { + var user = NewStaffUser(); + var now = DateTime.UtcNow; + + user.IssueRefreshToken("expired", now.AddMinutes(-1), now); + user.IssueRefreshToken("live", now.AddDays(7), now); + + user.RefreshTokens.Should().ContainSingle().Which.TokenHash.Should().Be("live"); + } + + [Fact] + public void FindActiveRefreshToken_IgnoresRevokedAndExpiredTokens() + { + var user = NewStaffUser(); + var now = DateTime.UtcNow; + var token = user.IssueRefreshToken("abc", now.AddDays(7), now); + + user.FindActiveRefreshToken("abc", now).Should().NotBeNull(); + + user.RevokeRefreshToken(token, now); + + user.FindActiveRefreshToken("abc", now).Should().BeNull(); + user.FindActiveRefreshToken("never-issued", now).Should().BeNull(); + } + + [Fact] + public void RevokeAllRefreshTokens_EndsEverySession() + { + var user = NewStaffUser(); + var now = DateTime.UtcNow; + user.IssueRefreshToken("a", now.AddDays(7), now); + user.IssueRefreshToken("b", now.AddDays(7), now); + + user.RevokeAllRefreshTokens(now); + + user.RefreshTokens.Should().OnlyContain(t => !t.IsActive(now)); + } + + [Fact] + public void CreateSystemAdmin_IsProtectedAndMustChangeItsPassword() + { + var admin = User.CreateSystemAdmin("admin", "System Administrator", AnyHash); + + admin.IsSystemAdmin.Should().BeTrue(); + admin.Role.Should().Be(UserRole.Admin); + admin.MustChangePassword.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/backend/tests/RestaurantPOS.UnitTests/SmokeTests.cs b/backend/tests/RestaurantPOS.UnitTests/SmokeTests.cs index 194510e..9527431 100644 --- a/backend/tests/RestaurantPOS.UnitTests/SmokeTests.cs +++ b/backend/tests/RestaurantPOS.UnitTests/SmokeTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; + using Xunit; namespace RestaurantPOS.UnitTests; diff --git a/docs/user-management.md b/docs/user-management.md new file mode 100644 index 0000000..3bb735a --- /dev/null +++ b/docs/user-management.md @@ -0,0 +1,163 @@ +# User Management & Roles + +The first module of the Sri Lakshmi Family Restaurant POS. It owns sign-in, staff accounts, +per-user module access, and the administrator approval PIN that other modules will call when a +privileged action needs authorising. + +## Roles and access + +There are two roles: + +| Role | Access | +| ------- | ------------------------------------------------------------------------- | +| `Admin` | Every module, implicitly. Can administer staff and hold an approval PIN. | +| `User` | Only the modules an administrator has explicitly granted. | + +Module access is all-or-nothing per module. Grants are stored one row per (user, module) in +`UserModulePermissions`, so action-level flags (`CanCreate`, `CanApprove`, …) can be added later +as columns with defaults rather than as a restructuring migration. + +Two modules are marked `AdminOnly` in the catalog and are never offered in the permission +editor, because granting them would be granting administrative authority itself: + +- `UserManagement` +- `SystemSettings` + +The catalog is served by `GET /api/v1/modules`. The frontend builds both its navigation sidebar +and the permission editor from it, so adding a module to `ModuleCatalog` on the backend is all +that is needed to make it appear and become grantable. + +## First run + +On start-up the API applies migrations and, if no administrator exists, seeds one from the +`SeedAdmin` configuration section: + +```json +"SeedAdmin": { + "Username": "admin", + "FullName": "System Administrator", + "Password": "ChangeMe!123" +} +``` + +Override per installation with configuration or environment variables +(`SeedAdmin__Username`, `SeedAdmin__Password`). + +The seeded account is flagged `MustChangePassword`, so the first thing anyone signing in as it +must do is choose a real password. + +### Adding the owner + +Sign in as the seeded administrator, then create the owner's account with the `Admin` role and a +temporary password. They will be forced to set their own password at first sign-in — the same +mechanism, no special case. + +## Forced password change + +Any account with `MustChangePassword` set — the seeded admin, anyone just created, anyone whose +password an admin has reset — receives a normal session, but that session is confined to the +password-change flow. + +This is enforced **server-side**, not just in the UI: `PasswordChangeRequiredMiddleware` rejects +every endpoint except those marked `[AllowPendingPasswordChange]` with: + +``` +403 { "code": "Auth.PasswordChangeRequired" } +``` + +so a temporary password cannot be used to do real work by calling the API directly. The client +keys on that code to route to the reset screen. + +## Approval PIN + +An administrator can set or generate a 4-digit PIN (`POST /api/v1/auth/pin`, re-authenticated +with their current password). Only the BCrypt hash is stored — the plaintext is returned exactly +once, at the moment it is set, and cannot be read back. + +`POST /api/v1/auth/pin/verify` is the shared approval gate any module can call. It is callable +by **any signed-in user**, which is the point: a cashier attempting to void an order calls it +while an administrator types their PIN, and the response records who authorised it. + +```jsonc +// POST /api/v1/auth/pin/verify { "pin": "4821", "reason": "Cancel order #1042" } +{ + "approvedByUserId": "…", + "approvedByName": "System Administrator", + "approvedAtUtc": "2026-08-02T08:17:34Z" +} +``` + +Because 4 digits is only ten thousand combinations, this endpoint sits behind a fixed-window +rate limiter (10 attempts/minute, partitioned per user), and PINs are hashed with the same cost +factor as passwords. + +## Sessions + +- **Access token** — JWT, 60 minutes by default, carrying the user's id, role and granted + modules. Administrators carry no module claims; their role covers everything. +- **Refresh token** — opaque, 14 days, **rotated on every use**. Only a SHA-256 hash is stored. + Presenting a token that has already been consumed fails, so a stolen copy has a short life. + +Sessions are revoked immediately — not left to expire — when a user is deactivated, has their +password reset by an admin, or changes their own password (all sessions but the current one). + +### Signing key + +If `Jwt:SigningKey` is empty, a 64-byte key is generated on first run and stored at +`Jwt:KeyFilePath` (default `keys/jwt-signing.key`, alongside the application). This keeps a +single-machine install zero-configuration without shipping a weak shared default. The file is +gitignored; deleting it signs everyone out. A multi-machine deployment should set +`Jwt:SigningKey` explicitly instead. + +## Accounts are deactivated, never deleted + +There is no delete endpoint. Orders, bills and stock movements will reference the staff member +who performed them, so accounts are deactivated to preserve that history. Guards prevent +administering the system into a corner: + +| Guard | Error code | +| ------------------------------ | ------------------------------- | +| Cannot deactivate yourself | `User.CannotDeactivateSelf` | +| Cannot change your own role | `User.CannotDemoteSelf` | +| Built-in admin is protected | `User.CannotModifySystemAdmin` | +| Cannot remove the last admin | `User.LastAdmin` | + +## API reference + +All paths are prefixed `/api/v1`. + +| Method | Path | Who | +| -------- | ----------------------- | ------------------------ | +| `POST` | `/auth/login` | anonymous | +| `POST` | `/auth/refresh` | anonymous | +| `POST` | `/auth/logout` | anonymous | +| `GET` | `/auth/me` | any signed-in user | +| `POST` | `/auth/change-password` | any signed-in user | +| `POST` | `/auth/pin` | admin | +| `DELETE` | `/auth/pin` | admin | +| `POST` | `/auth/pin/verify` | any signed-in user | +| `GET` | `/modules` | any signed-in user | +| `GET` | `/users` | admin | +| `GET` | `/users/{id}` | admin | +| `POST` | `/users` | admin | +| `PUT` | `/users/{id}` | admin | +| `PUT` | `/users/{id}/status` | admin | +| `POST` | `/users/{id}/password` | admin | + +Failures are RFC 7807 problem responses carrying a stable `code` (for example +`Auth.InvalidCredentials`, `User.UsernameTaken`) so clients branch on the code, never on prose. + +## Running it + +```bash +# Backend — migrates, seeds and serves on http://localhost:5207 +cd backend/src/RestaurantPOS.API +dotnet run + +# Backend tests (89: 48 unit, 39 integration, 2 architecture) +cd backend +dotnet test +``` + +Integration tests boot the real API against a throwaway SQLite file and go through the same +migrate-and-seed path a fresh install does, so the bootstrap itself is covered. diff --git a/frontend/.husky/commit-msg b/frontend/.husky/commit-msg deleted file mode 100644 index e81b051..0000000 --- a/frontend/.husky/commit-msg +++ /dev/null @@ -1 +0,0 @@ -npx commitlint --edit "$1" diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit deleted file mode 100644 index 2312dc5..0000000 --- a/frontend/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -npx lint-staged diff --git a/frontend/commitlint.config.cjs b/frontend/commitlint.config.cjs deleted file mode 100644 index d42f351..0000000 --- a/frontend/commitlint.config.cjs +++ /dev/null @@ -1,3 +0,0 @@ -module = { - extends: ["@commitlint/config-conventional"], -}; diff --git a/frontend/lint-staged.config.mjs b/frontend/lint-staged.config.mjs deleted file mode 100644 index 1ff0b6a..0000000 --- a/frontend/lint-staged.config.mjs +++ /dev/null @@ -1,9 +0,0 @@ -export default { - "*.{js,jsx,ts,tsx}": [ - "eslint --fix", - "prettier --write" - ], - "*.{json,css,md}": [ - "prettier --write" - ] -}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec3eeff..02eefba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,22 +8,33 @@ "name": "restaurant-pos-frontend", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.7.1", + "@radix-ui/react-avatar": "^1.2.6", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-query": "^5.64.2", "axios": "^1.7.9", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", "react": "19.2.8", "react-dom": "19.2.8", "react-hook-form": "^7.54.2", "react-redux": "^9.2.0", + "react-router-dom": "^7.18.2", "socket.io-client": "^4.8.1", + "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", "zod": "^3.24.1" }, "devDependencies": { - "@commitlint/cli": "^19.6.1", - "@commitlint/config-conventional": "^19.6.0", "@eslint/eslintrc": "^3.2.0", "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.6.3", @@ -37,7 +48,7 @@ "autoprefixer": "^10.4.20", "concurrently": "^10.0.4", "cross-env": "^10.1.0", - "electron": "^43.2.0", + "electron": "^42.0.0", "electron-builder": "^26.15.3", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -46,9 +57,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-unused-imports": "^4.1.4", - "husky": "^9.1.7", "jsdom": "^26.0.0", - "lint-staged": "^15.4.2", "msw": "^2.7.0", "postcss": "^8.5.1", "prettier": "^3.4.2", @@ -184,14 +193,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -324,13 +333,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -397,18 +406,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -416,9 +425,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -456,323 +465,6 @@ "node": ">=18.18" } }, - "node_modules/@boundaries/elements/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/@boundaries/elements/node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/@boundaries/elements/node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@boundaries/elements/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@commitlint/cli": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", - "integrity": "sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/format": "^19.8.1", - "@commitlint/lint": "^19.8.1", - "@commitlint/load": "^19.8.1", - "@commitlint/read": "^19.8.1", - "@commitlint/types": "^19.8.1", - "tinyexec": "^1.0.0", - "yargs": "^17.0.0" - }, - "bin": { - "commitlint": "cli.js" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/config-conventional": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.8.1.tgz", - "integrity": "sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "conventional-changelog-conventionalcommits": "^7.0.2" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/config-validator": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.8.1.tgz", - "integrity": "sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "ajv": "^8.11.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/ensure": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.8.1.tgz", - "integrity": "sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/execute-rule": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.8.1.tgz", - "integrity": "sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/format": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.8.1.tgz", - "integrity": "sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "chalk": "^5.3.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/is-ignored": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.8.1.tgz", - "integrity": "sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "semver": "^7.6.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/lint": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.8.1.tgz", - "integrity": "sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/is-ignored": "^19.8.1", - "@commitlint/parse": "^19.8.1", - "@commitlint/rules": "^19.8.1", - "@commitlint/types": "^19.8.1" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/load": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.8.1.tgz", - "integrity": "sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/config-validator": "^19.8.1", - "@commitlint/execute-rule": "^19.8.1", - "@commitlint/resolve-extends": "^19.8.1", - "@commitlint/types": "^19.8.1", - "chalk": "^5.3.0", - "cosmiconfig": "^9.0.0", - "cosmiconfig-typescript-loader": "^6.1.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "lodash.uniq": "^4.5.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/message": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.1.tgz", - "integrity": "sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/parse": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.8.1.tgz", - "integrity": "sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^19.8.1", - "conventional-changelog-angular": "^7.0.0", - "conventional-commits-parser": "^5.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/read": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.8.1.tgz", - "integrity": "sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/top-level": "^19.8.1", - "@commitlint/types": "^19.8.1", - "git-raw-commits": "^4.0.0", - "minimist": "^1.2.8", - "tinyexec": "^1.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/resolve-extends": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.8.1.tgz", - "integrity": "sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/config-validator": "^19.8.1", - "@commitlint/types": "^19.8.1", - "global-directory": "^4.0.1", - "import-meta-resolve": "^4.0.0", - "lodash.mergewith": "^4.6.2", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/rules": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.8.1.tgz", - "integrity": "sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/ensure": "^19.8.1", - "@commitlint/message": "^19.8.1", - "@commitlint/to-lines": "^19.8.1", - "@commitlint/types": "^19.8.1" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/to-lines": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.8.1.tgz", - "integrity": "sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/top-level": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.8.1.tgz", - "integrity": "sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^7.0.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@commitlint/types": { - "version": "19.8.1", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.8.1.tgz", - "integrity": "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/conventional-commits-parser": "^5.0.0", - "chalk": "^5.3.0" - }, - "engines": { - "node": ">=v18" - } - }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -916,38 +608,6 @@ "node": ">=10.12.0" } }, - "node_modules/@electron/asar/node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/@electron/asar/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@electron/fuses": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", @@ -1012,6 +672,19 @@ "node": ">=10" } }, + "node_modules/@electron/fuses/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@electron/get": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", @@ -1245,13 +918,12 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1884,6 +1556,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -1897,6 +1607,13 @@ "node": ">=14.0.0" } }, + "node_modules/@hapi/address/node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@hapi/formula": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", @@ -1904,13 +1621,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@hapi/hoek": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", - "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@hapi/pinpoint": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", @@ -1928,14 +1638,114 @@ "node": ">=14.0.0" } }, - "node_modules/@hapi/topo": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", - "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@hookform/resolvers": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.7.1.tgz", + "integrity": "sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==", + "license": "MIT", "dependencies": { - "@hapi/hoek": "^11.0.2" + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^1.2.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=3.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } } }, "node_modules/@humanfs/core": { @@ -2122,6 +1932,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2311,10 +2128,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", - "integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, @@ -2370,134 +2204,922 @@ "node": ">= 8" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "license": "MIT", - "engines": { - "node": ">=12.4.0" + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@open-draft/deferred-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", - "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "dev": true, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=8.0.0" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", "license": "MIT", "dependencies": { - "tslib": "^2.8.1" + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@peculiar/webcrypto": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", - "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", - "dev": true, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/json-schema": "^1.1.12", - "@peculiar/utils": "^2.0.2", - "tslib": "^2.8.1", - "webcrypto-core": "^1.9.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, - "engines": { - "node": ">=14.18.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.1" + "@radix-ui/react-primitive": "2.1.10" }, - "bin": { - "playwright": "cli.js" + "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" }, - "engines": { - "node": ">=20" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -2532,9 +3154,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", - "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -2546,9 +3168,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", - "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -2560,9 +3182,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", - "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -2574,9 +3196,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", - "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -2588,9 +3210,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", - "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -2602,9 +3224,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", - "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -2616,9 +3238,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", - "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], @@ -2630,9 +3252,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", - "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], @@ -2644,9 +3266,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", - "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], @@ -2658,9 +3280,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", - "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], @@ -2672,9 +3294,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", - "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], @@ -2686,9 +3308,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", - "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], @@ -2700,9 +3322,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", - "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], @@ -2714,9 +3336,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", - "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], @@ -2728,9 +3350,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", - "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], @@ -2742,9 +3364,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", - "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], @@ -2756,9 +3378,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", - "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], @@ -2770,9 +3392,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", - "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], @@ -2784,9 +3406,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", - "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], @@ -2798,9 +3420,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", - "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -2812,9 +3434,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", - "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -2826,9 +3448,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", - "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -2840,9 +3462,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", - "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -2854,9 +3476,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", - "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -2868,9 +3490,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", - "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -3136,16 +3758,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/conventional-commits-parser": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", - "integrity": "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3229,9 +3841,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3239,10 +3851,10 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3552,17 +4164,6 @@ "node": ">=14.0.0" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -3819,22 +4420,20 @@ } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", - "dependencies": { - "debug": "4" - }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3847,22 +4446,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4140,6 +4723,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -4167,13 +4762,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", - "dev": true, - "license": "MIT" - }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -4467,9 +5055,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.8.tgz", - "integrity": "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==", + "version": "2.11.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", + "integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4613,16 +5201,6 @@ "node": ">=12.0.0" } }, - "node_modules/builder-util/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/builder-util/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4670,6 +5248,19 @@ "node": ">= 14" } }, + "node_modules/builder-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -4931,42 +5522,21 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "license": "MIT", + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" + "clsx": "^2.1.1" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://polar.sh/cva" } }, "node_modules/cli-width": { @@ -5115,13 +5685,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5135,24 +5698,13 @@ } }, "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - } - }, - "node_modules/compare-func": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-ify": "^1.0.0", - "dot-prop": "^5.1.0" + "node": ">= 6" } }, "node_modules/compare-version": { @@ -5212,19 +5764,6 @@ "node": ">=20" } }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/concurrently/node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", @@ -5260,51 +5799,6 @@ "dev": true, "license": "MIT" }, - "node_modules/conventional-changelog-angular": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", - "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/conventional-changelog-conventionalcommits": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", - "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/conventional-commits-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", - "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-text-path": "^2.0.0", - "JSONStream": "^1.3.5", - "meow": "^12.0.1", - "split2": "^4.0.0" - }, - "bin": { - "conventional-commits-parser": "cli.mjs" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5316,7 +5810,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5333,51 +5826,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cosmiconfig": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", - "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cosmiconfig-typescript-loader": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", - "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jiti": "2.6.1" - }, - "engines": { - "node": ">=v18" - }, - "peerDependencies": { - "@types/node": "*", - "cosmiconfig": ">=9", - "typescript": ">=5" - } - }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -5461,19 +5909,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/dargs": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", - "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -5685,6 +6120,12 @@ "license": "MIT", "optional": true }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5703,35 +6144,6 @@ "p-limit": "^3.1.0 " } }, - "node_modules/dir-compare/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dir-compare/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -5773,19 +6185,6 @@ "license": "MIT", "peer": true }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5863,9 +6262,9 @@ } }, "node_modules/electron": { - "version": "43.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", - "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", + "version": "42.8.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.8.0.tgz", + "integrity": "sha512-lgeUDjUuUzUSBchBudmjCZ8ApeYdVneMi17nLRMdfxGw7FyLFligsLtIF+dL3UoNnOsupAwdqVNJCK5MFE82kQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5953,6 +6352,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/electron-builder/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/electron-publish": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", @@ -6004,10 +6416,23 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/electron-publish/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.398", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", - "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", "dev": true, "license": "ISC" }, @@ -6089,9 +6514,9 @@ "license": "MIT" }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -6150,19 +6575,6 @@ "node": ">=6" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -6170,16 +6582,6 @@ "dev": true, "license": "MIT" }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -6504,15 +6906,15 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", - "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.16.1", - "resolve": "^2.0.0-next.6" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -6641,48 +7043,17 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/eslint-plugin-boundaries/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-boundaries/node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-plugin-boundaries/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "node_modules/eslint-plugin-boundaries/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, "node_modules/eslint-plugin-import": { @@ -6868,34 +7239,18 @@ "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" }, - "node_modules/eslint/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "p-locate": "^5.0.0" }, "engines": { "node": ">=10" @@ -6930,17 +7285,17 @@ "node": ">=8" } }, - "node_modules/eslint/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "has-flag": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, "node_modules/espree": { @@ -7017,37 +7372,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -7076,9 +7400,39 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -7111,10 +7465,10 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", - "dev": true, + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "devOptional": true, "funding": [ { "type": "github", @@ -7206,24 +7560,6 @@ "node": ">=8" } }, - "node_modules/find-up": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", - "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^7.2.0", - "path-exists": "^5.0.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -7475,6 +7811,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -7488,19 +7833,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -7520,9 +7852,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", "dev": true, "license": "MIT", "dependencies": { @@ -7532,42 +7864,23 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/git-raw-commits": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", - "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", - "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", - "dev": true, - "license": "MIT", - "dependencies": { - "dargs": "^8.0.0", - "meow": "^12.0.1", - "split2": "^4.0.0" - }, - "bin": { - "git-raw-commits": "cli.mjs" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7586,32 +7899,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -7631,22 +7918,6 @@ "node": ">=10.0" } }, - "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -7930,16 +8201,6 @@ "node": ">= 14" } }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -7967,30 +8228,16 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "bin": { - "husky": "bin.js" + "dependencies": { + "debug": "4" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "node": ">= 6.0.0" } }, "node_modules/iconv-lite": { @@ -8053,17 +8300,6 @@ "node": ">=4" } }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8103,16 +8339,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -8146,13 +8372,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -8243,13 +8462,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -8335,19 +8554,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -8441,16 +8647,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8506,19 +8702,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -8550,21 +8733,8 @@ "engines": { "node": ">= 0.4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-text-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", - "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "text-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-typed-array": { @@ -8681,6 +8851,19 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/istanbul-lib-source-maps": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", @@ -8754,25 +8937,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/joi": { - "version": "18.2.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", - "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/address": "^5.1.1", - "@hapi/formula": "^3.0.2", - "@hapi/hoek": "^11.0.7", - "@hapi/pinpoint": "^2.0.1", - "@hapi/tlds": "^1.1.1", - "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.1.0" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8781,9 +8945,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -8843,16 +9007,6 @@ } } }, - "node_modules/jsdom/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/jsdom/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -8887,18 +9041,11 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -8942,33 +9089,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, - "node_modules/JSONStream": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", - "dev": true, - "license": "(MIT OR Apache-2.0)", - "dependencies": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" - }, - "bin": { - "JSONStream": "bin.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9020,52 +9140,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lint-staged": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", - "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.4.1", - "commander": "^13.1.0", - "debug": "^4.4.0", - "execa": "^8.0.1", - "lilconfig": "^3.1.3", - "listr2": "^8.2.5", - "micromatch": "^4.0.8", - "pidtree": "^0.6.0", - "string-argv": "^0.3.2", - "yaml": "^2.7.0" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/listr2": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", - "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/local-pkg": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", @@ -9084,22 +9158,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -9107,27 +9165,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9135,107 +9172,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -9344,26 +9280,6 @@ "node": ">= 0.4" } }, - "node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -9422,32 +9338,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -9612,22 +9502,22 @@ } }, "node_modules/msw/node_modules/tldts": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", - "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.9" + "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/msw/node_modules/tldts-core": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", - "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "dev": true, "license": "MIT" }, @@ -9738,35 +9628,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-exports-info": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", - "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-exports-info/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/node-gyp": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", @@ -9878,36 +9739,7 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9984,22 +9816,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/object.fromentries": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", @@ -10063,22 +9879,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10134,32 +9934,16 @@ } }, "node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^4.0.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10185,25 +9969,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -10217,16 +9982,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -10337,19 +10092,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.1.tgz", - "integrity": "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -10517,28 +10259,6 @@ "postcss": "^8.0.0" } }, - "node_modules/postcss-import/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/postcss-js": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", @@ -10989,9 +10709,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.83.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz", - "integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==", + "version": "7.84.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.84.0.tgz", + "integrity": "sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -11045,6 +10765,119 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "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 + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "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 + } + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -11191,7 +11024,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11222,16 +11055,14 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "2.0.0-next.7", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", - "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.2", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -11252,16 +11083,6 @@ "dev": true, "license": "MIT" }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -11285,39 +11106,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -11346,13 +11134,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -11368,29 +11149,6 @@ "rimraf": "bin.js" } }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -11411,9 +11169,9 @@ } }, "node_modules/rollup": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", - "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { @@ -11427,31 +11185,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.3", - "@rollup/rollup-android-arm64": "4.62.3", - "@rollup/rollup-darwin-arm64": "4.62.3", - "@rollup/rollup-darwin-x64": "4.62.3", - "@rollup/rollup-freebsd-arm64": "4.62.3", - "@rollup/rollup-freebsd-x64": "4.62.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", - "@rollup/rollup-linux-arm-musleabihf": "4.62.3", - "@rollup/rollup-linux-arm64-gnu": "4.62.3", - "@rollup/rollup-linux-arm64-musl": "4.62.3", - "@rollup/rollup-linux-loong64-gnu": "4.62.3", - "@rollup/rollup-linux-loong64-musl": "4.62.3", - "@rollup/rollup-linux-ppc64-gnu": "4.62.3", - "@rollup/rollup-linux-ppc64-musl": "4.62.3", - "@rollup/rollup-linux-riscv64-gnu": "4.62.3", - "@rollup/rollup-linux-riscv64-musl": "4.62.3", - "@rollup/rollup-linux-s390x-gnu": "4.62.3", - "@rollup/rollup-linux-x64-gnu": "4.62.3", - "@rollup/rollup-linux-x64-musl": "4.62.3", - "@rollup/rollup-openbsd-x64": "4.62.3", - "@rollup/rollup-openharmony-arm64": "4.62.3", - "@rollup/rollup-win32-arm64-msvc": "4.62.3", - "@rollup/rollup-win32-ia32-msvc": "4.62.3", - "@rollup/rollup-win32-x64-gnu": "4.62.3", - "@rollup/rollup-win32-x64-msvc": "4.62.3", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -11857,36 +11616,6 @@ "node": ">=10" } }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -11915,6 +11644,16 @@ "node": ">=10.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11946,16 +11685,6 @@ "source-map": "^0.6.0" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -12036,16 +11765,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -12110,13 +11829,6 @@ "node": ">=8" } }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/string.prototype.trim": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", @@ -12230,19 +11942,6 @@ "node": ">=4" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -12336,16 +12035,16 @@ } }, "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -12429,36 +12128,6 @@ "node": ">=14.0.0" } }, - "node_modules/tailwindcss/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/tailwindcss/node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/tailwindcss/node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -12469,28 +12138,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/tailwindcss/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -12582,6 +12229,61 @@ "node": "20 || >=22" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/test-exclude/node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -12598,19 +12300,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/text-extensions": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", - "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -12634,13 +12323,6 @@ "node": ">=0.8" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -12668,16 +12350,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -12892,7 +12564,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -13074,19 +12745,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -13215,6 +12873,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "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 + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "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 + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -13533,6 +13234,42 @@ "node": ">=20.0.0" } }, + "node_modules/wait-on/node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/wait-on/node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/wait-on/node_modules/joi": { + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", @@ -13925,22 +13662,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", @@ -14016,13 +13737,13 @@ } }, "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/frontend/package.json b/frontend/package.json index 34dff36..989d500 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,6 @@ "name": "restaurant-pos-frontend", "version": "0.1.0", "private": true, - "type": "module", "main": "dist-electron/main.js", "scripts": { "dev": "vite", @@ -18,28 +17,38 @@ "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "coverage": "vitest run --coverage", - "prepare": "husky", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"", "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"", "sonar": "sonar-scanner" }, "dependencies": { + "@hookform/resolvers": "^5.7.1", + "@radix-ui/react-avatar": "^1.2.6", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-query": "^5.64.2", "axios": "^1.7.9", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", "react": "19.2.8", "react-dom": "19.2.8", "react-hook-form": "^7.54.2", "react-redux": "^9.2.0", + "react-router-dom": "^7.18.2", "socket.io-client": "^4.8.1", + "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", "zod": "^3.24.1" }, "devDependencies": { - "@commitlint/cli": "^19.6.1", - "@commitlint/config-conventional": "^19.6.0", "@eslint/eslintrc": "^3.2.0", "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.6.3", @@ -53,7 +62,7 @@ "autoprefixer": "^10.4.20", "concurrently": "^10.0.4", "cross-env": "^10.1.0", - "electron": "^43.2.0", + "electron": "^42.0.0", "electron-builder": "^26.15.3", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -62,9 +71,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-unused-imports": "^4.1.4", - "husky": "^9.1.7", "jsdom": "^26.0.0", - "lint-staged": "^15.4.2", "msw": "^2.7.0", "postcss": "^8.5.1", "prettier": "^3.4.2", diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index a8b99a8..de90fa7 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,19 +1,24 @@ +import { useEffect } from "react"; +import { RouterProvider } from "react-router-dom"; +import { Toaster } from "sonner"; +import { sessionEnded, useRestoreSession } from "@/features/auth"; +import { onSessionExpired } from "@/shared/api/axiosClient"; +import { useAppDispatch } from "@/shared/store"; +import { router } from "./routing/router"; + export default function App() { + useRestoreSession(); + + const dispatch = useAppDispatch(); + + // When a refresh token can no longer be renewed, the interceptor calls this to clear the + // session; RequireAuth then redirects to /login on its own next render. + useEffect(() => onSessionExpired(() => dispatch(sessionEnded())), [dispatch]); + return ( -
-
-

- Restaurant POS System -

-

- Enterprise Desktop POS Terminal (Electron + React 19 + Vite) -

-
-

- Status: Architecture Scaffolded & Ready -

-
-
-
+ <> + + + ); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 40091be..3ac69d0 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2,18 +2,109 @@ @tailwind components; @tailwind utilities; -:root { - --background: #ffffff; - --foreground: #09090b; - --primary: #18181b; - --primary-foreground: #fafafa; +/* + * Colours are declared as raw HSL channels so Tailwind can compose them with an alpha value + * (e.g. `bg-primary/10`). Every colour the app uses is defined here once, in both themes, so + * no component hard-codes a hex value. + */ +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222 24% 11%; + + --card: 0 0% 100%; + --card-foreground: 222 24% 11%; + + --popover: 0 0% 100%; + --popover-foreground: 222 24% 11%; + + /* Deep teal-green: calm and professional on a till screen being stared at all day. */ + --primary: 168 72% 22%; + --primary-foreground: 0 0% 100%; + + --secondary: 210 20% 96%; + --secondary-foreground: 222 24% 20%; + + --muted: 210 20% 96%; + --muted-foreground: 215 16% 44%; + + --accent: 210 20% 94%; + --accent-foreground: 222 24% 20%; + + --destructive: 0 72% 45%; + --destructive-foreground: 0 0% 100%; + + --success: 142 62% 32%; + --success-foreground: 0 0% 100%; + + --warning: 35 92% 42%; + --warning-foreground: 0 0% 100%; + + --border: 214 22% 90%; + --input: 214 22% 88%; + --ring: 168 62% 30%; + + --radius: 0.625rem; + } + + .dark { + --background: 222 26% 8%; + --foreground: 210 20% 96%; + + --card: 222 22% 11%; + --card-foreground: 210 20% 96%; + + --popover: 222 22% 11%; + --popover-foreground: 210 20% 96%; + + --primary: 168 58% 44%; + --primary-foreground: 222 32% 7%; + + --secondary: 217 19% 17%; + --secondary-foreground: 210 20% 92%; + + --muted: 217 19% 16%; + --muted-foreground: 215 16% 64%; + + --accent: 217 19% 19%; + --accent-foreground: 210 20% 92%; + + --destructive: 0 62% 52%; + --destructive-foreground: 0 0% 100%; + + --success: 142 52% 46%; + --success-foreground: 222 32% 7%; + + --warning: 35 84% 54%; + --warning-foreground: 222 32% 7%; + + --border: 217 19% 21%; + --input: 217 19% 24%; + --ring: 168 58% 44%; + } } -@media (prefers-color-scheme: dark) { - :root { - --background: #09090b; - --foreground: #fafafa; - --primary: #fafafa; - --primary-foreground: #18181b; +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground antialiased; + font-feature-settings: + 'rlig' 1, + 'calt' 1; + } + + /* A POS is driven by touch and keyboard, so focus must always be clearly visible. */ + :focus-visible { + @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background; + } +} + +@layer utilities { + /* Tabular figures stop money and counts from shifting as their values change. */ + .tabular { + font-variant-numeric: tabular-nums; } } diff --git a/frontend/src/app/routing/RequireAuth.tsx b/frontend/src/app/routing/RequireAuth.tsx new file mode 100644 index 0000000..d44fed9 --- /dev/null +++ b/frontend/src/app/routing/RequireAuth.tsx @@ -0,0 +1,29 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAuth } from "@/features/auth"; +import { LoadingState } from "@/shared/ui"; + +/** + * Gate for every screen that requires a signed-in session. + * + * Also enforces the forced-password-change flow client-side: this mirrors the server's + * `PasswordChangeRequiredMiddleware`, which is the real enforcement point, but redirecting here + * too means a user with a pending change never even sees a screen the API would reject. + */ +export function RequireAuth() { + const { isAuthenticated, isResolving, mustChangePassword } = useAuth(); + const location = useLocation(); + + if (isResolving) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + if (mustChangePassword && location.pathname !== "/change-password") { + return ; + } + + return ; +} diff --git a/frontend/src/app/routing/RequireGuest.tsx b/frontend/src/app/routing/RequireGuest.tsx new file mode 100644 index 0000000..04d00d8 --- /dev/null +++ b/frontend/src/app/routing/RequireGuest.tsx @@ -0,0 +1,23 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "@/features/auth"; +import { LoadingState } from "@/shared/ui"; + +/** + * Gate for screens meant only for a signed-out visitor, namely `/login`. + * + * A signed-in user who lands here (back button, stale bookmark) is sent to the right place + * instead of seeing the login form again. + */ +export function RequireGuest() { + const { isAuthenticated, isResolving, mustChangePassword } = useAuth(); + + if (isResolving) { + return ; + } + + if (isAuthenticated) { + return ; + } + + return ; +} diff --git a/frontend/src/app/routing/RequireModule.tsx b/frontend/src/app/routing/RequireModule.tsx new file mode 100644 index 0000000..6ac17af --- /dev/null +++ b/frontend/src/app/routing/RequireModule.tsx @@ -0,0 +1,20 @@ +import { Navigate, Outlet } from "react-router-dom"; +import type { ModuleKey } from "@/entities/user"; +import { useAuth } from "@/features/auth"; + +export interface RequireModuleProps { + module: ModuleKey; +} + +/** + * Gate for a screen belonging to a specific module. + * + * This is a UX convenience, not the security boundary — every endpoint the page calls enforces + * the same module grant server-side (`AuthorizationPolicies.ForModule`), so at worst a user + * without access sees an empty screen's requests fail, never real data. + */ +export function RequireModule({ module }: RequireModuleProps) { + const { can } = useAuth(); + + return can(module) ? : ; +} diff --git a/frontend/src/app/routing/router.tsx b/frontend/src/app/routing/router.tsx new file mode 100644 index 0000000..7b78e9c --- /dev/null +++ b/frontend/src/app/routing/router.tsx @@ -0,0 +1,55 @@ +import { lazy, ReactNode, Suspense } from "react"; +import { createBrowserRouter, Navigate } from "react-router-dom"; +import { LoadingState } from "@/shared/ui"; +import { AppShell } from "@/widgets/app-shell/AppShell"; +import { RequireAuth } from "./RequireAuth"; +import { RequireGuest } from "./RequireGuest"; +import { RequireModule } from "./RequireModule"; + +// Lazy-loaded so the initial bundle only carries what the login screen needs; everything else +// loads once a session is established. +const LoginPage = lazy(() => import("@/pages/login")); +const ChangePasswordPage = lazy(() => import("@/pages/change-password")); +const DashboardPage = lazy(() => import("@/pages/dashboard")); +const AccountPage = lazy(() => import("@/pages/account")); +const UsersPage = lazy(() => import("@/pages/users")); +const CheckoutPage = lazy(() => import("@/pages/checkout")); +const ReportsPage = lazy(() => import("@/pages/reports")); + +function withSuspense(element: ReactNode) { + return }>{element}; +} + +export const router = createBrowserRouter([ + { + element: , + children: [{ path: "/login", element: withSuspense() }], + }, + { + element: , + children: [ + // Reachable the instant a session exists, even mid forced-password-change. + { path: "/change-password", element: withSuspense() }, + { + element: , + children: [ + { index: true, element: withSuspense() }, + { path: "account", element: withSuspense() }, + { + element: , + children: [{ path: "checkout", element: withSuspense() }], + }, + { + element: , + children: [{ path: "reports", element: withSuspense() }], + }, + { + element: , + children: [{ path: "users", element: withSuspense() }], + }, + ], + }, + ], + }, + { path: "*", element: }, +]); diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts new file mode 100644 index 0000000..5364563 --- /dev/null +++ b/frontend/src/entities/user/index.ts @@ -0,0 +1,13 @@ +export type { + User, + UserRole, + ModuleKey, + ModuleDescriptor, + Session, + Approval, + CreateUserPayload, + UpdateUserPayload, + UserFilters, +} from "./model/types"; + +export { canAccessModule, assignableModules, groupModules } from "./model/permissions"; diff --git a/frontend/src/entities/user/model/permissions.ts b/frontend/src/entities/user/model/permissions.ts new file mode 100644 index 0000000..645eece --- /dev/null +++ b/frontend/src/entities/user/model/permissions.ts @@ -0,0 +1,41 @@ +import type { ModuleDescriptor, ModuleKey, User } from "./types"; + +/** + * True when the user may open the given module. + * + * The backend enforces this on every request; this is only used to decide what to render, so + * the UI never offers a door the API will slam shut. + */ +export function canAccessModule(user: User | null, module: ModuleKey): boolean { + if (!user) return false; + + return user.role === "Admin" || user.modules.includes(module); +} + +/** The modules an administrator may grant to a non-admin user. */ +export function assignableModules(catalog: ModuleDescriptor[]): ModuleDescriptor[] { + return catalog.filter((m) => !m.adminOnly); +} + +/** + * Groups modules by their catalog group, preserving the backend's ordering both between and + * within groups so navigation and the permission editor always agree. + */ +export function groupModules(catalog: ModuleDescriptor[]): Array<{ + group: string; + modules: ModuleDescriptor[]; +}> { + const ordered = [...catalog].sort((a, b) => a.sortOrder - b.sortOrder); + const groups = new Map(); + + for (const descriptor of ordered) { + const existing = groups.get(descriptor.group); + if (existing) { + existing.push(descriptor); + } else { + groups.set(descriptor.group, [descriptor]); + } + } + + return [...groups.entries()].map(([group, modules]) => ({ group, modules })); +} diff --git a/frontend/src/entities/user/model/types.ts b/frontend/src/entities/user/model/types.ts new file mode 100644 index 0000000..52b16a5 --- /dev/null +++ b/frontend/src/entities/user/model/types.ts @@ -0,0 +1,81 @@ +/** + * Mirrors the backend contracts in `RestaurantPOS.Application`. Enums cross the wire as their + * names, so these are string unions rather than numeric enums. + */ + +export type UserRole = "Admin" | "User"; + +/** + * Identifier of an assignable module. Kept as a plain string rather than a closed union: the + * catalog is served by `GET /modules`, so adding a module on the backend must not require a + * frontend change. + */ +export type ModuleKey = string; + +/** A staff account. */ +export interface User { + id: string; + username: string; + fullName: string; + email: string | null; + role: UserRole; + isActive: boolean; + /** True until the user has chosen their own password. */ + mustChangePassword: boolean; + /** The built-in administrator, which cannot be deactivated or demoted. */ + isSystemAdmin: boolean; + hasApprovalPin: boolean; + lastLoginAtUtc: string | null; + createdAtUtc: string; + /** Modules the user can open. For administrators this is the entire catalog. */ + modules: ModuleKey[]; +} + +/** Display metadata for one module, as served by the backend catalog. */ +export interface ModuleDescriptor { + module: ModuleKey; + name: string; + group: string; + description: string; + sortOrder: number; + /** Reserved for administrators; never offered in the permission editor. */ + adminOnly: boolean; +} + +/** A signed-in session. */ +export interface Session { + accessToken: string; + accessTokenExpiresAtUtc: string; + refreshToken: string; + refreshTokenExpiresAtUtc: string; + user: User; +} + +/** Identifies the administrator who authorised a PIN-gated action. */ +export interface Approval { + approvedByUserId: string; + approvedByName: string; + approvedAtUtc: string; +} + +export interface CreateUserPayload { + username: string; + fullName: string; + email: string | null; + password: string; + role: UserRole; + modules: ModuleKey[]; +} + +export interface UpdateUserPayload { + fullName: string; + email: string | null; + role: UserRole; + modules: ModuleKey[]; +} + +export interface UserFilters { + search?: string; + role?: UserRole; + isActive?: boolean; +} diff --git a/frontend/src/features/auth/api/authApi.ts b/frontend/src/features/auth/api/authApi.ts new file mode 100644 index 0000000..8dc92c5 --- /dev/null +++ b/frontend/src/features/auth/api/authApi.ts @@ -0,0 +1,32 @@ +import type { Approval, ModuleDescriptor, Session, User } from "@/entities/user"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +/** Every call the authentication and approval-PIN flows make. */ +export const authApi = { + login: (username: string, password: string) => + apiService.post(API_ENDPOINTS.AUTH.LOGIN, { username, password }), + + logout: (refreshToken: string | null) => + apiService.post(API_ENDPOINTS.AUTH.LOGOUT, { refreshToken }), + + /** Re-reads the signed-in user so permissions are never trusted from cached client state. */ + me: () => apiService.get(API_ENDPOINTS.AUTH.ME), + + changePassword: (currentPassword: string, newPassword: string) => + apiService.post(API_ENDPOINTS.AUTH.CHANGE_PASSWORD, { + currentPassword, + newPassword, + }), + + modules: () => apiService.get(API_ENDPOINTS.MODULES), + + /** Sets the administrator's approval PIN. Pass `null` to have the server generate one. */ + setApprovalPin: (currentPassword: string, pin: string | null) => + apiService.post<{ pin: string }>(API_ENDPOINTS.AUTH.PIN, { currentPassword, pin }), + + clearApprovalPin: () => apiService.delete(API_ENDPOINTS.AUTH.PIN), + + /** Authorises a privileged action with an administrator's PIN. */ + verifyApprovalPin: (pin: string, reason?: string) => + apiService.post(API_ENDPOINTS.AUTH.VERIFY_PIN, { pin, reason }), +}; diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts new file mode 100644 index 0000000..02705b9 --- /dev/null +++ b/frontend/src/features/auth/index.ts @@ -0,0 +1,18 @@ +export { authApi } from "./api/authApi"; +export { + default as authReducer, + sessionEstablished, + sessionEnded, + userLoaded, + authenticating, + type AuthState, +} from "./model/authSlice"; +export { + useAuth, + useLogin, + useLogout, + useChangePassword, + useModules, + useRestoreSession, +} from "./model/useAuth"; +export { useApprovalPin, useRequestApproval } from "./model/useApprovalPin"; diff --git a/frontend/src/features/auth/model/authSlice.ts b/frontend/src/features/auth/model/authSlice.ts index 50394e6..f5fb568 100644 --- a/frontend/src/features/auth/model/authSlice.ts +++ b/frontend/src/features/auth/model/authSlice.ts @@ -1,43 +1,51 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - -export interface User { - id: string; - name: string; - email: string; - role: string; -} +import type { Session, User } from "@/entities/user"; +import { tokenStorage } from "@/shared/api/tokenStorage"; export interface AuthState { user: User | null; - isAuthenticated: boolean; - token: string | null; + /** + * Whether the stored token has been checked against the server yet. Routing waits on this so + * a refresh does not briefly bounce a signed-in user to the login screen. + */ + status: "idle" | "authenticating" | "authenticated" | "unauthenticated"; } const initialState: AuthState = { user: null, - isAuthenticated: false, - token: null, + status: "idle", }; export const authSlice = createSlice({ name: "auth", initialState, reducers: { - setCredentials: ( - state, - action: PayloadAction<{ user: User; token: string }> - ) => { + /** Records a new session and persists its tokens. */ + sessionEstablished: (state, action: PayloadAction) => { + tokenStorage.save(action.payload.accessToken, action.payload.refreshToken); state.user = action.payload.user; - state.token = action.payload.token; - state.isAuthenticated = true; + state.status = "authenticated"; + }, + + /** Refreshes the cached profile without touching tokens, e.g. after `GET /auth/me`. */ + userLoaded: (state, action: PayloadAction) => { + state.user = action.payload; + state.status = "authenticated"; }, - logout: (state) => { + + authenticating: (state) => { + state.status = "authenticating"; + }, + + /** Clears all session state. Used for sign-out and for an unrecoverable 401. */ + sessionEnded: (state) => { + tokenStorage.clear(); state.user = null; - state.token = null; - state.isAuthenticated = false; + state.status = "unauthenticated"; }, }, }); -export const { setCredentials, logout } = authSlice.actions; +export const { sessionEstablished, userLoaded, authenticating, sessionEnded } = authSlice.actions; + export default authSlice.reducer; diff --git a/frontend/src/features/auth/model/useApprovalPin.ts b/frontend/src/features/auth/model/useApprovalPin.ts new file mode 100644 index 0000000..7f38eb1 --- /dev/null +++ b/frontend/src/features/auth/model/useApprovalPin.ts @@ -0,0 +1,46 @@ +import { useMutation } from "@tanstack/react-query"; +import type { Approval } from "@/entities/user"; +import { useAppDispatch } from "@/shared/store"; +import { authApi } from "../api/authApi"; +import { userLoaded } from "./authSlice"; + +/** + * Manages the signed-in administrator's own approval PIN. + * + * `hasApprovalPin` lives on the cached Redux user, not a react-query cache, so both mutations + * re-fetch `/auth/me` on success and dispatch the result — otherwise the UI would keep showing + * the PIN's old presence/absence until the next full page load. + */ +export function useApprovalPin() { + const dispatch = useAppDispatch(); + + const refreshUser = async () => { + const user = await authApi.me(); + dispatch(userLoaded(user)); + }; + + const set = useMutation({ + mutationFn: ({ currentPassword, pin }: { currentPassword: string; pin: string | null }) => + authApi.setApprovalPin(currentPassword, pin), + onSuccess: refreshUser, + }); + + const clear = useMutation({ + mutationFn: () => authApi.clearApprovalPin(), + onSuccess: refreshUser, + }); + + return { set, clear }; +} + +/** + * Requests an administrator's authorisation for a privileged action. + * + * This is the hook other modules will reuse: a cashier attempting to void an order calls it, + * an administrator types their PIN, and the resolved {@link Approval} records who approved it. + */ +export function useRequestApproval() { + return useMutation({ + mutationFn: ({ pin, reason }) => authApi.verifyApprovalPin(pin, reason), + }); +} diff --git a/frontend/src/features/auth/model/useAuth.ts b/frontend/src/features/auth/model/useAuth.ts new file mode 100644 index 0000000..56fa9d3 --- /dev/null +++ b/frontend/src/features/auth/model/useAuth.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ModuleKey } from "@/entities/user"; +import { canAccessModule } from "@/entities/user"; +import { tokenStorage } from "@/shared/api/tokenStorage"; +import { useAppDispatch, useAppSelector } from "@/shared/store"; +import { authApi } from "../api/authApi"; +import { authenticating, sessionEnded, sessionEstablished, userLoaded } from "./authSlice"; + +/** + * Restores a session from the stored token on start-up. + * + * Mounted once by the app shell. The stored profile is never trusted: the server is asked who + * the token belongs to and what they may open. + */ +export function useRestoreSession(): void { + const dispatch = useAppDispatch(); + const status = useAppSelector((s) => s.auth.status); + + useEffect(() => { + if (status !== "idle") return; + + if (!tokenStorage.getAccessToken()) { + dispatch(sessionEnded()); + return; + } + + dispatch(authenticating()); + + authApi + .me() + .then((user) => dispatch(userLoaded(user))) + .catch(() => dispatch(sessionEnded())); + }, [dispatch, status]); +} + +/** The signed-in user and helpers derived from them. */ +export function useAuth() { + const { user, status } = useAppSelector((s) => s.auth); + + const can = useCallback((module: ModuleKey) => canAccessModule(user, module), [user]); + + return { + user, + status, + isAuthenticated: status === "authenticated", + /** True while the stored token is still being checked. */ + isResolving: status === "idle" || status === "authenticating", + isAdmin: user?.role === "Admin", + mustChangePassword: user?.mustChangePassword ?? false, + can, + }; +} + +/** Signs in with a username and password. */ +export function useLogin() { + const dispatch = useAppDispatch(); + + return useMutation({ + mutationFn: ({ username, password }: { username: string; password: string }) => + authApi.login(username, password), + onSuccess: (session) => dispatch(sessionEstablished(session)), + }); +} + +/** Changes the signed-in user's password and adopts the fresh session it returns. */ +export function useChangePassword() { + const dispatch = useAppDispatch(); + + return useMutation({ + mutationFn: ({ + currentPassword, + newPassword, + }: { + currentPassword: string; + newPassword: string; + }) => authApi.changePassword(currentPassword, newPassword), + onSuccess: (session) => dispatch(sessionEstablished(session)), + }); +} + +/** Signs out, clearing local state even if the server call fails. */ +export function useLogout() { + const dispatch = useAppDispatch(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => authApi.logout(tokenStorage.getRefreshToken()), + // Local state is cleared either way: a failed revoke must not strand the user signed in. + onSettled: () => { + dispatch(sessionEnded()); + queryClient.clear(); + }, + }); +} + +/** The module catalog, used for navigation and the permission editor. */ +export function useModules() { + const { isAuthenticated } = useAuth(); + + return useQuery({ + queryKey: ["modules"], + queryFn: authApi.modules, + enabled: isAuthenticated, + // The catalog only changes when the application itself is updated. + staleTime: Infinity, + }); +} diff --git a/frontend/src/features/users/api/usersApi.ts b/frontend/src/features/users/api/usersApi.ts new file mode 100644 index 0000000..82c8c48 --- /dev/null +++ b/frontend/src/features/users/api/usersApi.ts @@ -0,0 +1,24 @@ +import type { CreateUserPayload, UpdateUserPayload, User, UserFilters } from "@/entities/user"; +import { API_ENDPOINTS, apiService } from "@/shared/api/endpoints"; + +export const usersApi = { + list: (filters: UserFilters = {}) => + apiService.get(API_ENDPOINTS.USERS.BASE, { + search: filters.search || undefined, + role: filters.role, + isActive: filters.isActive, + }), + + byId: (id: string) => apiService.get(API_ENDPOINTS.USERS.BY_ID(id)), + + create: (payload: CreateUserPayload) => apiService.post(API_ENDPOINTS.USERS.BASE, payload), + + update: (id: string, payload: UpdateUserPayload) => + apiService.put(API_ENDPOINTS.USERS.BY_ID(id), payload), + + setActive: (id: string, isActive: boolean) => + apiService.put(API_ENDPOINTS.USERS.STATUS(id), { isActive }), + + resetPassword: (id: string, newPassword: string) => + apiService.post(API_ENDPOINTS.USERS.PASSWORD(id), { newPassword }), +}; diff --git a/frontend/src/features/users/index.ts b/frontend/src/features/users/index.ts new file mode 100644 index 0000000..52ae497 --- /dev/null +++ b/frontend/src/features/users/index.ts @@ -0,0 +1,5 @@ +export { usersApi } from "./api/usersApi"; +export { useUsers, useUserMutations } from "./model/useUsers"; +export { UserFormDialog } from "./ui/UserFormDialog"; +export { ResetPasswordDialog } from "./ui/ResetPasswordDialog"; +export { ModulePermissionPicker } from "./ui/ModulePermissionPicker"; diff --git a/frontend/src/features/users/model/useUsers.ts b/frontend/src/features/users/model/useUsers.ts new file mode 100644 index 0000000..88edef6 --- /dev/null +++ b/frontend/src/features/users/model/useUsers.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { CreateUserPayload, UpdateUserPayload, User, UserFilters } from "@/entities/user"; +import { usersApi } from "../api/usersApi"; + +const USERS_KEY = "users"; + +/** Staff accounts matching the given filters. */ +export function useUsers(filters: UserFilters = {}) { + return useQuery({ + queryKey: [USERS_KEY, filters], + queryFn: () => usersApi.list(filters), + // Keeps the previous page visible while a new search runs, avoiding a flash of empty table. + placeholderData: (previous) => previous, + }); +} + +/** + * Commands that change staff accounts. Each invalidates the list so the table reflects the + * server's view rather than an optimistic guess — correctness matters more than instant + * feedback for an administration screen. + */ +export function useUserMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: [USERS_KEY] }); + + const create = useMutation({ + mutationFn: usersApi.create, + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ id, payload }) => usersApi.update(id, payload), + onSuccess: invalidate, + }); + + const setActive = useMutation({ + mutationFn: ({ id, isActive }) => usersApi.setActive(id, isActive), + onSuccess: invalidate, + }); + + const resetPassword = useMutation({ + mutationFn: ({ id, newPassword }) => usersApi.resetPassword(id, newPassword), + onSuccess: invalidate, + }); + + return { create, update, setActive, resetPassword }; +} diff --git a/frontend/src/features/users/model/userSchema.ts b/frontend/src/features/users/model/userSchema.ts new file mode 100644 index 0000000..757ae11 --- /dev/null +++ b/frontend/src/features/users/model/userSchema.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +/** + * Mirrors the server's password policy so the user is told what is wrong before a round trip. + * The server remains the authority; this only spares them a failed submit. + */ +export const passwordSchema = z + .string() + .min(8, "Password must be at least 8 characters.") + .max(128, "Password cannot exceed 128 characters.") + .regex(/[A-Z]/, "Password must contain an upper-case letter.") + .regex(/[a-z]/, "Password must contain a lower-case letter.") + .regex(/[0-9]/, "Password must contain a digit."); + +export const PASSWORD_HINT = + "At least 8 characters, with an upper-case letter, a lower-case letter and a digit."; + +const usernameSchema = z + .string() + .trim() + .toLowerCase() + .min(3, "Username must be at least 3 characters.") + .max(32, "Username cannot exceed 32 characters.") + .regex( + /^[a-z0-9._-]+$/, + "Use only letters, digits, dots, hyphens and underscores.", + ); + +// Kept as a plain string (not transformed to null) so the input/output types stay identical — +// react-hook-form's zodResolver requires that when useForm is given a single type argument. +// Callers convert "" to null with `toNullableEmail` immediately before sending it to the API. +const emailSchema = z + .string() + .trim() + .max(200) + .refine((value) => value === "" || z.string().email().safeParse(value).success, { + message: "Enter a valid email address.", + }); + +/** Converts the form's empty-string "no email" sentinel to the `null` the API expects. */ +export const toNullableEmail = (value: string): string | null => (value === "" ? null : value); + +const baseUserFields = { + fullName: z.string().trim().min(1, "Full name is required.").max(120), + email: emailSchema, + role: z.enum(["Admin", "User"]), + modules: z.array(z.string()), +}; + +/** New account: a username and starting password are required. */ +export const createUserSchema = z.object({ + ...baseUserFields, + username: usernameSchema, + password: passwordSchema, +}); + +/** Existing account: the username is immutable and the password is changed separately. */ +export const updateUserSchema = z.object(baseUserFields); + +export const resetPasswordSchema = z.object({ + newPassword: passwordSchema, +}); + +export const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1, "Your current password is required."), + newPassword: passwordSchema, + confirmPassword: z.string().min(1, "Please confirm your new password."), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "The two passwords do not match.", + path: ["confirmPassword"], + }) + .refine((data) => data.newPassword !== data.currentPassword, { + message: "The new password must be different from your current one.", + path: ["newPassword"], + }); + +export const approvalPinSchema = z + .string() + .regex(/^[0-9]{4}$/, "The approval PIN must be exactly 4 digits."); + +export type CreateUserForm = z.infer; +export type UpdateUserForm = z.infer; +export type ResetPasswordForm = z.infer; +export type ChangePasswordForm = z.infer; diff --git a/frontend/src/features/users/ui/ModulePermissionPicker.tsx b/frontend/src/features/users/ui/ModulePermissionPicker.tsx new file mode 100644 index 0000000..03f9989 --- /dev/null +++ b/frontend/src/features/users/ui/ModulePermissionPicker.tsx @@ -0,0 +1,135 @@ +import { ShieldCheck } from "lucide-react"; +import type { ModuleDescriptor, ModuleKey } from "@/entities/user"; +import { assignableModules, groupModules } from "@/entities/user"; +import { cn } from "@/shared/lib/utils"; +import { Alert, AlertDescription, Button, Checkbox, Label } from "@/shared/ui"; + +export interface ModulePermissionPickerProps { + catalog: ModuleDescriptor[]; + selected: ModuleKey[]; + onChange: (modules: ModuleKey[]) => void; + /** Administrators hold every module implicitly, so the picker is replaced by an explanation. */ + isAdmin: boolean; + disabled?: boolean; +} + +/** + * Grants modules to a staff account. + * + * Only non-administrative modules are offered: `UserManagement` and `SystemSettings` carry + * administrative authority and come with the Admin role instead of being handed out one by one. + */ +export function ModulePermissionPicker({ + catalog, + selected, + onChange, + isAdmin, + disabled = false, +}: ModulePermissionPickerProps) { + const grantable = assignableModules(catalog); + const groups = groupModules(grantable); + + if (isAdmin) { + return ( + + + + Administrators can open every module, including user management and system settings. + Module selection does not apply to this role. + + + ); + } + + const toggle = (module: ModuleKey, checked: boolean) => { + onChange(checked ? [...selected, module] : selected.filter((m) => m !== module)); + }; + + const toggleGroup = (modules: ModuleDescriptor[], grantAll: boolean) => { + const keys = modules.map((m) => m.module); + + onChange( + grantAll + ? [...new Set([...selected, ...keys])] + : selected.filter((m) => !keys.includes(m)), + ); + }; + + return ( +
+
+

+ {selected.length === 0 + ? "No modules selected — this user will be able to sign in but not open anything." + : `${selected.length} of ${grantable.length} modules granted`} +

+ + {selected.length > 0 && !disabled && ( + + )} +
+ + {groups.map(({ group, modules }) => { + const allGranted = modules.every((m) => selected.includes(m.module)); + + return ( +
+
+ + {group} + + {!disabled && ( + + )} +
+ +
+ {modules.map((descriptor) => { + const checked = selected.includes(descriptor.module); + const id = `module-${descriptor.module}`; + + return ( + + ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/features/users/ui/ResetPasswordDialog.tsx b/frontend/src/features/users/ui/ResetPasswordDialog.tsx new file mode 100644 index 0000000..a370d6d --- /dev/null +++ b/frontend/src/features/users/ui/ResetPasswordDialog.tsx @@ -0,0 +1,107 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { User } from "@/entities/user"; +import { + Alert, + AlertDescription, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, +} from "@/shared/ui"; +import { useUserMutations } from "../model/useUsers"; +import { PASSWORD_HINT, ResetPasswordForm, resetPasswordSchema } from "../model/userSchema"; + +export interface ResetPasswordDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + user: User; +} + +/** + * Sets a temporary password for a staff account on an administrator's behalf. + * + * The user is forced to choose their own password at their next sign-in, and every session + * they currently hold open is ended — this is meant for "I forgot my password", not a way to + * quietly access someone else's account. + */ +export function ResetPasswordDialog({ open, onOpenChange, user }: ResetPasswordDialogProps) { + const { resetPassword } = useUserMutations(); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { newPassword: "" }, + }); + + const close = (isOpen: boolean) => { + if (!isOpen) reset({ newPassword: "" }); + onOpenChange(isOpen); + }; + + const onSubmit = handleSubmit(async ({ newPassword }) => { + try { + await resetPassword.mutateAsync({ id: user.id, newPassword }); + toast.success(`${user.fullName}'s password was reset. They must choose a new one at sign-in.`); + close(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + + + + Reset password + + Set a temporary password for {user.fullName}. + + + + + + This immediately signs {user.fullName} out everywhere. They will need the temporary + password to sign back in, and will be asked to choose their own straight away. + + + +
+ + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/features/users/ui/UserFormDialog.tsx b/frontend/src/features/users/ui/UserFormDialog.tsx new file mode 100644 index 0000000..fc7221c --- /dev/null +++ b/frontend/src/features/users/ui/UserFormDialog.tsx @@ -0,0 +1,313 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { ModuleDescriptor, ModuleKey, User } from "@/entities/user"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui"; +import { useUserMutations } from "../model/useUsers"; +import type { CreateUserForm, UpdateUserForm } from "../model/userSchema"; +import { + PASSWORD_HINT, + createUserSchema, + toNullableEmail, + updateUserSchema, +} from "../model/userSchema"; +import { ModulePermissionPicker } from "./ModulePermissionPicker"; + +export interface UserFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The account being edited, or omitted to create a new one. */ + user?: User; + catalog: ModuleDescriptor[]; +} + +/** + * Creates a staff account, or edits an existing one's profile, role and module grants. + * + * Rendered as two distinct form bodies rather than one form with optional fields: creation and + * editing have genuinely different shapes (a username and starting password only make sense + * once), and giving each its own `useForm` keeps both strongly typed instead of forcing + * `react-hook-form` through a union schema. + */ +export function UserFormDialog({ open, onOpenChange, user, catalog }: UserFormDialogProps) { + return ( + + + {user ? ( + onOpenChange(false)} /> + ) : ( + onOpenChange(false)} /> + )} + + + ); +} + +function CreateUserFormBody({ catalog, onDone }: { catalog: ModuleDescriptor[]; onDone: () => void }) { + const { create } = useUserMutations(); + + const { + register, + handleSubmit, + control, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createUserSchema), + defaultValues: { username: "", fullName: "", email: "", password: "", role: "User", modules: [] }, + }); + + const role = watch("role"); + + const onSubmit = handleSubmit(async (values) => { + try { + await create.mutateAsync({ + username: values.username, + fullName: values.fullName, + email: toNullableEmail(values.email), + password: values.password, + role: values.role, + modules: values.modules as ModuleKey[], + }); + toast.success(`${values.fullName} was added. They'll set their own password at first sign-in.`); + onDone(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + <> + + Add staff account + + The new account must choose its own password the first time it signs in. + + + +
+
+ + + + + + + + + + + + + + ( + + )} + /> + + + + + +
+ +
+

Module access

+ ( + + )} + /> +
+ + + + + +
+ + ); +} + +function EditUserFormBody({ + user, + catalog, + onDone, +}: { + user: User; + catalog: ModuleDescriptor[]; + onDone: () => void; +}) { + const { update } = useUserMutations(); + + const { + register, + handleSubmit, + control, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(updateUserSchema), + defaultValues: { + fullName: user.fullName, + email: user.email ?? "", + role: user.role, + modules: user.modules, + }, + }); + + const role = watch("role"); + + const onSubmit = handleSubmit(async (values) => { + try { + await update.mutateAsync({ + id: user.id, + payload: { + fullName: values.fullName, + email: toNullableEmail(values.email), + role: values.role, + modules: values.modules as ModuleKey[], + }, + }); + toast.success(`${values.fullName}'s account was updated.`); + onDone(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } + }); + + return ( + <> + + Edit staff account + + Changes to role or modules take effect the next time this user signs in. + + + +
+
+ + + + + + + + + + ( + + )} + /> + +
+ +
+

Module access

+ ( + + )} + /> +
+ + + + + +
+ + ); +} diff --git a/frontend/src/pages/account/ApprovalPinCard.tsx b/frontend/src/pages/account/ApprovalPinCard.tsx new file mode 100644 index 0000000..833093b --- /dev/null +++ b/frontend/src/pages/account/ApprovalPinCard.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { Copy, KeyRound, ShieldCheck } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useApprovalPin, useAuth } from "@/features/auth"; +import { toApiError } from "@/shared/api/problem"; +import { + Alert, + AlertDescription, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + FormField, + Input, +} from "@/shared/ui"; + +interface PinFormValues { + currentPassword: string; + pin: string; +} + +/** + * Lets an administrator set, regenerate or remove their 4-digit approval PIN — the code other + * staff will ask them to type in at the till to authorise things like an order cancellation. + */ +export function ApprovalPinCard() { + const { user } = useAuth(); + const [revealedPin, setRevealedPin] = useState(null); + + const { set, clear } = useApprovalPin(); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: { currentPassword: "", pin: "" } }); + + if (!user || user.role !== "Admin") return null; + + const onGenerate = handleSubmit(async ({ currentPassword }) => { + try { + const result = await set.mutateAsync({ currentPassword, pin: null }); + setRevealedPin(result.pin); + reset(); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + const onSetChosen = handleSubmit(async ({ currentPassword, pin }) => { + if (!/^\d{4}$/.test(pin)) { + toast.error("The approval PIN must be exactly 4 digits."); + return; + } + + try { + await set.mutateAsync({ currentPassword, pin }); + setRevealedPin(pin); + reset(); + } catch (error) { + toast.error(toApiError(error).message); + } + }); + + const onClear = async () => { + try { + await clear.mutateAsync(); + setRevealedPin(null); + toast.success("Your approval PIN was removed."); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + const copyPin = async () => { + if (!revealedPin) return; + + await navigator.clipboard.writeText(revealedPin); + toast.success("PIN copied to clipboard."); + }; + + return ( + + + + + Approval PIN + + + A 4-digit PIN staff will ask you to enter at the till to authorise actions like + cancelling an order. Only you know it — it is stored as a one-way hash, never in + plain text. + + + + + {revealedPin && ( + + + + + Your new PIN is {revealedPin}. + Make a note of it now — it won't be shown again. + + + + + )} + +

+ Status:{" "} + + {user.hasApprovalPin ? "A PIN is currently set." : "No PIN has been set yet."} + +

+ +
+ + + + + + + +
+ +
+ + + {user.hasApprovalPin && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx new file mode 100644 index 0000000..546f196 --- /dev/null +++ b/frontend/src/pages/account/index.tsx @@ -0,0 +1,140 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useAuth, useChangePassword } from "@/features/auth"; +import { ChangePasswordForm, PASSWORD_HINT, changePasswordSchema } from "@/features/users/model/userSchema"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + FormField, + Input, +} from "@/shared/ui"; +import { ApprovalPinCard } from "./ApprovalPinCard"; + +export default function AccountPage() { + const { user } = useAuth(); + + if (!user) return null; + + return ( +
+
+

My account

+

+ Manage your sign-in details{user.role === "Admin" ? " and approval PIN" : ""}. +

+
+ + + + Profile + + +
+

Full name

+

{user.fullName}

+
+
+

Username

+

@{user.username}

+
+
+

Email

+

{user.email ?? "—"}

+
+
+

Role

+ {user.role} +
+
+
+ + + + {user.role === "Admin" && } +
+ ); +} + +function ChangePasswordCard() { + const changePassword = useChangePassword(); + + const { + register, + handleSubmit, + reset, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(changePasswordSchema), + defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" }, + }); + + const onSubmit = handleSubmit(async ({ currentPassword, newPassword }) => { + try { + await changePassword.mutateAsync({ currentPassword, newPassword }); + toast.success("Your password has been updated."); + reset(); + } catch (error) { + const apiError = toApiError(error); + + if (apiError.code === "Auth.PasswordMismatch") { + setError("currentPassword", { message: apiError.message }); + } else { + setError("newPassword", { message: apiError.message }); + } + } + }); + + return ( + + + Change password + This signs you out on every other device you're signed in on. + + +
+ + + + + + + + + + + + +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/pages/change-password/index.tsx b/frontend/src/pages/change-password/index.tsx new file mode 100644 index 0000000..8b2fbe5 --- /dev/null +++ b/frontend/src/pages/change-password/index.tsx @@ -0,0 +1,104 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { KeyRound } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { useAuth, useChangePassword } from "@/features/auth"; +import { changePasswordSchema, ChangePasswordForm, PASSWORD_HINT } from "@/features/users/model/userSchema"; +import { toApiError } from "@/shared/api/problem"; +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, FormField, Input } from "@/shared/ui"; + +/** + * Forced for any account with a pending password change (the seeded admin, anyone just + * created, anyone whose password was just reset); reachable voluntarily otherwise. + */ +export default function ChangePasswordPage() { + const { mustChangePassword } = useAuth(); + const changePassword = useChangePassword(); + const navigate = useNavigate(); + + const { + register, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(changePasswordSchema), + defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" }, + }); + + const onSubmit = handleSubmit(async ({ currentPassword, newPassword }) => { + try { + await changePassword.mutateAsync({ currentPassword, newPassword }); + toast.success("Your password has been updated."); + + // This screen sits outside the app shell (no nav chrome), so a forced change must send + // the user on into the app itself rather than leaving them stranded here. + if (mustChangePassword) { + navigate("/", { replace: true }); + } + } catch (error) { + const apiError = toApiError(error); + + if (apiError.code === "Auth.PasswordMismatch") { + setError("currentPassword", { message: apiError.message }); + } else { + setError("newPassword", { message: apiError.message }); + } + } + }); + + return ( +
+ + +
+ +
+ {mustChangePassword ? "Choose a new password" : "Change your password"} + + {mustChangePassword + ? "For your security, you must set your own password before continuing." + : "Signing out everywhere else you're currently signed in."} + +
+ + +
+ + + + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/dashboard/index.tsx b/frontend/src/pages/dashboard/index.tsx index 5f61f1d..90e7ca9 100644 --- a/frontend/src/pages/dashboard/index.tsx +++ b/frontend/src/pages/dashboard/index.tsx @@ -1,7 +1,69 @@ +import { Link } from "react-router-dom"; +import { groupModules } from "@/entities/user"; +import { useAuth, useModules } from "@/features/auth"; +import { MODULE_ROUTES, DEFAULT_MODULE_ICON } from "@/shared/config/moduleRoutes"; +import { Card, CardContent, LoadingState } from "@/shared/ui"; + export default function DashboardPage() { + const { user, can } = useAuth(); + const { data: catalog, isLoading } = useModules(); + + if (isLoading || !catalog) { + return ; + } + + const accessible = groupModules(catalog) + .map((group) => ({ ...group, modules: group.modules.filter((m) => can(m.module)) })) + .filter((group) => group.modules.length > 0); + return ( -
-

Dashboard

+
+
+

Welcome back, {user?.fullName?.split(" ")[0]}

+

Here's what you can open today.

+
+ + {accessible.map(({ group, modules }) => ( +
+

+ {group} +

+
+ {modules.map((descriptor) => { + const route = MODULE_ROUTES[descriptor.module]; + const Icon = route?.icon ?? DEFAULT_MODULE_ICON; + const content = ( + + + + + +
+

{descriptor.name}

+

{descriptor.description}

+ {!route?.path && ( +

+ Coming soon +

+ )} +
+
+
+ ); + + return route?.path ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + })} +
+
+ ))}
); } diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx index f149ccc..ab195b1 100644 --- a/frontend/src/pages/login/index.tsx +++ b/frontend/src/pages/login/index.tsx @@ -1,7 +1,77 @@ +import { useState } from "react"; +import { AlertCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { useLogin } from "@/features/auth"; +import { toApiError } from "@/shared/api/problem"; +import { Alert, AlertDescription, Button, Card, CardContent, FormField, Input } from "@/shared/ui"; + +interface LoginFormValues { + username: string; + password: string; +} + export default function LoginPage() { + const login = useLogin(); + const [formError, setFormError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ defaultValues: { username: "", password: "" } }); + + const onSubmit = handleSubmit(async ({ username, password }) => { + setFormError(null); + + try { + await login.mutateAsync({ username, password }); + } catch (error) { + setFormError(toApiError(error).message); + } + }); + return ( -
-

Login Screen

+
+ + +
+
+ SL +
+

Sri Lakshmi Family Restaurant

+

Sign in to the POS terminal

+
+ +
+ {formError && ( + + + {formError} + + )} + + + + + + + + + + +
+
+
); } diff --git a/frontend/src/pages/users/index.tsx b/frontend/src/pages/users/index.tsx new file mode 100644 index 0000000..e9dd154 --- /dev/null +++ b/frontend/src/pages/users/index.tsx @@ -0,0 +1,225 @@ +import { useState } from "react"; +import { + KeyRound, + MoreHorizontal, + Pencil, + Plus, + Search, + ShieldOff, + ShieldCheck, + Users as UsersIcon, +} from "lucide-react"; +import { toast } from "sonner"; +import type { User, UserFilters, UserRole } from "@/entities/user"; +import { useAuth, useModules } from "@/features/auth"; +import { ResetPasswordDialog, UserFormDialog, useUserMutations, useUsers } from "@/features/users"; +import { toApiError } from "@/shared/api/problem"; +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Input, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/ui"; + +const ROLE_FILTER_ALL = "all"; +const STATUS_FILTER_ALL = "all"; + +export default function UsersPage() { + const { user: me } = useAuth(); + const { data: catalog } = useModules(); + + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState(ROLE_FILTER_ALL); + const [statusFilter, setStatusFilter] = useState(STATUS_FILTER_ALL); + + const filters: UserFilters = { + search: search || undefined, + role: roleFilter === ROLE_FILTER_ALL ? undefined : (roleFilter as UserRole), + isActive: statusFilter === STATUS_FILTER_ALL ? undefined : statusFilter === "active", + }; + + const { data: users, isLoading } = useUsers(filters); + const { setActive } = useUserMutations(); + + const [formUser, setFormUser] = useState(undefined); + const [resetTarget, setResetTarget] = useState(null); + + const toggleActive = async (target: User) => { + try { + await setActive.mutateAsync({ id: target.id, isActive: !target.isActive }); + toast.success( + target.isActive ? `${target.fullName} was deactivated.` : `${target.fullName} was reactivated.`, + ); + } catch (error) { + toast.error(toApiError(error).message); + } + }; + + return ( +
+
+
+

User Management & Roles

+

+ Create staff accounts and control which modules they can open. +

+
+ +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search by name or username…" + className="pl-9" + /> +
+ + + + +
+ + + {isLoading ? ( + + ) : !users || users.length === 0 ? ( + } + title="No staff accounts match your filters" + description="Try clearing the search or filters, or add a new account." + /> + ) : ( + + + + Name + Role + Modules + Status + Last sign-in + + + + + {users.map((row) => ( + + +

{row.fullName}

+

@{row.username}

+
+ + {row.role} + + + {row.role === "Admin" ? "All modules" : `${row.modules.length} granted`} + + + {row.isActive ? ( + Active + ) : ( + Deactivated + )} + {row.mustChangePassword && ( + + Pending reset + + )} + + + {row.lastLoginAtUtc ? new Date(row.lastLoginAtUtc).toLocaleString() : "Never"} + + + + + + + + setFormUser(row)}> + Edit + + setResetTarget(row)}> + Reset password + + {row.id !== me?.id && !row.isSystemAdmin && ( + toggleActive(row)}> + {row.isActive ? ( + <> + Deactivate + + ) : ( + <> + Reactivate + + )} + + )} + + + +
+ ))} +
+
+ )} +
+ + !open && setFormUser(undefined)} + user={formUser ?? undefined} + catalog={catalog ?? []} + /> + + {resetTarget && ( + !open && setResetTarget(null)} + user={resetTarget} + /> + )} +
+ ); +} diff --git a/frontend/src/shared/api/axiosClient.ts b/frontend/src/shared/api/axiosClient.ts index fb3dd97..7aeb13e 100644 --- a/frontend/src/shared/api/axiosClient.ts +++ b/frontend/src/shared/api/axiosClient.ts @@ -1,16 +1,30 @@ import axios from "axios"; +import { APP_CONFIG } from "@/shared/config"; import { setupAuthInterceptor } from "./interceptors/authInterceptor"; -import { setupRefreshTokenInterceptor } from "./interceptors/refreshTokenInterceptor"; import { setupErrorInterceptor } from "./interceptors/errorInterceptor"; +import { setupRefreshTokenInterceptor } from "./interceptors/refreshTokenInterceptor"; export const axiosClient = axios.create({ - baseURL: import.meta.env.VITE_API_URL || "http://localhost:5207/api/v1", + baseURL: APP_CONFIG.apiBaseUrl, headers: { "Content-Type": "application/json", }, - timeout: 10000, + timeout: 15000, }); +/** + * Called when a session cannot be renewed. The app layer registers a handler that clears store + * state and routes to sign-in; keeping it injectable stops this module from having to know + * about the router or the Redux store. + */ +let sessionExpiredHandler: (() => void) | null = null; + +export function onSessionExpired(handler: () => void): void { + sessionExpiredHandler = handler; +} + +// Order matters. Auth stamps the token on the way out; on the way back, refresh gets first +// look at a 401 so it can retry, and the error interceptor normalises whatever is left. setupAuthInterceptor(axiosClient); -setupRefreshTokenInterceptor(axiosClient); +setupRefreshTokenInterceptor(axiosClient, () => sessionExpiredHandler?.()); setupErrorInterceptor(axiosClient); diff --git a/frontend/src/shared/api/endpoints/index.ts b/frontend/src/shared/api/endpoints/index.ts index d4a1e79..aed08d5 100644 --- a/frontend/src/shared/api/endpoints/index.ts +++ b/frontend/src/shared/api/endpoints/index.ts @@ -1,40 +1,28 @@ import { axiosClient } from "../axiosClient"; +/** + * Every API path in one place. Modules beyond user management are listed as they are built. + */ export const API_ENDPOINTS = { AUTH: { LOGIN: "/auth/login", REFRESH: "/auth/refresh", LOGOUT: "/auth/logout", ME: "/auth/me", + CHANGE_PASSWORD: "/auth/change-password", + PIN: "/auth/pin", + VERIFY_PIN: "/auth/pin/verify", }, - ORDERS: { - BASE: "/orders", - BY_ID: (id: string) => `/orders/${id}`, - STATUS: (id: string) => `/orders/${id}/status`, - }, - PRODUCTS: { - BASE: "/products", - BY_ID: (id: string) => `/products/${id}`, - CATEGORIES: "/products/categories", - }, - INVENTORY: { - BASE: "/inventory", - STOCK: "/inventory/stock", - }, - KITCHEN: { - KOT: "/kitchen/tickets", - UPDATE_STATUS: (id: string) => `/kitchen/tickets/${id}/status`, - }, - SUPPLIERS: { - BASE: "/suppliers", - BY_ID: (id: string) => `/suppliers/${id}`, - }, - REPORTS: { - SALES: "/reports/sales", - INVENTORY: "/reports/inventory", + MODULES: "/modules", + USERS: { + BASE: "/users", + BY_ID: (id: string) => `/users/${id}`, + STATUS: (id: string) => `/users/${id}/status`, + PASSWORD: (id: string) => `/users/${id}/password`, }, } as const; +/** Thin typed wrapper that unwraps `response.data`. */ export const apiService = { get: (url: string, params?: Record) => axiosClient.get(url, { params }).then((res) => res.data), @@ -42,9 +30,7 @@ export const apiService = { post: (url: string, data?: unknown) => axiosClient.post(url, data).then((res) => res.data), - put: (url: string, data?: unknown) => - axiosClient.put(url, data).then((res) => res.data), + put: (url: string, data?: unknown) => axiosClient.put(url, data).then((res) => res.data), - delete: (url: string) => - axiosClient.delete(url).then((res) => res.data), + delete: (url: string) => axiosClient.delete(url).then((res) => res.data), }; diff --git a/frontend/src/shared/api/interceptors/authInterceptor.ts b/frontend/src/shared/api/interceptors/authInterceptor.ts index 8ed8f57..30619fd 100644 --- a/frontend/src/shared/api/interceptors/authInterceptor.ts +++ b/frontend/src/shared/api/interceptors/authInterceptor.ts @@ -1,14 +1,18 @@ import { AxiosInstance, InternalAxiosRequestConfig } from "axios"; +import { tokenStorage } from "../tokenStorage"; +/** Attaches the current access token to every outgoing request. */ export function setupAuthInterceptor(axiosInstance: AxiosInstance): void { axiosInstance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - const token = typeof window !== "undefined" ? localStorage.getItem("access_token") : null; + const token = tokenStorage.getAccessToken(); + if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } + return config; }, - (error) => Promise.reject(error) + (error) => Promise.reject(error), ); } diff --git a/frontend/src/shared/api/interceptors/errorInterceptor.ts b/frontend/src/shared/api/interceptors/errorInterceptor.ts index f02239b..1db20b1 100644 --- a/frontend/src/shared/api/interceptors/errorInterceptor.ts +++ b/frontend/src/shared/api/interceptors/errorInterceptor.ts @@ -1,16 +1,15 @@ -import { AxiosInstance, AxiosError } from "axios"; +import { AxiosError, AxiosInstance } from "axios"; +import { toApiError } from "../problem"; +/** + * Normalises every failure into an {@link import('../problem').ApiError} so callers handle one + * error shape rather than picking apart axios internals and RFC 7807 bodies at each call site. + * + * Registered last so it sees only failures the refresh interceptor could not recover from. + */ export function setupErrorInterceptor(axiosInstance: AxiosInstance): void { axiosInstance.interceptors.response.use( (response) => response, - (error: AxiosError) => { - if (!error.response) { - console.error("Network Error or Server Unreachable"); - } else { - const { status, data } = error.response; - console.error(`[API Error ${status}]:`, data); - } - return Promise.reject(error); - } + (error: AxiosError) => Promise.reject(toApiError(error)), ); } diff --git a/frontend/src/shared/api/interceptors/refreshTokenInterceptor.ts b/frontend/src/shared/api/interceptors/refreshTokenInterceptor.ts index edf42d5..225005f 100644 --- a/frontend/src/shared/api/interceptors/refreshTokenInterceptor.ts +++ b/frontend/src/shared/api/interceptors/refreshTokenInterceptor.ts @@ -1,79 +1,111 @@ -import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; +import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios"; +import { tokenStorage } from "../tokenStorage"; -interface FailedRequestQueueItem { +interface QueuedRequest { resolve: (token: string) => void; - reject: (error: any) => void; + reject: (error: unknown) => void; } -let isRefreshing = false; -let failedQueue: FailedRequestQueueItem[] = []; +/** + * Endpoints that must never trigger a refresh attempt. + * + * A 401 from sign-in means "wrong password", not "expired session". A 401 from the refresh + * endpoint itself means the refresh token is dead, and retrying it would recurse forever. + */ +const NON_REFRESHABLE_PATHS = ["/auth/login", "/auth/refresh", "/auth/logout"]; -const processQueue = (error: any, token: string | null = null): void => { - failedQueue.forEach((prom) => { - if (error) { - prom.reject(error); - } else if (token) { - prom.resolve(token); +const isNonRefreshable = (url: string | undefined): boolean => + !!url && NON_REFRESHABLE_PATHS.some((path) => url.includes(path)); + +/** + * Transparently renews an expired access token and replays the request that hit the 401. + * + * Concurrent failures queue behind a single refresh: because the server rotates refresh tokens, + * firing several refreshes at once would consume tokens it has already invalidated and drop the + * session entirely. + */ +export function setupRefreshTokenInterceptor( + axiosInstance: AxiosInstance, + onSessionExpired?: () => void, +): void { + let isRefreshing = false; + let queue: QueuedRequest[] = []; + + const flushQueue = (error: unknown, token: string | null): void => { + for (const pending of queue) { + if (token) { + pending.resolve(token); + } else { + pending.reject(error); + } } - }); - failedQueue = []; -}; -export function setupRefreshTokenInterceptor(axiosInstance: AxiosInstance): void { + queue = []; + }; + + const failSession = (error: unknown): Promise => { + tokenStorage.clear(); + flushQueue(error, null); + onSessionExpired?.(); + + return Promise.reject(error); + }; + axiosInstance.interceptors.response.use( (response) => response, async (error: AxiosError) => { - const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; - - if (error.response?.status === 401 && !originalRequest._retry) { - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }).then((token) => { - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${token}`; - } - return axiosInstance(originalRequest); - }); - } + const request = error.config as + | (InternalAxiosRequestConfig & { _retried?: boolean }) + | undefined; - originalRequest._retry = true; - isRefreshing = true; + const shouldAttemptRefresh = + error.response?.status === 401 && + !!request && + !request._retried && + !isNonRefreshable(request.url); - try { - const refreshToken = typeof window !== "undefined" ? localStorage.getItem("refresh_token") : null; - if (!refreshToken) { - throw new Error("No refresh token available"); - } + if (!shouldAttemptRefresh) { + return Promise.reject(error); + } - const response = await axiosInstance.post("/auth/refresh", { refreshToken }); - const { accessToken, refreshToken: newRefreshToken } = response.data; + request._retried = true; - if (typeof window !== "undefined") { - localStorage.setItem("access_token", accessToken); - localStorage.setItem("refresh_token", newRefreshToken); + // A refresh is already in flight, so wait for it rather than starting another. + if (isRefreshing) { + return new Promise((resolve, reject) => { + queue.push({ resolve, reject }); + }).then((token) => { + if (request.headers) { + request.headers.Authorization = `Bearer ${token}`; } - processQueue(null, accessToken); + return axiosInstance(request); + }); + } + + const refreshToken = tokenStorage.getRefreshToken(); + if (!refreshToken) { + return failSession(error); + } - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${accessToken}`; - } - return axiosInstance(originalRequest); - } catch (refreshError) { - processQueue(refreshError, null); - if (typeof window !== "undefined") { - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); - window.location.href = "/login"; - } - return Promise.reject(refreshError); - } finally { - isRefreshing = false; + isRefreshing = true; + + try { + const { data } = await axiosInstance.post("/auth/refresh", { refreshToken }); + + tokenStorage.save(data.accessToken, data.refreshToken); + flushQueue(null, data.accessToken); + + if (request.headers) { + request.headers.Authorization = `Bearer ${data.accessToken}`; } - } - return Promise.reject(error); - } + return await axiosInstance(request); + } catch (refreshError) { + return failSession(refreshError); + } finally { + isRefreshing = false; + } + }, ); } diff --git a/frontend/src/shared/api/problem.ts b/frontend/src/shared/api/problem.ts new file mode 100644 index 0000000..7c2e73d --- /dev/null +++ b/frontend/src/shared/api/problem.ts @@ -0,0 +1,90 @@ +import { AxiosError } from "axios"; + +/** RFC 7807 problem body as produced by the API. */ +interface ProblemDetails { + title?: string; + status?: number; + detail?: string; + /** Stable machine-readable code, e.g. `Auth.InvalidCredentials`. */ + code?: string; + /** Field-keyed validation messages, present on 400 responses. */ + errors?: Record; +} + +/** + * A server or network failure in the shape the UI actually needs: a message safe to show, a + * stable code to branch on, and per-field messages to attach to form inputs. + */ +export class ApiError extends Error { + readonly status: number; + readonly code: string | null; + readonly fieldErrors: Record; + + constructor(message: string, status: number, code: string | null, fieldErrors: Record = {}) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.fieldErrors = fieldErrors; + } + + /** True when the caller should re-authenticate. */ + get isUnauthorized(): boolean { + return this.status === 401; + } + + /** True when the API is refusing to serve anything until the password is changed. */ + get requiresPasswordChange(): boolean { + return this.code === "Auth.PasswordChangeRequired"; + } + + /** First message recorded against a field, if any. */ + fieldError(field: string): string | undefined { + const key = Object.keys(this.fieldErrors).find( + (k) => k.toLowerCase() === field.toLowerCase(), + ); + + return key ? this.fieldErrors[key]?.[0] : undefined; + } +} + +/** Converts an axios failure into an {@link ApiError}. */ +export function toApiError(error: unknown): ApiError { + if (error instanceof ApiError) return error; + + if (error instanceof AxiosError) { + if (!error.response) { + return new ApiError( + "Cannot reach the server. Check that the POS service is running.", + 0, + "Network.Unreachable", + ); + } + + const { status, data } = error.response; + const problem = (data ?? {}) as ProblemDetails; + + return new ApiError( + problem.title ?? problem.detail ?? defaultMessageFor(status), + status, + problem.code ?? null, + problem.errors ?? {}, + ); + } + + return new ApiError( + error instanceof Error ? error.message : "Something went wrong.", + 0, + null, + ); +} + +function defaultMessageFor(status: number): string { + if (status === 401) return "Your session has expired. Please sign in again."; + if (status === 403) return "You do not have permission to do that."; + if (status === 404) return "That item could not be found."; + if (status === 429) return "Too many attempts. Please wait a moment and try again."; + if (status >= 500) return "The server ran into a problem. Please try again."; + + return "The request could not be completed."; +} diff --git a/frontend/src/shared/api/tokenStorage.ts b/frontend/src/shared/api/tokenStorage.ts new file mode 100644 index 0000000..728f64f --- /dev/null +++ b/frontend/src/shared/api/tokenStorage.ts @@ -0,0 +1,34 @@ +const ACCESS_TOKEN_KEY = "access_token"; +const REFRESH_TOKEN_KEY = "refresh_token"; + +const isBrowser = () => typeof window !== "undefined" && !!window.localStorage; + +/** + * The single place tokens are read from and written to. + * + * Centralised so the axios interceptors, the auth slice and the sign-out path cannot drift out + * of step over key names or clean-up. + */ +export const tokenStorage = { + getAccessToken(): string | null { + return isBrowser() ? window.localStorage.getItem(ACCESS_TOKEN_KEY) : null; + }, + + getRefreshToken(): string | null { + return isBrowser() ? window.localStorage.getItem(REFRESH_TOKEN_KEY) : null; + }, + + save(accessToken: string, refreshToken: string): void { + if (!isBrowser()) return; + + window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + }, + + clear(): void { + if (!isBrowser()) return; + + window.localStorage.removeItem(ACCESS_TOKEN_KEY); + window.localStorage.removeItem(REFRESH_TOKEN_KEY); + }, +}; diff --git a/frontend/src/shared/config/moduleRoutes.ts b/frontend/src/shared/config/moduleRoutes.ts new file mode 100644 index 0000000..14fc676 --- /dev/null +++ b/frontend/src/shared/config/moduleRoutes.ts @@ -0,0 +1,51 @@ +import { + LayoutDashboard, + ChefHat, + ClipboardList, + PackageSearch, + Truck, + Warehouse, + Receipt, + BarChart3, + Bell, + Users, + Settings, + type LucideIcon, +} from "lucide-react"; + +/** + * Where each module lives in the app, and what to show for it in the sidebar before its screen + * exists yet. + * + * This is the one place a module goes from "in the catalog" to "has a page" — add a `path` here + * once its screen is built. Until then the module still appears in navigation (so the menu + * structure matches what the restaurant was promised) as a disabled "coming soon" item. + * + * Lives in `shared` rather than `entities/user` (where `ModuleKey` is defined) or `app` (where + * the router lives) because it is consumed by both a widget (the sidebar) and pages — the FSD + * boundary rules this project enforces mean `shared` is the only layer both can reach. The map + * is keyed by the same plain-string module identifier as `ModuleKey`, just without importing it, + * so this file carries no dependency on the entity layer. + */ +export interface ModuleRoute { + /** Present once the module's screen exists; absent renders as a disabled entry. */ + path?: string; + icon: LucideIcon; +} + +export const MODULE_ROUTES: Record = { + PosBilling: { path: "/checkout", icon: Receipt }, + RecipeManagement: { icon: ChefHat }, + StoreStockManagement: { icon: Warehouse }, + KitchenStockRelease: { icon: PackageSearch }, + KitchenStockTracking: { icon: ClipboardList }, + KitchenOperations: { icon: ChefHat }, + ReportsAnalytics: { path: "/reports", icon: BarChart3 }, + Notifications: { icon: Bell }, + UserManagement: { path: "/users", icon: Users }, + SupplierManagement: { icon: Truck }, + ExpensesManagement: { icon: Receipt }, + SystemSettings: { icon: Settings }, +}; + +export const DEFAULT_MODULE_ICON: LucideIcon = LayoutDashboard; diff --git a/frontend/src/shared/store/index.ts b/frontend/src/shared/store/index.ts index f6a812f..99786f5 100644 --- a/frontend/src/shared/store/index.ts +++ b/frontend/src/shared/store/index.ts @@ -10,10 +10,6 @@ const rootReducer = combineReducers({ export const store = configureStore({ reducer: rootReducer, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ - serializableCheck: false, - }), }); export type RootState = ReturnType; diff --git a/frontend/src/shared/theme/index.ts b/frontend/src/shared/theme/index.ts index f9955d5..7d48037 100644 --- a/frontend/src/shared/theme/index.ts +++ b/frontend/src/shared/theme/index.ts @@ -1,7 +1,33 @@ -export const theme = { - colors: { - primary: "var(--primary)", - background: "var(--background)", - foreground: "var(--foreground)", - }, -}; +/** + * Theme tokens live in `src/app/globals.css` as raw HSL channels and reach components through + * Tailwind classes (`bg-primary`, `text-muted-foreground`, …) configured in `tailwind.config.ts`. + * + * These helpers exist only for the rare case where a colour is needed in JavaScript — a canvas + * chart or an inline SVG fill — so such code still reads from the same source of truth instead + * of hard-coding a hex value. + */ + +export type ThemeToken = + | "background" + | "foreground" + | "card" + | "primary" + | "secondary" + | "muted" + | "accent" + | "destructive" + | "success" + | "warning" + | "border"; + +/** Returns a CSS colour expression for a token, e.g. `hsl(var(--primary) / 0.5)`. */ +export function themeColor(token: ThemeToken, alpha = 1): string { + return alpha === 1 ? `hsl(var(--${token}))` : `hsl(var(--${token}) / ${alpha})`; +} + +/** Applies a theme by toggling the `dark` class that the Tailwind config keys off. */ +export function applyTheme(theme: "light" | "dark"): void { + if (typeof document === "undefined") return; + + document.documentElement.classList.toggle("dark", theme === "dark"); +} diff --git a/frontend/src/shared/ui/alert.tsx b/frontend/src/shared/ui/alert.tsx new file mode 100644 index 0000000..e4808ee --- /dev/null +++ b/frontend/src/shared/ui/alert.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/shared/lib/utils"; + +const alertVariants = cva( + "relative flex w-full gap-3 rounded-lg border p-4 text-sm [&>svg]:size-5 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + info: "border-primary/25 bg-primary/5 text-foreground [&>svg]:text-primary", + success: "border-success/25 bg-success/5 text-foreground [&>svg]:text-success", + warning: "border-warning/30 bg-warning/5 text-foreground [&>svg]:text-warning", + destructive: + "border-destructive/30 bg-destructive/5 text-foreground [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface AlertProps + extends React.HTMLAttributes, + VariantProps {} + +const Alert = React.forwardRef( + ({ className, variant, ...props }, ref) => ( +
+ ), +); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/frontend/src/shared/ui/badge.tsx b/frontend/src/shared/ui/badge.tsx new file mode 100644 index 0000000..ffd4afd --- /dev/null +++ b/frontend/src/shared/ui/badge.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/shared/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors", + { + variants: { + variant: { + default: "border-transparent bg-primary/10 text-primary", + secondary: "border-transparent bg-secondary text-secondary-foreground", + success: "border-transparent bg-success/10 text-success", + warning: "border-transparent bg-warning/10 text-warning", + destructive: "border-transparent bg-destructive/10 text-destructive", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ; +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/shared/ui/button.tsx b/frontend/src/shared/ui/button.tsx new file mode 100644 index 0000000..51c828f --- /dev/null +++ b/frontend/src/shared/ui/button.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +const buttonVariants = cva( + // Touch targets are generous by default: this runs on a till, often tapped rather than clicked. + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-12 rounded-md px-6 text-base", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** Renders the child element instead of a `button`, e.g. to make a link look like a button. */ + asChild?: boolean; + /** Shows a spinner and disables the button. */ + loading?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, loading = false, disabled, children, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + + {loading ? ( + <> + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend/src/shared/ui/card.tsx b/frontend/src/shared/ui/card.tsx new file mode 100644 index 0000000..dccb90f --- /dev/null +++ b/frontend/src/shared/ui/card.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; diff --git a/frontend/src/shared/ui/checkbox.tsx b/frontend/src/shared/ui/checkbox.tsx new file mode 100644 index 0000000..25ba16f --- /dev/null +++ b/frontend/src/shared/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/frontend/src/shared/ui/dialog.tsx b/frontend/src/shared/ui/dialog.tsx new file mode 100644 index 0000000..96439d2 --- /dev/null +++ b/frontend/src/shared/ui/dialog.tsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/frontend/src/shared/ui/dropdown-menu.tsx b/frontend/src/shared/ui/dropdown-menu.tsx new file mode 100644 index 0000000..0d27e0b --- /dev/null +++ b/frontend/src/shared/ui/dropdown-menu.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cn } from "@/shared/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { destructive?: boolean } +>(({ className, destructive, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuGroup, +}; diff --git a/frontend/src/shared/ui/form-field.tsx b/frontend/src/shared/ui/form-field.tsx new file mode 100644 index 0000000..1094544 --- /dev/null +++ b/frontend/src/shared/ui/form-field.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; +import { Label } from "./label"; + +export interface FormFieldProps { + /** Ties the label, hint and error to the control for screen readers. */ + htmlFor: string; + label: string; + /** Explanatory text shown under the control while it is valid. */ + hint?: string; + /** Validation message. When present it replaces the hint and marks the field invalid. */ + error?: string; + required?: boolean; + className?: string; + children: React.ReactNode; +} + +/** + * Labelled wrapper for a single form control. Owns the id conventions for the description and + * error elements so every form in the app announces errors the same way. + */ +export function FormField({ + htmlFor, + label, + hint, + error, + required, + className, + children, +}: FormFieldProps) { + const describedBy = error ? `${htmlFor}-error` : hint ? `${htmlFor}-hint` : undefined; + + return ( +
+ + + {React.isValidElement(children) + ? React.cloneElement(children as React.ReactElement>, { + id: htmlFor, + "aria-invalid": error ? true : undefined, + "aria-describedby": describedBy, + }) + : children} + + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +} diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 95c3e67..9d5277f 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1,2 +1,41 @@ -// Export shared UI components here (e.g. Button, Input, Modal, Table) -export {}; +export { Button, buttonVariants, type ButtonProps } from "./button"; +export { Input, type InputProps } from "./input"; +export { Label } from "./label"; +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./card"; +export { Badge, badgeVariants, type BadgeProps } from "./badge"; +export { Switch } from "./switch"; +export { Checkbox } from "./checkbox"; +export { Separator } from "./separator"; +export { Alert, AlertTitle, AlertDescription, type AlertProps } from "./alert"; +export { FormField, type FormFieldProps } from "./form-field"; +export { LoadingState, EmptyState, Skeleton, type EmptyStateProps } from "./states"; +export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "./table"; +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from "./dialog"; +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectItem, +} from "./select"; +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuGroup, +} from "./dropdown-menu"; diff --git a/frontend/src/shared/ui/input.tsx b/frontend/src/shared/ui/input.tsx new file mode 100644 index 0000000..536050e --- /dev/null +++ b/frontend/src/shared/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => ( + + ), +); +Input.displayName = "Input"; + +export { Input }; diff --git a/frontend/src/shared/ui/label.tsx b/frontend/src/shared/ui/label.tsx new file mode 100644 index 0000000..402dae1 --- /dev/null +++ b/frontend/src/shared/ui/label.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cn } from "@/shared/lib/utils"; + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/frontend/src/shared/ui/select.tsx b/frontend/src/shared/ui/select.tsx new file mode 100644 index 0000000..57cc1e1 --- /dev/null +++ b/frontend/src/shared/ui/select.tsx @@ -0,0 +1,84 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + {children} + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }; diff --git a/frontend/src/shared/ui/separator.tsx b/frontend/src/shared/ui/separator.tsx new file mode 100644 index 0000000..c25d3ac --- /dev/null +++ b/frontend/src/shared/ui/separator.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/shared/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/frontend/src/shared/ui/states.tsx b/frontend/src/shared/ui/states.tsx new file mode 100644 index 0000000..1db2f5d --- /dev/null +++ b/frontend/src/shared/ui/states.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +/** Centred spinner for a region that is still loading. */ +export function LoadingState({ label = "Loading…", className }: { label?: string; className?: string }) { + return ( +
+
+ ); +} + +export interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + description?: string; + action?: React.ReactNode; + className?: string; +} + +/** Shown in place of a list or table that has no rows to display. */ +export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} + +/** Placeholder block used while content is being fetched. */ +export function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
; +} diff --git a/frontend/src/shared/ui/switch.tsx b/frontend/src/shared/ui/switch.tsx new file mode 100644 index 0000000..cf048b8 --- /dev/null +++ b/frontend/src/shared/ui/switch.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import { cn } from "@/shared/lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/frontend/src/shared/ui/table.tsx b/frontend/src/shared/ui/table.tsx new file mode 100644 index 0000000..c76f8db --- /dev/null +++ b/frontend/src/shared/ui/table.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import { cn } from "@/shared/lib/utils"; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( + // Wrapped so a wide table scrolls within its container rather than the page. +
+ + + ), +); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }; diff --git a/frontend/src/widgets/app-shell/AppShell.tsx b/frontend/src/widgets/app-shell/AppShell.tsx new file mode 100644 index 0000000..747952b --- /dev/null +++ b/frontend/src/widgets/app-shell/AppShell.tsx @@ -0,0 +1,26 @@ +import { Outlet } from "react-router-dom"; +import { useModules } from "@/features/auth"; +import { LoadingState } from "@/shared/ui"; +import { Sidebar } from "./Sidebar"; +import { Topbar } from "./Topbar"; + +/** The signed-in application frame: sidebar navigation, top bar, and the routed page. */ +export function AppShell() { + const { data: catalog, isLoading } = useModules(); + + if (isLoading || !catalog) { + return ; + } + + return ( +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/widgets/app-shell/Sidebar.tsx b/frontend/src/widgets/app-shell/Sidebar.tsx new file mode 100644 index 0000000..ac1019a --- /dev/null +++ b/frontend/src/widgets/app-shell/Sidebar.tsx @@ -0,0 +1,88 @@ +import { NavLink } from "react-router-dom"; +import { groupModules, type ModuleDescriptor } from "@/entities/user"; +import { useAuth } from "@/features/auth"; +import { MODULE_ROUTES, DEFAULT_MODULE_ICON } from "@/shared/config/moduleRoutes"; +import { cn } from "@/shared/lib/utils"; + +export interface SidebarProps { + catalog: ModuleDescriptor[]; +} + +/** + * Primary navigation, built from the module catalog rather than a hard-coded list — a module a + * user cannot open is left out entirely rather than shown and blocked, so the menu only ever + * promises what it can deliver. + */ +export function Sidebar({ catalog }: SidebarProps) { + const { can } = useAuth(); + const groups = groupModules(catalog).map((group) => ({ + ...group, + modules: group.modules.filter((m) => can(m.module)), + })); + + return ( + + ); +} + +function ModuleNavItem({ descriptor }: { descriptor: ModuleDescriptor }) { + const route = MODULE_ROUTES[descriptor.module]; + const Icon = route?.icon ?? DEFAULT_MODULE_ICON; + + if (!route?.path) { + return ( +
+ + {descriptor.name} + Soon +
+ ); + } + + return ( + + cn( + "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", + isActive + ? "bg-primary/10 text-primary" + : "text-foreground/80 hover:bg-accent hover:text-accent-foreground", + ) + } + > + + {descriptor.name} + + ); +} diff --git a/frontend/src/widgets/app-shell/Topbar.tsx b/frontend/src/widgets/app-shell/Topbar.tsx new file mode 100644 index 0000000..ddcec6b --- /dev/null +++ b/frontend/src/widgets/app-shell/Topbar.tsx @@ -0,0 +1,57 @@ +import { LogOut, ShieldCheck, User as UserIcon } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useAuth, useLogout } from "@/features/auth"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui"; + +/** Top bar: current user, role, and account actions. */ +export function Topbar() { + const { user } = useAuth(); + const logout = useLogout(); + const navigate = useNavigate(); + + if (!user) return null; + + return ( +
+
+ + + + + + + + {user.fullName} + @{user.username} + + {user.role === "Admin" && ( + + + Admin + + )} + + + + Signed in as {user.username} + + navigate("/account")}> + My account + + + logout.mutate()}> + Sign out + + + +
+ ); +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index ac59a97..4d08855 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -1,25 +1,82 @@ import type { Config } from "tailwindcss"; +/** Maps a CSS custom property holding raw HSL channels to a Tailwind colour. */ +const hsl = (variable: string) => `hsl(var(--${variable}) / )`; + const config: Config = { darkMode: ["class"], content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", - "./src/widgets/**/*.{js,ts,jsx,tsx,mdx}", - "./src/features/**/*.{js,ts,jsx,tsx,mdx}", - "./src/entities/**/*.{js,ts,jsx,tsx,mdx}", - "./src/shared/**/*.{js,ts,jsx,tsx,mdx}", + "./index.html", + "./src/app/**/*.{js,ts,jsx,tsx}", + "./src/pages/**/*.{js,ts,jsx,tsx}", + "./src/widgets/**/*.{js,ts,jsx,tsx}", + "./src/features/**/*.{js,ts,jsx,tsx}", + "./src/entities/**/*.{js,ts,jsx,tsx}", + "./src/shared/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { colors: { - background: "var(--background)", - foreground: "var(--foreground)", + background: hsl("background"), + foreground: hsl("foreground"), + border: hsl("border"), + input: hsl("input"), + ring: hsl("ring"), + card: { + DEFAULT: hsl("card"), + foreground: hsl("card-foreground"), + }, + popover: { + DEFAULT: hsl("popover"), + foreground: hsl("popover-foreground"), + }, primary: { - DEFAULT: "var(--primary)", - foreground: "var(--primary-foreground)", + DEFAULT: hsl("primary"), + foreground: hsl("primary-foreground"), + }, + secondary: { + DEFAULT: hsl("secondary"), + foreground: hsl("secondary-foreground"), + }, + muted: { + DEFAULT: hsl("muted"), + foreground: hsl("muted-foreground"), + }, + accent: { + DEFAULT: hsl("accent"), + foreground: hsl("accent-foreground"), + }, + destructive: { + DEFAULT: hsl("destructive"), + foreground: hsl("destructive-foreground"), }, + success: { + DEFAULT: hsl("success"), + foreground: hsl("success-foreground"), + }, + warning: { + DEFAULT: hsl("warning"), + foreground: hsl("warning-foreground"), + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "fade-in": { + from: { opacity: "0" }, + to: { opacity: "1" }, + }, + "slide-up": { + from: { opacity: "0", transform: "translateY(6px)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + }, + animation: { + "fade-in": "fade-in 150ms ease-out", + "slide-up": "slide-up 200ms ease-out", }, }, }, diff --git a/frontend/tests/components/LoginPage.test.tsx b/frontend/tests/components/LoginPage.test.tsx new file mode 100644 index 0000000..eed8ea6 --- /dev/null +++ b/frontend/tests/components/LoginPage.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import LoginPage from "@/pages/login"; +import { server } from "@tests/mocks/server"; +import { renderWithProviders } from "@tests/utils/render"; + +describe("LoginPage", () => { + it("requires both fields before submitting", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /sign in/i })); + + expect(await screen.findByText("Username is required.")).toBeInTheDocument(); + expect(screen.getByText("Password is required.")).toBeInTheDocument(); + }); + + it("shows the server's message when credentials are rejected", async () => { + server.use( + http.post("*/api/v1/auth/login", () => + HttpResponse.json( + { title: "The username or password is incorrect.", code: "Auth.InvalidCredentials" }, + { status: 401 }, + ), + ), + ); + + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/username/i), "admin"); + await user.type(screen.getByLabelText(/password/i), "wrong-password"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + expect(await screen.findByText("The username or password is incorrect.")).toBeInTheDocument(); + }); + + it("establishes a session on success", async () => { + const user = userEvent.setup(); + const { store } = renderWithProviders(); + + await user.type(screen.getByLabelText(/username/i), "admin"); + await user.type(screen.getByLabelText(/password/i), "ChangeMe!123"); + await user.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => expect(store.getState().auth.status).toBe("authenticated")); + expect(store.getState().auth.user?.username).toBe("admin"); + }); +}); diff --git a/frontend/tests/components/ModulePermissionPicker.test.tsx b/frontend/tests/components/ModulePermissionPicker.test.tsx new file mode 100644 index 0000000..e7b1dd4 --- /dev/null +++ b/frontend/tests/components/ModulePermissionPicker.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { render } from "@testing-library/react"; +import { ModulePermissionPicker } from "@/features/users"; +import { moduleCatalog } from "@tests/mocks/fixtures"; + +describe("ModulePermissionPicker", () => { + it("explains that module selection does not apply to administrators", () => { + render( + , + ); + + expect(screen.getByText(/administrators can open every module/i)).toBeInTheDocument(); + expect(screen.queryByText("POS & Billing")).not.toBeInTheDocument(); + }); + + it("never offers an admin-only module to a staff account", () => { + render( + , + ); + + expect(screen.getByText("POS & Billing")).toBeInTheDocument(); + expect(screen.queryByText("User Management & Roles")).not.toBeInTheDocument(); + }); + + it("toggles a module on and off", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("checkbox", { name: /pos & billing/i })); + + expect(onChange).toHaveBeenCalledWith(["PosBilling"]); + }); + + it("deselects a currently-granted module", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("checkbox", { name: /pos & billing/i })); + + expect(onChange).toHaveBeenCalledWith([]); + }); + + it("clears every selection at once", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /clear all/i })); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/frontend/tests/components/example.test.tsx b/frontend/tests/components/example.test.tsx deleted file mode 100644 index c8fe3bb..0000000 --- a/frontend/tests/components/example.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import App from "@/app/App"; -import { renderWithProviders } from "../utils/render"; - -describe("App Component Test", () => { - it("renders system title heading", () => { - renderWithProviders(); - expect( - screen.getByRole("heading", { name: /Restaurant POS System/i }) - ).toBeInTheDocument(); - }); -}); diff --git a/frontend/tests/integration/example.test.ts b/frontend/tests/integration/example.test.ts deleted file mode 100644 index 589756e..0000000 --- a/frontend/tests/integration/example.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { apiService } from "@/shared/api/endpoints"; - -describe("MSW API Integration Tests", () => { - it("fetches mock health status successfully", async () => { - const response = await apiService.get<{ status: string }>("/health"); - expect(response.status).toBe("Healthy"); - }); -}); diff --git a/frontend/tests/mocks/fixtures.ts b/frontend/tests/mocks/fixtures.ts new file mode 100644 index 0000000..96e94cd --- /dev/null +++ b/frontend/tests/mocks/fixtures.ts @@ -0,0 +1,76 @@ +import type { ModuleDescriptor, Session, User } from "@/entities/user"; + +/** A signed-in administrator who still owes a password change. */ +export const adminPendingChange: User = { + id: "11111111-1111-1111-1111-111111111111", + username: "admin", + fullName: "System Administrator", + email: null, + role: "Admin", + isActive: true, + mustChangePassword: true, + isSystemAdmin: true, + hasApprovalPin: false, + lastLoginAtUtc: null, + createdAtUtc: "2026-01-01T00:00:00Z", + modules: [], +}; + +/** A fully provisioned administrator. */ +export const adminProvisioned: User = { + ...adminPendingChange, + mustChangePassword: false, +}; + +/** A staff account limited to POS & Billing. */ +export const cashierUser: User = { + id: "22222222-2222-2222-2222-222222222222", + username: "cashier01", + fullName: "Ravi Kumar", + email: null, + role: "User", + isActive: true, + mustChangePassword: false, + isSystemAdmin: false, + hasApprovalPin: false, + lastLoginAtUtc: null, + createdAtUtc: "2026-01-02T00:00:00Z", + modules: ["PosBilling"], +}; + +export const moduleCatalog: ModuleDescriptor[] = [ + { + module: "PosBilling", + name: "POS & Billing", + group: "Operations", + description: "Take orders, split and settle bills, and print receipts.", + sortOrder: 10, + adminOnly: false, + }, + { + module: "ReportsAnalytics", + name: "Reports & Analytics", + group: "Administration", + description: "Sales, inventory, expense and staff performance reporting.", + sortOrder: 90, + adminOnly: false, + }, + { + module: "UserManagement", + name: "User Management & Roles", + group: "Administration", + description: "Create staff accounts and control which modules they can open.", + sortOrder: 110, + adminOnly: true, + }, +]; + +export function sessionFor(user: User): Session { + return { + accessToken: "mock-access-token", + accessTokenExpiresAtUtc: "2026-01-01T01:00:00Z", + refreshToken: "mock-refresh-token", + refreshTokenExpiresAtUtc: "2026-01-15T00:00:00Z", + user, + }; +} diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index 63aaed2..bcf9271 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -1,14 +1,20 @@ import { http, HttpResponse } from "msw"; +import { adminPendingChange, moduleCatalog, sessionFor } from "./fixtures"; +const API = "*/api/v1"; + +/** + * Default handlers matching the real API's contracts. Individual tests override specific + * routes with `server.use(...)` for the scenario under test, e.g. a login failure. + */ export const handlers = [ - http.get("*/api/v1/health", () => { - return HttpResponse.json({ status: "Healthy" }); - }), - http.post("*/api/v1/auth/login", () => { - return HttpResponse.json({ - accessToken: "mock_access_token", - refreshToken: "mock_refresh_token", - user: { id: "1", name: "Admin User", email: "admin@pos.com", role: "Admin" }, - }); - }), + http.get("*/health", () => HttpResponse.json({ status: "Healthy" })), + + http.post(`${API}/auth/login`, () => HttpResponse.json(sessionFor(adminPendingChange))), + + http.get(`${API}/auth/me`, () => HttpResponse.json(adminPendingChange)), + + http.get(`${API}/modules`, () => HttpResponse.json(moduleCatalog)), + + http.get(`${API}/users`, () => HttpResponse.json([])), ]; diff --git a/frontend/tests/unit/entities/user-permissions.test.ts b/frontend/tests/unit/entities/user-permissions.test.ts new file mode 100644 index 0000000..453a590 --- /dev/null +++ b/frontend/tests/unit/entities/user-permissions.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { ModuleDescriptor, User } from "@/entities/user"; +import { assignableModules, canAccessModule, groupModules } from "@/entities/user"; + +function makeUser(overrides: Partial = {}): User { + return { + id: "1", + username: "cashier01", + fullName: "Ravi Kumar", + email: null, + role: "User", + isActive: true, + mustChangePassword: false, + isSystemAdmin: false, + hasApprovalPin: false, + lastLoginAtUtc: null, + createdAtUtc: "2026-01-01T00:00:00Z", + modules: [], + ...overrides, + }; +} + +const catalog: ModuleDescriptor[] = [ + { module: "PosBilling", name: "POS & Billing", group: "Operations", description: "", sortOrder: 10, adminOnly: false }, + { module: "ExpensesManagement", name: "Expenses Management", group: "Operations", description: "", sortOrder: 80, adminOnly: false }, + { module: "ReportsAnalytics", name: "Reports & Analytics", group: "Administration", description: "", sortOrder: 90, adminOnly: false }, + { module: "UserManagement", name: "User Management & Roles", group: "Administration", description: "", sortOrder: 110, adminOnly: true }, +]; + +describe("canAccessModule", () => { + it("is false for a signed-out user regardless of module", () => { + expect(canAccessModule(null, "PosBilling")).toBe(false); + }); + + it("is limited to granted modules for a staff user", () => { + const user = makeUser({ modules: ["PosBilling"] }); + + expect(canAccessModule(user, "PosBilling")).toBe(true); + expect(canAccessModule(user, "ReportsAnalytics")).toBe(false); + }); + + it("is unconditional for an administrator, even with no listed modules", () => { + const admin = makeUser({ role: "Admin", modules: [] }); + + expect(canAccessModule(admin, "UserManagement")).toBe(true); + }); +}); + +describe("assignableModules", () => { + it("excludes admin-only modules", () => { + const result = assignableModules(catalog); + + expect(result.map((m) => m.module)).toEqual(["PosBilling", "ExpensesManagement", "ReportsAnalytics"]); + }); +}); + +describe("groupModules", () => { + it("groups by the catalog's group label and preserves sort order within and across groups", () => { + const shuffled = [...catalog].reverse(); + + const groups = groupModules(shuffled); + + expect(groups.map((g) => g.group)).toEqual(["Operations", "Administration"]); + expect(groups[0].modules.map((m) => m.module)).toEqual(["PosBilling", "ExpensesManagement"]); + expect(groups[1].modules.map((m) => m.module)).toEqual(["ReportsAnalytics", "UserManagement"]); + }); +}); diff --git a/frontend/tests/unit/features/userSchema.test.ts b/frontend/tests/unit/features/userSchema.test.ts new file mode 100644 index 0000000..abf2667 --- /dev/null +++ b/frontend/tests/unit/features/userSchema.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + approvalPinSchema, + changePasswordSchema, + createUserSchema, + toNullableEmail, + updateUserSchema, +} from "@/features/users/model/userSchema"; + +const validCreate = { + username: "cashier01", + fullName: "Ravi Kumar", + email: "", + password: "Cashier@2026", + role: "User" as const, + modules: ["PosBilling"], +}; + +describe("createUserSchema", () => { + it("accepts a well-formed submission", () => { + expect(createUserSchema.safeParse(validCreate).success).toBe(true); + }); + + it.each(["ab", "has spaces", "bad!char"])("rejects a malformed username %s", (username) => { + const result = createUserSchema.safeParse({ ...validCreate, username }); + expect(result.success).toBe(false); + }); + + it.each(["short1A", "alllowercase1", "ALLUPPERCASE1", "NoDigitsHere"])( + "rejects a password that fails the policy: %s", + (password) => { + const result = createUserSchema.safeParse({ ...validCreate, password }); + expect(result.success).toBe(false); + }, + ); + + it("allows an empty email but rejects a malformed one", () => { + expect(createUserSchema.safeParse({ ...validCreate, email: "" }).success).toBe(true); + expect(createUserSchema.safeParse({ ...validCreate, email: "not-an-email" }).success).toBe(false); + expect(createUserSchema.safeParse({ ...validCreate, email: "ravi@srilakshmi.lk" }).success).toBe(true); + }); +}); + +describe("updateUserSchema", () => { + it("does not require a username or password", () => { + const result = updateUserSchema.safeParse({ + fullName: "Ravi Kumar", + email: "", + role: "User", + modules: [], + }); + + expect(result.success).toBe(true); + }); +}); + +describe("toNullableEmail", () => { + it("converts the empty-string sentinel to null and leaves real addresses alone", () => { + expect(toNullableEmail("")).toBeNull(); + expect(toNullableEmail("ravi@srilakshmi.lk")).toBe("ravi@srilakshmi.lk"); + }); +}); + +describe("changePasswordSchema", () => { + const base = { + currentPassword: "Current@2026", + newPassword: "Brand@2026New", + confirmPassword: "Brand@2026New", + }; + + it("accepts matching, policy-compliant passwords", () => { + expect(changePasswordSchema.safeParse(base).success).toBe(true); + }); + + it("rejects when the confirmation does not match", () => { + const result = changePasswordSchema.safeParse({ ...base, confirmPassword: "Different@2026" }); + + expect(result.success).toBe(false); + expect(result.success ? undefined : result.error.issues[0].path).toEqual(["confirmPassword"]); + }); + + it("rejects reusing the current password as the new one", () => { + const result = changePasswordSchema.safeParse({ + ...base, + newPassword: base.currentPassword, + confirmPassword: base.currentPassword, + }); + + expect(result.success).toBe(false); + expect(result.success ? undefined : result.error.issues[0].path).toEqual(["newPassword"]); + }); +}); + +describe("approvalPinSchema", () => { + it.each(["4821", "0000", "9999"])("accepts a 4-digit PIN: %s", (pin) => { + expect(approvalPinSchema.safeParse(pin).success).toBe(true); + }); + + it.each(["123", "12345", "abcd", ""])("rejects an invalid PIN: %s", (pin) => { + expect(approvalPinSchema.safeParse(pin).success).toBe(false); + }); +}); diff --git a/frontend/tests/utils/render.tsx b/frontend/tests/utils/render.tsx index cbff3bf..c1c581d 100644 --- a/frontend/tests/utils/render.tsx +++ b/frontend/tests/utils/render.tsx @@ -2,13 +2,17 @@ import React, { ReactElement } from "react"; import { render, RenderOptions } from "@testing-library/react"; import { Provider } from "react-redux"; import { QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import type { RootState } from "@/shared/store"; import { createTestStore } from "./testStore"; import { createTestQueryClient } from "./testQueryClient"; interface ExtendedRenderOptions extends Omit { - preloadedState?: any; + preloadedState?: Partial; store?: ReturnType; queryClient?: ReturnType; + /** Starting URL(s) for components that use router hooks (`useNavigate`, `Link`, ...). */ + route?: string; } export function renderWithProviders( @@ -17,14 +21,15 @@ export function renderWithProviders( preloadedState = {}, store = createTestStore(preloadedState), queryClient = createTestQueryClient(), + route = "/", ...renderOptions - }: ExtendedRenderOptions = {} + }: ExtendedRenderOptions = {}, ) { function Wrapper({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ); From 4ddcf94fb910a0dd3ae304cfdad340176433b736 Mon Sep 17 00:00:00 2001 From: BinadaPasandul Date: Sun, 2 Aug 2026 22:20:18 +0530 Subject: [PATCH 2/2] test commit --- frontend/src/pages/dashboard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/dashboard/index.tsx b/frontend/src/pages/dashboard/index.tsx index 90e7ca9..3d2354e 100644 --- a/frontend/src/pages/dashboard/index.tsx +++ b/frontend/src/pages/dashboard/index.tsx @@ -19,7 +19,7 @@ export default function DashboardPage() { return (
-

Welcome back, {user?.fullName?.split(" ")[0]}

+

Hi welcome back, {user?.fullName?.split(" ")[0]}

Here's what you can open today.