diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ed0702bc4..d4d05ee5b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -14,9 +14,13 @@ name: "CodeQL" on: push: branches: [ "main" ] + aths-ignore: + - ".github/workflows/deploy/**" pull_request: # The branches below must be a subset of the branches above branches: [ "main" ] + paths-ignore: + - ".github/workflows/deploy/**" schedule: - cron: '0 0 * * 1' diff --git a/.github/workflows/deploy/.env b/.github/workflows/deploy/.env new file mode 100644 index 000000000..5c14c219b --- /dev/null +++ b/.github/workflows/deploy/.env @@ -0,0 +1,31 @@ +USE_IN_MEMORY_DATABASE= +ASPNETCORE_ENVIRONMENT=Development +ASPNETCORE_URLS=http://+:80;https://+:443 +ASPNETCORE_HTTP_PORTS=80 +ASPNETCORE_HTTPS_PORTS=443 +APP_URL= +APP_VERSION= + +DB_PROVIDER=mssql +DB_CONNECTION_STRING=Server=;Database=;User Id=;Password=;MultipleActiveResultSets=true;Encrypt=false;TrustServerCertificate=false +SA_PASSWORD= + +SMTP_USER= +SMTP_PORT=2525 +SMTP_SERVER= +SMTP_PASSWORD= +SMTP_DEFAULT_FROM= + +MS_CLIENT_ID= +MS_CLIENT_SECRET= + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +FB_APP_ID= +FB_APP_SECRET= + +MINIO_ENDPOINT= +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_BUCKET= diff --git a/.github/workflows/deploy/docker-compose-db.yml b/.github/workflows/deploy/docker-compose-db.yml new file mode 100644 index 000000000..d1b1a0e15 --- /dev/null +++ b/.github/workflows/deploy/docker-compose-db.yml @@ -0,0 +1,53 @@ +version: '3.8' +services: + mssql: + image: mcr.microsoft.com/mssql/server:2019-latest + environment: + - ACCEPT_EULA=Y + - SA_PASSWORD=${SA_PASSWORD} # 请在 .env 中配置你的 SA 密码 + ports: + - "1433:1433" + volumes: + - mssql_data:/var/opt/mssql + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P ${SA_PASSWORD} -Q \"SELECT 1\""] + interval: 10s + timeout: 10s + retries: 3 + + blazorserverapp: + image: blazordevlab/cleanarchitectureblazorserver:1.1.95-pre.c66e9603 + environment: + - UseInMemoryDatabase=${USE_IN_MEMORY_DATABASE} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT} + - ASPNETCORE_URLS=${ASPNETCORE_URLS} + - ASPNETCORE_HTTP_PORTS=${ASPNETCORE_HTTP_PORTS} + - ASPNETCORE_HTTPS_PORTS=${ASPNETCORE_HTTPS_PORTS} + - AppConfigurationSettings__ApplicationUrl=${APP_URL} + - AppConfigurationSettings__Version=${APP_VERSION} + - DatabaseSettings__DBProvider=mssql + - DatabaseSettings__ConnectionString=Server=mssql;Database=BlazorDashboardDb;User Id=sa;Password=${SA_PASSWORD};MultipleActiveResultSets=true;Encrypt=false;TrustServerCertificate=false + - SmtpClientOptions__User=${SMTP_USER} + - SmtpClientOptions__Port=${SMTP_PORT} + - SmtpClientOptions__Server=${SMTP_SERVER} + - SmtpClientOptions__Password=${SMTP_PASSWORD} + - SmtpClientOptions__DefaultFromEmail=${SMTP_DEFAULT_FROM} + - Authentication__Microsoft__ClientId=${MS_CLIENT_ID} + - Authentication__Microsoft__ClientSecret=${MS_CLIENT_SECRET} + - Authentication__Google__ClientId=${GOOGLE_CLIENT_ID} + - Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET} + - Authentication__Facebook__AppId=${FB_APP_ID} + - Authentication__Facebook__AppSecret=${FB_APP_SECRET} + - Minio__Endpoint=${MINIO_ENDPOINT} + - Minio__AccessKey=${MINIO_ACCESS_KEY} + - Minio__SecretKey=${MINIO_SECRET_KEY} + - Minio__BucketName=${MINIO_BUCKET} + ports: + - "8014:80" + - "8015:443" + depends_on: + mssql: + condition: service_healthy + +volumes: + mssql_data: diff --git a/.github/workflows/deploy/docker-compose.yml b/.github/workflows/deploy/docker-compose.yml new file mode 100644 index 000000000..e33646799 --- /dev/null +++ b/.github/workflows/deploy/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' +services: + blazorserverapp: + image: blazordevlab/cleanarchitectureblazorserver:1.1.95-pre.c66e9603 + environment: + - UseInMemoryDatabase=${USE_IN_MEMORY_DATABASE} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT} + - ASPNETCORE_URLS=${ASPNETCORE_URLS} + - ASPNETCORE_HTTP_PORTS=${ASPNETCORE_HTTP_PORTS} + - ASPNETCORE_HTTPS_PORTS=${ASPNETCORE_HTTPS_PORTS} + - AppConfigurationSettings__ApplicationUrl=${APP_URL} + - AppConfigurationSettings__Version=${APP_VERSION} + - DatabaseSettings__DBProvider=${DB_PROVIDER} + - DatabaseSettings__ConnectionString=${DB_CONNECTION_STRING} + - SmtpClientOptions__User=${SMTP_USER} + - SmtpClientOptions__Port=${SMTP_PORT} + - SmtpClientOptions__Server=${SMTP_SERVER} + - SmtpClientOptions__Password=${SMTP_PASSWORD} + - SmtpClientOptions__DefaultFromEmail=${SMTP_DEFAULT_FROM} + - Authentication__Microsoft__ClientId=${MS_CLIENT_ID} + - Authentication__Microsoft__ClientSecret=${MS_CLIENT_SECRET} + - Authentication__Google__ClientId=${GOOGLE_CLIENT_ID} + - Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET} + - Authentication__Facebook__AppId=${FB_APP_ID} + - Authentication__Facebook__AppSecret=${FB_APP_SECRET} + - Minio__Endpoint=${MINIO_ENDPOINT} + - Minio__AccessKey=${MINIO_ACCESS_KEY} + - Minio__SecretKey=${MINIO_SECRET_KEY} + - Minio__BucketName=${MINIO_BUCKET} + ports: + - "8014:80" + - "8015:443" \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index e526271f1..01d292462 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -3,8 +3,12 @@ name: Docker Image CI on: push: branches: [ "main" ] + paths-ignore: + - ".github/workflows/deploy/**" pull_request: branches: [ "main" ] + paths-ignore: + - ".github/workflows/deploy/**" jobs: @@ -26,25 +30,51 @@ jobs: - run: git tag ${{ steps.version.outputs.version }} - run: git push origin ${{ steps.version.outputs.version }} - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + # - name: Login to Docker Hub + # uses: docker/login-action@v3 + # with: + # username: ${{ secrets.DOCKER_USERNAME }} + # password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: blazordevlab/cleanarchitectureblazorserver:${{ steps.version.outputs.version }} + # - name: Build and push + # uses: docker/build-push-action@v5 + # with: + # context: . + # push: true + # tags: blazordevlab/cleanarchitectureblazorserver:${{ steps.version.outputs.version }} - - name: Build and push with latest tag - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: blazordevlab/cleanarchitectureblazorserver:latest + # - name: Build and push with latest tag + # uses: docker/build-push-action@v5 + # with: + # context: . + # push: true + # tags: blazordevlab/cleanarchitectureblazorserver:latest + + # - name: Update version in docker-compose files + # run: | + # echo "Updating docker-compose files with version ${{ steps.version.outputs.version }}" + # sed -i 's#\(blazordevlab/cleanarchitectureblazorserver:\).*#\1${{ steps.version.outputs.version }}#' .github/workflows/deploy/docker-compose.yml + # sed -i 's#\(blazordevlab/cleanarchitectureblazorserver:\).*#\1${{ steps.version.outputs.version }}#' .github/workflows/deploy/docker-compose-db.yml + + - name: Update version in docker-compose files + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + echo "Updating docker-compose files with version ${{ steps.version.outputs.version }}" + sed -i 's#\(blazordevlab/cleanarchitectureblazorserver:\).*#\1${{ steps.version.outputs.version }}#' .github/workflows/deploy/docker-compose.yml + sed -i 's#\(blazordevlab/cleanarchitectureblazorserver:\).*#\1${{ steps.version.outputs.version }}#' .github/workflows/deploy/docker-compose-db.yml + + - name: Set up Git + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + - name: Commit and push updated files + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git add .github/workflows/deploy/docker-compose.yml .github/workflows/deploy/docker-compose-db.yml + git commit -m "Update Docker Compose files to version ${{ steps.version.outputs.version }}" + git push origin main diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 948db89d7..0a736c779 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -6,8 +6,12 @@ name: Build on: push: branches: [ main ] + paths-ignore: + - ".github/workflows/deploy/**" pull_request: branches: [ main ] + paths-ignore: + - ".github/workflows/deploy/**" jobs: build: diff --git a/src/Application/Common/Models/Result.cs b/src/Application/Common/Models/Result.cs index dfcf18524..0796e6f74 100644 --- a/src/Application/Common/Models/Result.cs +++ b/src/Application/Common/Models/Result.cs @@ -120,22 +120,30 @@ protected Result(bool succeeded, IEnumerable? errors, T? data) public static new Task> FailureAsync(params string[] errors) => Task.FromResult(Failure(errors)); /// - /// Executes the appropriate function based on whether the operation succeeded. + /// Executes the appropriate action based on whether the operation succeeded. /// - /// The return type. - /// Function to execute if the operation succeeded, with the data. - /// Function to execute if the operation failed, with an error message. - public TResult Match(Func onSuccess, Func onFailure) - => Succeeded ? onSuccess(Data!) : onFailure(ErrorMessage); + /// Action to execute if the operation succeeded, with the data. + /// Action to execute if the operation failed, with an error message. + public void Match(Action onSuccess, Action onFailure) + { + if (Succeeded) + onSuccess(Data!); + else + onFailure(ErrorMessage); + } /// - /// Asynchronously executes the appropriate function based on whether the operation succeeded. + /// Asynchronously executes the appropriate action based on whether the operation succeeded. /// - /// The return type. - /// Asynchronous function to execute if the operation succeeded, with the data. - /// Asynchronous function to execute if the operation failed, with an error message. - public Task MatchAsync(Func> onSuccess, Func> onFailure) - => Succeeded ? onSuccess(Data!) : onFailure(ErrorMessage); + /// Asynchronous action to execute if the operation succeeded, with the data. + /// Asynchronous action to execute if the operation failed, with an error message. + public async Task MatchAsync(Func onSuccess, Func onFailure) + { + if (Succeeded) + await onSuccess(Data!); + else + await onFailure(ErrorMessage); + } /// /// Maps the data contained in the result to a new type. diff --git a/src/Application/Common/Models/UploadRequest.cs b/src/Application/Common/Models/UploadRequest.cs index 238ed5e07..030ae28b0 100644 --- a/src/Application/Common/Models/UploadRequest.cs +++ b/src/Application/Common/Models/UploadRequest.cs @@ -7,14 +7,13 @@ namespace CleanArchitecture.Blazor.Application.Common.Models; public class UploadRequest { - public UploadRequest(string fileName, UploadType uploadType, byte[] data, bool overwrite = false,string? folder=null, ResizeOptions? resizeOptions=null) + public UploadRequest(string fileName, UploadType uploadType, byte[] data, bool overwrite = false,string? folder=null) { FileName = fileName; UploadType = uploadType; Data = data; Overwrite = overwrite; Folder = folder; - ResizeOptions = resizeOptions; } public string FileName { get; set; } public string? Extension { get; set; } @@ -22,5 +21,4 @@ public UploadRequest(string fileName, UploadType uploadType, byte[] data, bool o public bool Overwrite { get; set; } public byte[] Data { get; set; } public string? Folder { get; set; } - public ResizeOptions? ResizeOptions { get; set; } } \ No newline at end of file diff --git a/src/Application/Common/Security/UserProfileStateService.cs b/src/Application/Common/Security/UserProfileStateService.cs index 80bd80e76..156574ebc 100644 --- a/src/Application/Common/Security/UserProfileStateService.cs +++ b/src/Application/Common/Security/UserProfileStateService.cs @@ -7,38 +7,56 @@ namespace CleanArchitecture.Blazor.Application.Common.Security; -public class UserProfileStateService +public class UserProfileStateService : IDisposable { + // Cache refresh interval of 60 seconds private TimeSpan RefreshInterval => TimeSpan.FromSeconds(60); - private UserProfile _userProfile = new UserProfile() { Email="", UserId="", UserName="" }; + + // Internal user profile state + private UserProfile _userProfile = new UserProfile { Email = "", UserId = "", UserName = "" }; + + // Dependencies private readonly UserManager _userManager; private readonly IMapper _mapper; private readonly IFusionCache _fusionCache; + private readonly IServiceScope _scope; public UserProfileStateService( IMapper mapper, IServiceScopeFactory scopeFactory, IFusionCache fusionCache) { - var scope = scopeFactory.CreateScope(); - _userManager = scope.ServiceProvider.GetRequiredService>(); + _scope = scopeFactory.CreateScope(); + _userManager = _scope.ServiceProvider.GetRequiredService>(); _mapper = mapper; _fusionCache = fusionCache; - } + + /// + /// Loads and initializes the user profile from the database. + /// public async Task InitializeAsync(string userName) { var key = GetApplicationUserCacheKey(userName); - var result = await _fusionCache.GetOrSetAsync(key, - _ => _userManager.Users.Where(x => x.UserName == userName).Include(x => x.UserRoles) - .ThenInclude(x => x.Role).ProjectTo(_mapper.ConfigurationProvider) - .FirstOrDefaultAsync(), RefreshInterval); - if(result is not null) + var result = await _fusionCache.GetOrSetAsync( + key, + _ => _userManager.Users + .Where(x => x.UserName == userName) + .Include(x => x.UserRoles).ThenInclude(x => x.Role) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(), + RefreshInterval); + + if (result is not null) { _userProfile = result.ToUserProfile(); NotifyStateChanged(); } } + + /// + /// Gets or sets the current user profile. + /// public UserProfile UserProfile { get => _userProfile; @@ -49,11 +67,19 @@ public UserProfile UserProfile } } - public event Func? OnChange; - - private void NotifyStateChanged() => OnChange?.Invoke(); + /// + /// Refreshes the user profile by removing the cached value and reloading data from the database. + /// + public async Task RefreshAsync(string userName) + { + RemoveApplicationUserCache(userName); + await InitializeAsync(userName); + } - public void UpdateUserProfile(string userName,string? profilePictureDataUrl, string? fullName,string? phoneNumber,string? timeZoneId,string? languageCode) + /// + /// Updates the user profile and clears the cache. + /// + public void UpdateUserProfile(string userName, string? profilePictureDataUrl, string? fullName, string? phoneNumber, string? timeZoneId, string? languageCode) { _userProfile.ProfilePictureDataUrl = profilePictureDataUrl; _userProfile.DisplayName = fullName; @@ -63,13 +89,21 @@ public void UpdateUserProfile(string userName,string? profilePictureDataUrl, str RemoveApplicationUserCache(userName); NotifyStateChanged(); } + + public event Func? OnChange; + + private void NotifyStateChanged() => OnChange?.Invoke(); + private string GetApplicationUserCacheKey(string userName) { return $"GetApplicationUserDto:{userName}"; } + public void RemoveApplicationUserCache(string userName) { _fusionCache.Remove(GetApplicationUserCacheKey(userName)); } + + public void Dispose() => _scope.Dispose(); } diff --git a/src/Application/Features/Contacts/Caching/ContactCacheKey.cs b/src/Application/Features/Contacts/Caching/ContactCacheKey.cs index f4b03d622..96e8966e1 100644 --- a/src/Application/Features/Contacts/Caching/ContactCacheKey.cs +++ b/src/Application/Features/Contacts/Caching/ContactCacheKey.cs @@ -5,8 +5,8 @@ // See the LICENSE file in the project root for more information. // // Author: neozhu -// Created Date: 2025-03-13 -// Last Modified: 2025-03-13 +// Created Date: 2025-03-19 +// Last Modified: 2025-03-19 // Description: // Defines static methods and properties for managing cache keys and expiration // settings for Contact-related data. This includes creating unique cache keys for diff --git a/src/Application/Features/Contacts/Commands/AddEdit/AddEditContactCommand.cs b/src/Application/Features/Contacts/Commands/AddEdit/AddEditContactCommand.cs index f9a6e499e..16ef0e253 100644 --- a/src/Application/Features/Contacts/Commands/AddEdit/AddEditContactCommand.cs +++ b/src/Application/Features/Contacts/Commands/AddEdit/AddEditContactCommand.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Command for adding/editing a contact entity with validation, mapping, // domain events, and cache invalidation. // Documentation: https://docs.cleanarchitectureblazor.com/features/contact diff --git a/src/Application/Features/Contacts/Commands/AddEdit/AddEditContactCommandValidator.cs b/src/Application/Features/Contacts/Commands/AddEdit/AddEditContactCommandValidator.cs index 64612a456..3e5965884 100644 --- a/src/Application/Features/Contacts/Commands/AddEdit/AddEditContactCommandValidator.cs +++ b/src/Application/Features/Contacts/Commands/AddEdit/AddEditContactCommandValidator.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Validator for AddEditContactCommand: enforces field length and required property rules for Contact entities. // Docs: https://docs.cleanarchitectureblazor.com/features/contact // diff --git a/src/Application/Features/Contacts/Commands/Create/CreateContactCommand.cs b/src/Application/Features/Contacts/Commands/Create/CreateContactCommand.cs index 14c6faffb..0127c294e 100644 --- a/src/Application/Features/Contacts/Commands/Create/CreateContactCommand.cs +++ b/src/Application/Features/Contacts/Commands/Create/CreateContactCommand.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Command and handler for creating a new Contact. // Uses caching invalidation and domain events for data consistency. // Docs: https://docs.cleanarchitectureblazor.com/features/contact diff --git a/src/Application/Features/Contacts/Commands/Create/CreateContactCommandValidator.cs b/src/Application/Features/Contacts/Commands/Create/CreateContactCommandValidator.cs index 72fa93709..6be3e4e60 100644 --- a/src/Application/Features/Contacts/Commands/Create/CreateContactCommandValidator.cs +++ b/src/Application/Features/Contacts/Commands/Create/CreateContactCommandValidator.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Validator for CreateContactCommand: enforces max lengths and required fields for Contact entities. // Docs: https://docs.cleanarchitectureblazor.com/features/contact // diff --git a/src/Application/Features/Contacts/Commands/Delete/DeleteContactCommand.cs b/src/Application/Features/Contacts/Commands/Delete/DeleteContactCommand.cs index 8ca75b7f2..953febe85 100644 --- a/src/Application/Features/Contacts/Commands/Delete/DeleteContactCommand.cs +++ b/src/Application/Features/Contacts/Commands/Delete/DeleteContactCommand.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Command and handler for deleting Contact entities. // Implements cache invalidation and triggers domain events. // Docs: https://docs.cleanarchitectureblazor.com/features/contact diff --git a/src/Application/Features/Contacts/Commands/Delete/DeleteContactCommandValidator.cs b/src/Application/Features/Contacts/Commands/Delete/DeleteContactCommandValidator.cs index 40ba72e4c..8d8f1ad01 100644 --- a/src/Application/Features/Contacts/Commands/Delete/DeleteContactCommandValidator.cs +++ b/src/Application/Features/Contacts/Commands/Delete/DeleteContactCommandValidator.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Validator for DeleteContactCommand: ensures the ID list for contact is not null and contains only positive IDs. // Docs: https://docs.cleanarchitectureblazor.com/features/contact // diff --git a/src/Application/Features/Contacts/Commands/Import/ImportContactsCommand.cs b/src/Application/Features/Contacts/Commands/Import/ImportContactsCommand.cs index cd2b20b21..9da76968a 100644 --- a/src/Application/Features/Contacts/Commands/Import/ImportContactsCommand.cs +++ b/src/Application/Features/Contacts/Commands/Import/ImportContactsCommand.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Import command & template for contacts. // Validates Excel data, prevents duplicates, and provides a template for bulk entry. // Docs: https://docs.cleanarchitectureblazor.com/features/contact @@ -62,11 +62,11 @@ public async Task> Handle(ImportContactsCommand request, Cancellatio var result = await _excelService.ImportAsync(request.Data, mappers: new Dictionary> { - { _localizer[_dto.GetMemberDescription(x=>x.Name)], (row, item) => item.Name = row[_localizer[_dto.GetMemberDescription(x=>x.Name)]].ToString() }, -{ _localizer[_dto.GetMemberDescription(x=>x.Description)], (row, item) => item.Description = row[_localizer[_dto.GetMemberDescription(x=>x.Description)]].ToString() }, -{ _localizer[_dto.GetMemberDescription(x=>x.Email)], (row, item) => item.Email = row[_localizer[_dto.GetMemberDescription(x=>x.Email)]].ToString() }, -{ _localizer[_dto.GetMemberDescription(x=>x.PhoneNumber)], (row, item) => item.PhoneNumber = row[_localizer[_dto.GetMemberDescription(x=>x.PhoneNumber)]].ToString() }, -{ _localizer[_dto.GetMemberDescription(x=>x.Country)], (row, item) => item.Country = row[_localizer[_dto.GetMemberDescription(x=>x.Country)]].ToString() }, + { _localizer[_dto.GetMemberDescription(x=>x.Name)], (row, item) => item.Name = row[_localizer[_dto.GetMemberDescription(x=>x.Name)]].ToString() }, + { _localizer[_dto.GetMemberDescription(x=>x.Description)], (row, item) => item.Description = row[_localizer[_dto.GetMemberDescription(x=>x.Description)]].ToString() }, + { _localizer[_dto.GetMemberDescription(x=>x.Email)], (row, item) => item.Email = row[_localizer[_dto.GetMemberDescription(x=>x.Email)]].ToString() }, + { _localizer[_dto.GetMemberDescription(x=>x.PhoneNumber)], (row, item) => item.PhoneNumber = row[_localizer[_dto.GetMemberDescription(x=>x.PhoneNumber)]].ToString() }, + { _localizer[_dto.GetMemberDescription(x=>x.Country)], (row, item) => item.Country = row[_localizer[_dto.GetMemberDescription(x=>x.Country)]].ToString() }, }, _localizer[_dto.GetClassDescription()]); if (result.Succeeded && result.Data is not null) diff --git a/src/Application/Features/Contacts/Commands/Import/ImportContactsCommandValidator.cs b/src/Application/Features/Contacts/Commands/Import/ImportContactsCommandValidator.cs index 7227a2acd..258f4d81a 100644 --- a/src/Application/Features/Contacts/Commands/Import/ImportContactsCommandValidator.cs +++ b/src/Application/Features/Contacts/Commands/Import/ImportContactsCommandValidator.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Validator for ImportContactsCommand: ensures the Data property is non-null and non-empty for contact import. // Docs: https://docs.cleanarchitectureblazor.com/features/contact // diff --git a/src/Application/Features/Contacts/Commands/Update/UpdateContactCommand.cs b/src/Application/Features/Contacts/Commands/Update/UpdateContactCommand.cs index dbe4c4399..b29aba0dd 100644 --- a/src/Application/Features/Contacts/Commands/Update/UpdateContactCommand.cs +++ b/src/Application/Features/Contacts/Commands/Update/UpdateContactCommand.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // UpdateContactCommand & handler: updates an existing Contact with cache invalidation and raises ContactUpdatedEvent. // Docs: https://docs.cleanarchitectureblazor.com/features/contact // @@ -11,9 +11,8 @@ // Usage: // Use UpdateContactCommand to update an existing contact. If found, changes are applied, cache is invalidated, and ContactUpdatedEvent is raised. - -using CleanArchitecture.Blazor.Application.Features.Contacts.Caching; using CleanArchitecture.Blazor.Application.Features.Contacts.DTOs; +using CleanArchitecture.Blazor.Application.Features.Contacts.Caching; namespace CleanArchitecture.Blazor.Application.Features.Contacts.Commands.Update; @@ -21,7 +20,7 @@ public class UpdateContactCommand: ICacheInvalidatorRequest> { [Description("Id")] public int Id { get; set; } - [Description("Name")] + [Description("Name")] public string Name {get;set;} [Description("Description")] public string? Description {get;set;} diff --git a/src/Application/Features/Contacts/Commands/Update/UpdateContactCommandValidator.cs b/src/Application/Features/Contacts/Commands/Update/UpdateContactCommandValidator.cs index 58c4c371d..ee074f7ea 100644 --- a/src/Application/Features/Contacts/Commands/Update/UpdateContactCommandValidator.cs +++ b/src/Application/Features/Contacts/Commands/Update/UpdateContactCommandValidator.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Validator for UpdateContactCommand: ensures required fields (e.g., Id, non-empty Name, max length for properties) are valid for updating a contact. // Docs: https://docs.cleanarchitectureblazor.com/features/contact // diff --git a/src/Application/Features/Contacts/DTOs/ContactDto.cs b/src/Application/Features/Contacts/DTOs/ContactDto.cs index 3a859b557..edb1a90a8 100644 --- a/src/Application/Features/Contacts/DTOs/ContactDto.cs +++ b/src/Application/Features/Contacts/DTOs/ContactDto.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // ContactDto: transfers contact data between layers. // Docs: https://docs.cleanarchitectureblazor.com/features/contact // diff --git a/src/Application/Features/Contacts/EventHandlers/ContactCreatedEventHandler.cs b/src/Application/Features/Contacts/EventHandlers/ContactCreatedEventHandler.cs index 833de08e8..2bcdf657a 100644 --- a/src/Application/Features/Contacts/EventHandlers/ContactCreatedEventHandler.cs +++ b/src/Application/Features/Contacts/EventHandlers/ContactCreatedEventHandler.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Handles ContactCreatedEvent: triggered when a new contact is created. // Extendable for additional actions (e.g., notifications, system updates). // diff --git a/src/Application/Features/Contacts/EventHandlers/ContactDeletedEventHandler.cs b/src/Application/Features/Contacts/EventHandlers/ContactDeletedEventHandler.cs index 1ae960534..cfa5c7b26 100644 --- a/src/Application/Features/Contacts/EventHandlers/ContactDeletedEventHandler.cs +++ b/src/Application/Features/Contacts/EventHandlers/ContactDeletedEventHandler.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Handles ContactDeletedEvent: triggered when a contact is deleted. // Extendable for additional actions (e.g., notifications, system updates). // diff --git a/src/Application/Features/Contacts/EventHandlers/ContactUpdatedEventHandler.cs b/src/Application/Features/Contacts/EventHandlers/ContactUpdatedEventHandler.cs index ccdc21023..baaa7aaa3 100644 --- a/src/Application/Features/Contacts/EventHandlers/ContactUpdatedEventHandler.cs +++ b/src/Application/Features/Contacts/EventHandlers/ContactUpdatedEventHandler.cs @@ -2,7 +2,7 @@ // // CleanArchitecture.Blazor - MIT Licensed. // Author: neozhu -// Created/Modified: 2025-03-13 +// Created/Modified: 2025-03-19 // Handles ContactUpdatedEvent: triggered when a contact is updated. // Extendable for additional actions (e.g., notifications, system updates). // diff --git a/src/Application/Features/Contacts/Queries/Export/ExportContactsQuery.cs b/src/Application/Features/Contacts/Queries/Export/ExportContactsQuery.cs index a86b8c9e0..682edbaf9 100644 --- a/src/Application/Features/Contacts/Queries/Export/ExportContactsQuery.cs +++ b/src/Application/Features/Contacts/Queries/Export/ExportContactsQuery.cs @@ -5,8 +5,8 @@ // See the LICENSE file in the project root for more information. // // Author: neozhu -// Created Date: 2025-03-13 -// Last Modified: 2025-03-13 +// Created Date: 2025-03-19 +// Last Modified: 2025-03-19 // Description: // Defines a query to export contact data to an Excel file. This query // applies advanced filtering options and generates an Excel file with @@ -26,7 +26,7 @@ public class ExportContactsQuery : ContactAdvancedFilter, ICacheableRequest? Tags => ContactCacheKey.Tags; public override string ToString() { - return $"Listview:{ListView}:{CurrentUser?.UserId}-{LocalTimezoneOffset.TotalHours}, Search:{Keyword}, {OrderBy}, {SortDirection}"; + return $"Listview:{ListView}:{CurrentUser?.UserId}, Search:{Keyword}, {OrderBy}, {SortDirection}"; } public string CacheKey => ContactCacheKey.GetExportCacheKey($"{this}"); } @@ -63,11 +63,11 @@ public async Task> Handle(ExportContactsQuery request, Cancellati new Dictionary>() { // TODO: Define the fields that should be exported, for example: - {_localizer[_dto.GetMemberDescription(x=>x.Name)],item => item.Name}, -{_localizer[_dto.GetMemberDescription(x=>x.Description)],item => item.Description}, -{_localizer[_dto.GetMemberDescription(x=>x.Email)],item => item.Email}, -{_localizer[_dto.GetMemberDescription(x=>x.PhoneNumber)],item => item.PhoneNumber}, -{_localizer[_dto.GetMemberDescription(x=>x.Country)],item => item.Country}, + {_localizer[_dto.GetMemberDescription(x=>x.Name)],item => item.Name}, + {_localizer[_dto.GetMemberDescription(x=>x.Description)],item => item.Description}, + {_localizer[_dto.GetMemberDescription(x=>x.Email)],item => item.Email}, + {_localizer[_dto.GetMemberDescription(x=>x.PhoneNumber)],item => item.PhoneNumber}, + {_localizer[_dto.GetMemberDescription(x=>x.Country)],item => item.Country}, } , _localizer[_dto.GetClassDescription()]); diff --git a/src/Application/Features/Contacts/Queries/GetAll/GetAllContactsQuery.cs b/src/Application/Features/Contacts/Queries/GetAll/GetAllContactsQuery.cs index 87bdc4963..10087c51a 100644 --- a/src/Application/Features/Contacts/Queries/GetAll/GetAllContactsQuery.cs +++ b/src/Application/Features/Contacts/Queries/GetAll/GetAllContactsQuery.cs @@ -5,8 +5,8 @@ // See the LICENSE file in the project root for more information. // // Author: neozhu -// Created Date: 2025-03-13 -// Last Modified: 2025-03-13 +// Created Date: 2025-03-19 +// Last Modified: 2025-03-19 // Description: // Defines a query to retrieve all contacts from the database. The result // is cached to improve performance and reduce database load for repeated diff --git a/src/Application/Features/Contacts/Queries/GetById/GetContactByIdQuery.cs b/src/Application/Features/Contacts/Queries/GetById/GetContactByIdQuery.cs index 7baf20f82..bf6558fef 100644 --- a/src/Application/Features/Contacts/Queries/GetById/GetContactByIdQuery.cs +++ b/src/Application/Features/Contacts/Queries/GetById/GetContactByIdQuery.cs @@ -5,8 +5,8 @@ // See the LICENSE file in the project root for more information. // // Author: neozhu -// Created Date: 2025-03-13 -// Last Modified: 2025-03-13 +// Created Date: 2025-03-19 +// Last Modified: 2025-03-19 // Description: // Defines a query to retrieve a contact by its ID. The result is cached // to optimize performance for repeated retrievals of the same contact. diff --git a/src/Application/Features/Contacts/Queries/Pagination/ContactsPaginationQuery.cs b/src/Application/Features/Contacts/Queries/Pagination/ContactsPaginationQuery.cs index 3a49cb956..cdaa4fccc 100644 --- a/src/Application/Features/Contacts/Queries/Pagination/ContactsPaginationQuery.cs +++ b/src/Application/Features/Contacts/Queries/Pagination/ContactsPaginationQuery.cs @@ -5,8 +5,8 @@ // See the LICENSE file in the project root for more information. // // Author: neozhu -// Created Date: 2025-03-13 -// Last Modified: 2025-03-13 +// Created Date: 2025-03-19 +// Last Modified: 2025-03-19 // Description: // Defines a query for retrieving contacts with pagination and filtering // options. The result is cached to enhance performance for repeated queries. @@ -23,7 +23,7 @@ public class ContactsWithPaginationQuery : ContactAdvancedFilter, ICacheableRequ { public override string ToString() { - return $"Listview:{ListView}:{CurrentUser?.UserId}-{LocalTimezoneOffset.TotalHours}, Search:{Keyword}, {OrderBy}, {SortDirection}, {PageNumber}, {PageSize}"; + return $"Listview:{ListView}:{CurrentUser?.UserId}, Search:{Keyword}, {OrderBy}, {SortDirection}, {PageNumber}, {PageSize}"; } public string CacheKey => ContactCacheKey.GetPaginationCacheKey($"{this}"); public IEnumerable? Tags => ContactCacheKey.Tags; diff --git a/src/Application/Features/Contacts/Specifications/ContactAdvancedFilter.cs b/src/Application/Features/Contacts/Specifications/ContactAdvancedFilter.cs index ed4dc5371..bdad0091b 100644 --- a/src/Application/Features/Contacts/Specifications/ContactAdvancedFilter.cs +++ b/src/Application/Features/Contacts/Specifications/ContactAdvancedFilter.cs @@ -5,8 +5,8 @@ // See the LICENSE file in the project root for more information. // // Author: neozhu -// Created Date: 2025-03-13 -// Last Modified: 2025-03-13 +// Created Date: 2025-03-19 +// Last Modified: 2025-03-19 // Description: // Defines the available views for filtering contacts and provides advanced // filtering options for contact lists. This includes pagination and various @@ -36,7 +36,6 @@ public enum ContactListView /// public class ContactAdvancedFilter: PaginationFilter { - public TimeSpan LocalTimezoneOffset { get; set; } public ContactListView ListView { get; set; } = ContactListView.All; public UserProfile? CurrentUser { get; set; } } \ No newline at end of file diff --git a/src/Application/Features/Contacts/Specifications/ContactAdvancedSpecification.cs b/src/Application/Features/Contacts/Specifications/ContactAdvancedSpecification.cs index 64cdd021a..19b63f02d 100644 --- a/src/Application/Features/Contacts/Specifications/ContactAdvancedSpecification.cs +++ b/src/Application/Features/Contacts/Specifications/ContactAdvancedSpecification.cs @@ -5,8 +5,8 @@ // See the LICENSE file in the project root for more information. // // Author: neozhu -// Created Date: 2025-03-13 -// Last Modified: 2025-03-13 +// Created Date: 2025-03-19 +// Last Modified: 2025-03-19 // Description: // Defines a specification for applying advanced filtering options to the // Contact entity, supporting different views and keyword-based searches. @@ -23,8 +23,8 @@ public class ContactAdvancedSpecification : Specification public ContactAdvancedSpecification(ContactAdvancedFilter filter) { DateTime today = DateTime.UtcNow; - var todayrange = today.GetDateRange(ContactListView.TODAY.ToString(), filter.LocalTimezoneOffset); - var last30daysrange = today.GetDateRange(ContactListView.LAST_30_DAYS.ToString(),filter.LocalTimezoneOffset); + var todayrange = today.GetDateRange(ContactListView.TODAY.ToString(), filter.CurrentUser.LocalTimeOffset); + var last30daysrange = today.GetDateRange(ContactListView.LAST_30_DAYS.ToString(),filter.CurrentUser.LocalTimeOffset); Query.Where(q => q.Name != null) .Where(filter.Keyword,!string.IsNullOrEmpty(filter.Keyword)) diff --git a/src/Application/Features/Contacts/Specifications/ContactByIdSpecification.cs b/src/Application/Features/Contacts/Specifications/ContactByIdSpecification.cs index 9cb52066f..583acf166 100644 --- a/src/Application/Features/Contacts/Specifications/ContactByIdSpecification.cs +++ b/src/Application/Features/Contacts/Specifications/ContactByIdSpecification.cs @@ -5,8 +5,8 @@ // See the LICENSE file in the project root for more information. // // Author: neozhu -// Created Date: 2025-03-13 -// Last Modified: 2025-03-13 +// Created Date: 2025-03-19 +// Last Modified: 2025-03-19 // Description: // Defines a specification for filtering a Contact entity by its ID. // diff --git a/src/Application/Features/SystemLogs/Queries/PaginationQuery/SystemLogsWithPaginationQuery.cs b/src/Application/Features/SystemLogs/Queries/PaginationQuery/SystemLogsWithPaginationQuery.cs index c6e61c7a8..7506fdefb 100644 --- a/src/Application/Features/SystemLogs/Queries/PaginationQuery/SystemLogsWithPaginationQuery.cs +++ b/src/Application/Features/SystemLogs/Queries/PaginationQuery/SystemLogsWithPaginationQuery.cs @@ -18,7 +18,7 @@ public class SystemLogsWithPaginationQuery : SystemLogAdvancedFilter, ICacheable public override string ToString() { return - $"Listview:{ListView}-{LocalTimeOffset.TotalHours},{Level},Search:{Keyword},OrderBy:{OrderBy} {SortDirection},{PageNumber},{PageSize}"; + $"Listview:{ListView},{Level},Search:{Keyword},OrderBy:{OrderBy} {SortDirection},{PageNumber},{PageSize}"; } } diff --git a/src/Application/Features/SystemLogs/Specifications/SystemLogAdvancedFilter.cs b/src/Application/Features/SystemLogs/Specifications/SystemLogAdvancedFilter.cs index 069bab5a2..6b9363369 100644 --- a/src/Application/Features/SystemLogs/Specifications/SystemLogAdvancedFilter.cs +++ b/src/Application/Features/SystemLogs/Specifications/SystemLogAdvancedFilter.cs @@ -12,7 +12,7 @@ public enum SystemLogListView public class SystemLogAdvancedFilter : PaginationFilter { - public TimeSpan LocalTimeOffset { get; set; } + public UserProfile? CurrentUser { get; set; } public LogLevel? Level { get; set; } public SystemLogListView ListView { get; set; } = SystemLogListView.LAST_30_DAYS; } \ No newline at end of file diff --git a/src/Application/Features/SystemLogs/Specifications/SystemLogAdvancedSpecification.cs b/src/Application/Features/SystemLogs/Specifications/SystemLogAdvancedSpecification.cs index 28ebcfbf4..44841ce82 100644 --- a/src/Application/Features/SystemLogs/Specifications/SystemLogAdvancedSpecification.cs +++ b/src/Application/Features/SystemLogs/Specifications/SystemLogAdvancedSpecification.cs @@ -7,8 +7,8 @@ public class SystemLogAdvancedSpecification : Specification public SystemLogAdvancedSpecification(SystemLogsWithPaginationQuery filter) { DateTime today = DateTime.UtcNow; - var todayrange = today.GetDateRange("TODAY", filter.LocalTimeOffset); - var last30daysrange = today.GetDateRange("LAST_30_DAYS", filter.LocalTimeOffset); + var todayrange = today.GetDateRange("TODAY", filter.CurrentUser.LocalTimeOffset); + var last30daysrange = today.GetDateRange("LAST_30_DAYS", filter.CurrentUser.LocalTimeOffset); // Build query conditions Query.Where(p => p.TimeStamp >= todayrange.Start && p.TimeStamp < todayrange.End.AddDays(1), filter.ListView == SystemLogListView.TODAY) diff --git a/src/Domain/Identity/ApplicationUser.cs b/src/Domain/Identity/ApplicationUser.cs index 54ac0be1f..ae33092a4 100644 --- a/src/Domain/Identity/ApplicationUser.cs +++ b/src/Domain/Identity/ApplicationUser.cs @@ -5,7 +5,7 @@ namespace CleanArchitecture.Blazor.Domain.Identity; -public class ApplicationUser : IdentityUser, IAuditableEntity +public class ApplicationUser : IdentityUser { public ApplicationUser() { diff --git a/src/Infrastructure/Services/FileUploadService.cs b/src/Infrastructure/Services/FileUploadService.cs index b4fb2dd5e..f6a3b8692 100644 --- a/src/Infrastructure/Services/FileUploadService.cs +++ b/src/Infrastructure/Services/FileUploadService.cs @@ -2,10 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using CleanArchitecture.Blazor.Application.Common.Extensions; -using CleanArchitecture.Blazor.Domain.Common.Enums; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Png; -using SixLabors.ImageSharp.Processing; namespace CleanArchitecture.Blazor.Infrastructure.Services; @@ -24,17 +20,6 @@ public class FileUploadService : IUploadService public async Task UploadAsync(UploadRequest request) { if (request.Data == null || !request.Data.Any()) return string.Empty; - - if (request.ResizeOptions != null) - { - using var inputStream = new MemoryStream(request.Data); - using var outputStream = new MemoryStream(); - using var image = Image.Load(inputStream); - image.Mutate(i => i.Resize(request.ResizeOptions)); - image.Save(outputStream, PngFormat.Instance); - request.Data = outputStream.ToArray(); - } - var folder = request.UploadType.GetDescription(); var folderName = Path.Combine("Files", folder); if (!string.IsNullOrEmpty(request.Folder)) diff --git a/src/Infrastructure/Services/MediatorWrapper/ScopedMediator.cs b/src/Infrastructure/Services/MediatorWrapper/ScopedMediator.cs index 51ce7fd06..ac4c0e491 100644 --- a/src/Infrastructure/Services/MediatorWrapper/ScopedMediator.cs +++ b/src/Infrastructure/Services/MediatorWrapper/ScopedMediator.cs @@ -11,122 +11,49 @@ public class ScopedMediator : IScopedMediator { private readonly IServiceScopeFactory _scopeFactory; - /// - /// Initializes a new instance of the class. - /// - /// The service scope factory. - public ScopedMediator(IServiceScopeFactory scopeFactory) - { + public ScopedMediator(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory; - } - /// - public async Task Send( - IRequest request, - CancellationToken cancellationToken = default) - { - AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - IMediator mediator = scope.ServiceProvider.GetRequiredService(); + public Task Send(IRequest request, CancellationToken cancellationToken = default) => + ExecuteWithinScope(mediator => mediator.Send(request, cancellationToken)); - TResponse response = await mediator.Send(request, cancellationToken); + public Task Send(TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest => + ExecuteWithinScope(mediator => mediator.Send(request, cancellationToken)); - return response; - } - } + public Task Send(object request, CancellationToken cancellationToken = default) => + ExecuteWithinScope(mediator => mediator.Send(request, cancellationToken)); - /// - public async Task Send(TRequest request, CancellationToken cancellationToken = default) - where TRequest : IRequest - { - AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - IMediator mediator = scope.ServiceProvider.GetRequiredService(); + public Task Publish(TNotification notification, CancellationToken cancellationToken = default) where TNotification : INotification => + ExecuteWithinScope(mediator => mediator.Publish(notification, cancellationToken)); - await mediator.Send(request, cancellationToken); - } - } + public Task Publish(object notification, CancellationToken cancellationToken = default) => + ExecuteWithinScope(mediator => mediator.Publish(notification, cancellationToken)); - /// - public async Task Send(object request, CancellationToken cancellationToken = default) + public async IAsyncEnumerable CreateStream(IStreamRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - IMediator mediator = scope.ServiceProvider.GetRequiredService(); - - object? response = await mediator.Send(request, cancellationToken); - - return response; - } + using var scope = _scopeFactory.CreateAsyncScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + await foreach (var item in mediator.CreateStream(request, cancellationToken)) + yield return item; } - /// - public async Task Publish( - TNotification notification, - CancellationToken cancellationToken = default) - where TNotification : INotification + public async IAsyncEnumerable CreateStream(object request, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - IMediator mediator = scope.ServiceProvider.GetRequiredService(); - - await mediator.Publish(notification, cancellationToken); - } + using var scope = _scopeFactory.CreateAsyncScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + await foreach (var item in mediator.CreateStream(request, cancellationToken)) + yield return item; } - /// - public async Task Publish(object notification, CancellationToken cancellationToken = default) + private async Task ExecuteWithinScope(Func> operation) { - AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - IMediator mediator = scope.ServiceProvider.GetRequiredService(); - - await mediator.Publish(notification, cancellationToken); - } - } - - /// - public async IAsyncEnumerable CreateStream( - IStreamRequest request, - [EnumeratorCancellation] - CancellationToken cancellationToken = default) - { - AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - IMediator mediator = scope.ServiceProvider.GetRequiredService(); - - IAsyncEnumerable items = mediator.CreateStream(request, cancellationToken); - - await foreach (TResponse item in items) - { - yield return item; - } - } + using var scope = _scopeFactory.CreateAsyncScope(); + return await operation(scope.ServiceProvider.GetRequiredService()); } - /// - public async IAsyncEnumerable CreateStream( - object request, - [EnumeratorCancellation] - CancellationToken cancellationToken = default) + private Task ExecuteWithinScope(Func operation) { - AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - await using (scope.ConfigureAwait(false)) - { - IMediator mediator = scope.ServiceProvider.GetRequiredService(); - - IAsyncEnumerable items = mediator.CreateStream(request, cancellationToken); - - await foreach (object? item in items) - { - yield return item; - } - } + using var scope = _scopeFactory.CreateAsyncScope(); + return operation(scope.ServiceProvider.GetRequiredService()); } -} +} \ No newline at end of file diff --git a/src/Infrastructure/Services/MinioUploadService.cs b/src/Infrastructure/Services/MinioUploadService.cs index 89a8f6e5c..50c224cbf 100644 --- a/src/Infrastructure/Services/MinioUploadService.cs +++ b/src/Infrastructure/Services/MinioUploadService.cs @@ -3,10 +3,6 @@ using Microsoft.AspNetCore.StaticFiles; using Minio; using Minio.DataModel.Args; -using Minio.Exceptions; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Png; -using SixLabors.ImageSharp.Processing; namespace CleanArchitecture.Blazor.Infrastructure.Services; public class MinioUploadService : IUploadService @@ -39,19 +35,6 @@ public async Task UploadAsync(UploadRequest request) var bitmapImageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; var ext = Path.GetExtension(request.FileName).ToLowerInvariant(); - // If ResizeOptions is provided and the file is a bitmap image, process the image. - if (request.ResizeOptions != null && Array.Exists(bitmapImageExtensions, e => e.Equals(ext, StringComparison.OrdinalIgnoreCase))) - { - using var inputStream = new MemoryStream(request.Data); - using var outputStream = new MemoryStream(); - using var image = Image.Load(inputStream); - image.Mutate(x => x.Resize(request.ResizeOptions)); - // Convert the image to PNG format. - image.Save(outputStream, new PngEncoder()); - request.Data = outputStream.ToArray(); - contentType = "image/png"; - } - // Ensure the bucket exists. bool bucketExists = await _minioClient.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucketName)); if (!bucketExists) diff --git a/src/Server.UI/Components/Autocompletes/TimeZoneAutocomplete.cs b/src/Server.UI/Components/Autocompletes/TimeZoneAutocomplete.cs index 340d80562..0f576c5c4 100644 --- a/src/Server.UI/Components/Autocompletes/TimeZoneAutocomplete.cs +++ b/src/Server.UI/Components/Autocompletes/TimeZoneAutocomplete.cs @@ -7,6 +7,7 @@ public TimeZoneAutocomplete() SearchFunc = SearchFunc_; Dense = true; ResetValueOnEmptyText = true; + MaxItems = 80; ToStringFunc = x => { var timeZone = TimeZones.FirstOrDefault(tz => tz.Id.Equals(x)); diff --git a/src/Server.UI/Pages/Contacts/Components/ContactFormDialog.razor b/src/Server.UI/Pages/Contacts/Components/ContactFormDialog.razor index e24ce718d..afc5db3ce 100644 --- a/src/Server.UI/Pages/Contacts/Components/ContactFormDialog.razor +++ b/src/Server.UI/Pages/Contacts/Components/ContactFormDialog.razor @@ -6,23 +6,23 @@ - + @*TODO: define mudform that should be edit fields, for example:*@ - + - + - + - + - + @@ -30,37 +30,34 @@ @ConstantString.Cancel - @ConstantString.SaveAndNew - @ConstantString.Save + @ConstantString.SaveAndNew + @ConstantString.Save @code { - MudForm? _form; + MudForm? _contactForm; private bool _saving = false; private bool _savingnew = false; [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; - AddEditContactCommandValidator _modelValidator = new (); - [EditorRequired] [Parameter] public AddEditContactCommand model { get; set; } = null!; - async Task Submit() + [EditorRequired] [Parameter] public AddEditContactCommand _model { get; set; } = null!; + async Task OnSubmit() { try { _saving = true; - await _form!.Validate().ConfigureAwait(false); - if (!_form!.IsValid) + await _contactForm!.Validate().ConfigureAwait(false); + if (!_contactForm!.IsValid) return; - var result = await Mediator.Send(model); + var result = await Mediator.Send(_model); result.Match(data => { MudDialog.Close(DialogResult.Ok(true)); Snackbar.Add(ConstantString.SaveSuccess, MudBlazor.Severity.Info); - return data; }, errors => { Snackbar.Add(errors, MudBlazor.Severity.Error); - return -1; }); } finally @@ -68,25 +65,24 @@ _saving = false; } } - async Task SaveAndNew() + async Task OnSaveAndNew() { try { _savingnew = true; - await _form!.Validate().ConfigureAwait(false); - if (!_form!.IsValid) + await _contactForm!.Validate().ConfigureAwait(false); + if (!_contactForm!.IsValid) return; - var result = await Mediator.Send(model); + var result = await Mediator.Send(_model); await result.MatchAsync(async data => { Snackbar.Add(ConstantString.SaveSuccess, MudBlazor.Severity.Info); await Task.Delay(300); - model = new AddEditContactCommand() { }; - return data; + _model = new AddEditContactCommand() { }; }, errors => { Snackbar.Add(errors, MudBlazor.Severity.Error); - return Task.FromResult(-1); + return Task.CompletedTask; }); } finally diff --git a/src/Server.UI/Pages/Contacts/Components/ContactsAdvancedSearchComponent.razor b/src/Server.UI/Pages/Contacts/Components/ContactsAdvancedSearchComponent.razor index c1eafd6d9..607cbc826 100644 --- a/src/Server.UI/Pages/Contacts/Components/ContactsAdvancedSearchComponent.razor +++ b/src/Server.UI/Pages/Contacts/Components/ContactsAdvancedSearchComponent.razor @@ -2,12 +2,11 @@ @inject IStringLocalizer L + Class="pa-2 mb-3" Text="@ConstantString.AdvancedSearch"> - @*TODO: define advanced search query fields, for example:*@ - @* + @* @@ -17,7 +16,7 @@ @code { - [EditorRequired][Parameter] public ContactsWithPaginationQuery TRequest { get; set; } = null!; + [EditorRequired][Parameter] public ContactsWithPaginationQuery ContactsQuery { get; set; } = null!; [EditorRequired][Parameter] public EventCallback OnConditionChanged { get; set; } private bool _advancedSearchExpanded; private async Task TextChanged(string str) diff --git a/src/Server.UI/Pages/Contacts/Contacts.razor b/src/Server.UI/Pages/Contacts/Contacts.razor index 80816efe3..991827dbc 100644 --- a/src/Server.UI/Pages/Contacts/Contacts.razor +++ b/src/Server.UI/Pages/Contacts/Contacts.razor @@ -1,4 +1,4 @@ -@page "/pages/Contacts" +@page "/pages/contacts" @using CleanArchitecture.Blazor.Application.Features.Contacts.Caching @using CleanArchitecture.Blazor.Application.Features.Contacts.DTOs @@ -18,24 +18,23 @@ + RowClick="@(s=>OnDataGridRowClick(s.Item))" + @bind-SelectedItems="_selectedContacts" + Hover="true" @ref="_contactsGrid"> @Title - + @@ -56,12 +55,12 @@ @if (_accessRights.Create) { - @ConstantString.Clone + @ConstantString.Clone } @if (_accessRights.Delete) { - + @ConstantString.Delete } @@ -91,7 +90,7 @@ @if (_accessRights.Search) { - } @@ -110,11 +109,11 @@ EndIcon="@Icons.Material.Filled.KeyboardArrowDown" IconColor="Color.Info" AnchorOrigin="Origin.CenterLeft"> @if (_accessRights.Edit) { - @ConstantString.Edit + @ConstantString.Edit } @if (_accessRights.Delete) { - @ConstantString.Delete + @ConstantString.Delete } } @@ -131,7 +130,7 @@ @*TODO: Define the fields that should be displayed in data table*@ - +
@context.Item.Name @@ -139,9 +138,9 @@
- - - + + + @@ -160,9 +159,9 @@ @code { public string? Title { get; private set; } private int _defaultPageSize = 15; - private HashSet _selectedItems = new HashSet(); - private MudDataGrid _table = default!; - private ContactDto _currentDto = new(); + private HashSet _selectedContacts = new HashSet(); + private MudDataGrid _contactsGrid = default!; + private ContactDto _contactDto = new(); private bool _loading; private bool _uploading; private bool _exporting; @@ -170,15 +169,14 @@ private Task AuthState { get; set; } = default!; [CascadingParameter] private UserProfile? UserProfile { get; set; } - [CascadingParameter(Name = "LocalTimezoneOffset")] - private TimeSpan _localTimezoneOffset { get; set; } - private ContactsWithPaginationQuery Query { get; set; } = new(); + + private ContactsWithPaginationQuery _contactsQuery { get; set; } = new(); private ContactsAccessRights _accessRights = new(); protected override async Task OnInitializedAsync() { - Title = L[_currentDto.GetClassDescription()]; + Title = L[_contactDto.GetClassDescription()]; _accessRights = await PermissionService.GetAccessRightsAsync(); } @@ -187,16 +185,15 @@ try { _loading = true; - Query.CurrentUser = UserProfile; + _contactsQuery.CurrentUser = UserProfile; var sortDefinition = state.SortDefinitions.FirstOrDefault(); - Query.OrderBy = sortDefinition?.SortBy ?? "Id"; - Query.SortDirection = (sortDefinition != null && sortDefinition.Descending) + _contactsQuery.OrderBy = sortDefinition?.SortBy ?? "Id"; + _contactsQuery.SortDirection = (sortDefinition != null && sortDefinition.Descending) ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); - Query.PageNumber = state.Page + 1; - Query.PageSize = state.PageSize; - Query.LocalTimezoneOffset = _localTimezoneOffset; - var result = await Mediator.Send(Query).ConfigureAwait(false); + _contactsQuery.PageNumber = state.Page + 1; + _contactsQuery.PageSize = state.PageSize; + var result = await Mediator.Send(_contactsQuery).ConfigureAwait(false); return new GridData() { TotalItems = result.TotalItems, Items = result.Items }; } finally @@ -207,50 +204,50 @@ } private async Task OnSearch(string text) { - _selectedItems.Clear(); - Query.Keyword = text; - await _table.ReloadServerData(); + _selectedContacts.Clear(); + _contactsQuery.Keyword = text; + await _contactsGrid.ReloadServerData(); } - private async Task OnChangedListView(ContactListView listview) + private async Task OnListViewChanged(ContactListView listview) { - Query.ListView = listview; - await _table.ReloadServerData(); + _contactsQuery.ListView = listview; + await _contactsGrid.ReloadServerData(); } private async Task OnRefresh() { ContactCacheKey.Refresh(); - _selectedItems.Clear(); - Query.Keyword = string.Empty; - await _table.ReloadServerData(); + _selectedContacts.Clear(); + _contactsQuery.Keyword = string.Empty; + await _contactsGrid.ReloadServerData(); } private async Task ShowEditFormDialog(string title, AddEditContactCommand command) { var parameters = new DialogParameters { - { x=>x.model,command }, + { x=>x._model,command }, }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; - var dialog = DialogService.Show(title, parameters, options); + var dialog = await DialogService.ShowAsync(title, parameters, options); var state = await dialog.Result; if (state != null && !state.Canceled) { - await _table.ReloadServerData(); - _selectedItems.Clear(); + await _contactsGrid.ReloadServerData(); + _selectedContacts.Clear(); } } - private void OnView(ContactDto dto) + private void OnDataGridRowClick(ContactDto dto) { - Navigation.NavigateTo($"/pages/Contacts/view/{dto.Id}"); + Navigation.NavigateTo($"/pages/contacts/view/{dto.Id}"); } private async Task OnCreate() { var command = new AddEditContactCommand(); - await ShowEditFormDialog(string.Format(ConstantString.CreateAnItem, L["Contact"]), command); + await ShowEditFormDialog(L["New Contact"], command); } - private async Task OnClone() + private async Task OnCloneContact() { - var dto = _selectedItems.First(); + var dto = _selectedContacts.First(); var command = new AddEditContactCommand() { Name = dto.Name, @@ -260,14 +257,16 @@ Country = dto.Country, }; - await ShowEditFormDialog(string.Format(ConstantString.CreateAnItem, L["Contact"]), command); + await ShowEditFormDialog(L["Clone Contact"], command); } - private async Task OnEdit(ContactDto dto) + private async Task OnEditContact(ContactDto dto) { - Navigation.NavigateTo($"/pages/Contacts/edit/{dto.Id}"); + //var command = Mapper.Map(dto); + //await ShowEditFormDialog(L["Edit Contact"], command); + Navigation.NavigateTo($"/pages/contacts/edit/{dto.Id}"); } - private async Task OnDelete(ContactDto dto) + private async Task OnDeleteContact(ContactDto dto) { var contentText = string.Format(ConstantString.DeleteConfirmation, dto.Name); var command = new DeleteContactCommand(new int[] { dto.Id }); @@ -275,22 +274,22 @@ { await InvokeAsync(async () => { - await _table.ReloadServerData(); - _selectedItems.Clear(); + await _contactsGrid.ReloadServerData(); + _selectedContacts.Clear(); }); }); } - private async Task OnDeleteChecked() + private async Task OnDeleteSelectedContacts() { - var contentText = string.Format(ConstantString.DeleteConfirmWithSelected, _selectedItems.Count); - var command = new DeleteContactCommand(_selectedItems.Select(x => x.Id).ToArray()); + var contentText = string.Format(ConstantString.DeleteConfirmWithSelected, _selectedContacts.Count); + var command = new DeleteContactCommand(_selectedContacts.Select(x => x.Id).ToArray()); await DialogServiceHelper.ShowDeleteConfirmationDialogAsync(command, ConstantString.DeleteConfirmationTitle, contentText,async () => { await InvokeAsync(async () => { - await _table.ReloadServerData(); - _selectedItems.Clear(); + await _contactsGrid.ReloadServerData(); + _selectedContacts.Clear(); }); }); } @@ -300,24 +299,23 @@ _exporting = true; var request = new ExportContactsQuery() { - Keyword = Query.Keyword, + Keyword = _contactsQuery.Keyword, CurrentUser = UserProfile, - ListView = Query.ListView, - OrderBy = _table.SortDefinitions.Values.FirstOrDefault()?.SortBy ?? "Id", - SortDirection = (_table.SortDefinitions.Values.FirstOrDefault()?.Descending ?? true) ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString() + ListView = _contactsQuery.ListView, + OrderBy = _contactsGrid.SortDefinitions.Values.FirstOrDefault()?.SortBy ?? "Id", + SortDirection = (_contactsGrid.SortDefinitions.Values.FirstOrDefault()?.Descending ?? true) ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString() }; var result = await Mediator.Send(request); - await result.MatchAsync( + await result.MatchAsync( async data => { await BlazorDownloadFileService.DownloadFileAsync($"{L["Contacts"]}.xlsx", result.Data, contentType:"application/octet-stream"); Snackbar.Add($"{ConstantString.ExportSuccess}", MudBlazor.Severity.Info); - return data; }, errors => { Snackbar.Add($"{errors}", MudBlazor.Severity.Error); - return Task.FromResult(Array.Empty()); + return Task.CompletedTask; }); _exporting = false; } @@ -331,13 +329,12 @@ await result.MatchAsync( async data => { - await _table.ReloadServerData(); + await _contactsGrid.ReloadServerData(); Snackbar.Add($"{ConstantString.ImportSuccess}", MudBlazor.Severity.Info); - return data; }, errors => { Snackbar.Add($"{errors}", MudBlazor.Severity.Error); - return Task.FromResult(0); + return Task.CompletedTask; }); _uploading = false; } diff --git a/src/Server.UI/Pages/Contacts/CreateContact.razor b/src/Server.UI/Pages/Contacts/CreateContact.razor index 84c6a42d0..89d113f24 100644 --- a/src/Server.UI/Pages/Contacts/CreateContact.razor +++ b/src/Server.UI/Pages/Contacts/CreateContact.razor @@ -1,4 +1,4 @@ -@page "/pages/Contacts/create" +@page "/pages/contacts/create" @using CleanArchitecture.Blazor.Application.Features.Contacts.Commands.Create @inherits MudComponentBase @@ -7,8 +7,8 @@ @attribute [Authorize(Policy = Permissions.Contacts.Create)] @Title - - + + @@ -16,29 +16,29 @@ - + - + - + - + - + - + - @ConstantString.Save + @ConstantString.Save @@ -46,40 +46,38 @@ @code { public string? Title { get; private set; } - MudForm? _form; + MudForm? _contactForm; private bool _saving = false; private List _breadcrumbItems = new List { new BreadcrumbItem("Home", href: "/"), - new BreadcrumbItem("Contacts", href: "/pages/Contacts"), + new BreadcrumbItem("Contacts", href: "/pages/contacts"), new BreadcrumbItem("Create Contact", href:null, disabled:true) }; - private CreateContactCommand model = new(); + private CreateContactCommand _model = new(); protected override Task OnInitializedAsync() { Title = L["Create Contact"]; return Task.CompletedTask; } - async Task Submit() + async Task OnSubmit() { try { _saving = true; - await _form!.Validate().ConfigureAwait(false); - if (!_form!.IsValid) + await _contactForm!.Validate().ConfigureAwait(false); + if (!_contactForm!.IsValid) return; - var result = await Mediator.Send(model); + var result = await Mediator.Send(_model); result.Match( data=> { Snackbar.Add(ConstantString.SaveSuccess, MudBlazor.Severity.Info); Navigation.NavigateTo($"/pages/Contacts"); - return data; }, errors=> { Snackbar.Add(errors, MudBlazor.Severity.Error); - return -1; }); } finally diff --git a/src/Server.UI/Pages/Contacts/EditContact.razor b/src/Server.UI/Pages/Contacts/EditContact.razor index 34d03b75d..a5d9e0d25 100644 --- a/src/Server.UI/Pages/Contacts/EditContact.razor +++ b/src/Server.UI/Pages/Contacts/EditContact.razor @@ -1,4 +1,4 @@ -@page "/pages/Contacts/edit/{id:int}" +@page "/pages/contacts/edit/{id:int}" @using CleanArchitecture.Blazor.Application.Features.Contacts.Commands.Update @using CleanArchitecture.Blazor.Application.Features.Contacts.Queries.GetById @using CleanArchitecture.Blazor.Server.UI.Components.Fusion @@ -9,9 +9,9 @@ @attribute [Authorize(Policy = Permissions.Contacts.Edit)] @Title - - -@if (model != null) + + +@if (_model != null) { @@ -20,30 +20,30 @@ - - + + - + - + - + - + - + - @ConstantString.Save + @ConstantString.Save } @@ -54,52 +54,48 @@ public string? Title { get; private set; } [Parameter] public int Id { get; set; } - MudForm? _form; + MudForm? _contactForm; private bool _saving = false; private List _breadcrumbItems = new List { new BreadcrumbItem("Home", href: "/"), - new BreadcrumbItem("Contacts", href: "/pages/Contacts") + new BreadcrumbItem("Contacts", href: "/pages/contacts") }; - private UpdateContactCommand? model; + private UpdateContactCommand? _model; protected override async Task OnInitializedAsync() { Title = L["Edit Contact"]; var result = await Mediator.Send(new GetContactByIdQuery() { Id = Id }); result.Map(data => { - model = Mapper.Map(data); + _model = Mapper.Map(data); return data; }).Match(data => { - _breadcrumbItems.Add(new BreadcrumbItem(data.Name, href: $"/pages/Contacts/edit/{Id}")); - return data; + _breadcrumbItems.Add(new BreadcrumbItem(data.Name, href: $"/pages/contacts/edit/{Id}")); }, errors => { Snackbar.Add($"{errors}", Severity.Error); - return null!; }); } - async Task Submit() + async Task OnSubmit() { try { _saving = true; - await _form!.Validate().ConfigureAwait(false); - if (!_form!.IsValid) + await _contactForm!.Validate().ConfigureAwait(false); + if (!_contactForm!.IsValid) return; - var result = await Mediator.Send(model); + var result = await Mediator.Send(_model); result.Match( data=> { Snackbar.Add(ConstantString.SaveSuccess, MudBlazor.Severity.Info); - return data; }, errors=> { Snackbar.Add(errors, MudBlazor.Severity.Error); - return 0; }); } finally diff --git a/src/Server.UI/Pages/Contacts/ViewContact.razor b/src/Server.UI/Pages/Contacts/ViewContact.razor index 55acad593..7c188b419 100644 --- a/src/Server.UI/Pages/Contacts/ViewContact.razor +++ b/src/Server.UI/Pages/Contacts/ViewContact.razor @@ -1,4 +1,4 @@ -@page "/pages/Contacts/view/{id:int}" +@page "/pages/contacts/view/{id:int}" @using CleanArchitecture.Blazor.Application.Features.Contacts.Commands.Delete @using CleanArchitecture.Blazor.Application.Features.Contacts.DTOs @using CleanArchitecture.Blazor.Application.Features.Contacts.Queries.GetById @@ -7,8 +7,8 @@ @attribute [Authorize(Policy = Permissions.Contacts.View)] @Title - -@if (model != null) + +@if (_model != null) { @@ -19,19 +19,19 @@ - + - + - + - + - + @@ -49,26 +49,23 @@ private List _breadcrumbItems = new List { new BreadcrumbItem("Home", href: "/"), - new BreadcrumbItem("Contacts", href: "/pages/Contacts") + new BreadcrumbItem("Contacts", href: "/pages/contacts") }; - private ContactDto? model; + private ContactDto? _model; protected override async Task OnInitializedAsync() { Title = L["Contact"]; var result = await Mediator.Send(new GetContactByIdQuery() { Id = Id }); result.Map(data => { - model = data; + _model = data; return data; }).Match(data => { _breadcrumbItems.Add(new BreadcrumbItem(data.Name, null, disabled: true)); - return data; - }, errors => { Snackbar.Add(errors, MudBlazor.Severity.Error); - return null!; }); } @@ -78,8 +75,8 @@ } async Task Delete() { - var contentText = string.Format(ConstantString.DeleteConfirmation, model.Name); - var command = new DeleteContactCommand(new int[] { model.Id }); + var contentText = string.Format(ConstantString.DeleteConfirmation, _model.Name); + var command = new DeleteContactCommand(new int[] { _model.Id }); await DialogServiceHelper.ShowDeleteConfirmationDialogAsync(command, ConstantString.DeleteConfirmationTitle, contentText, async () => { await InvokeAsync(() => diff --git a/src/Server.UI/Pages/Documents/Documents.razor b/src/Server.UI/Pages/Documents/Documents.razor index 9f69a14c7..09f9c0dc0 100644 --- a/src/Server.UI/Pages/Documents/Documents.razor +++ b/src/Server.UI/Pages/Documents/Documents.razor @@ -12,166 +12,193 @@ @using CleanArchitecture.Blazor.Application.Features.Documents.Commands.Delete @implements IDisposable -@inject BlazorDownloadFileService BlazorDownloadFileService -@inject HubClient Client -@inject IStringLocalizer L +@inject BlazorDownloadFileService _downloadFileService +@inject HubClient _hubClient +@inject IStringLocalizer _localizer -@Title +@_title - + Hover="true"> + - @L[_currentDto.GetClassDescription()] - + + @_localizer[_currentDto.GetClassDescription()] + + + - + @ConstantString.Refresh @if (_accessRights.Create) { - - @L["Upload Pictures"] + + @_localizer["Upload Pictures"] } @if (_accessRights.Delete) { - + @ConstantString.Delete } - - @if (_accessRights.Search) - { - - - } - + @if (_accessRights.Search) + { + + + } - - - + @if (_accessRights.Edit || _accessRights.Delete) { + Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" + IconColor="Color.Info" AnchorOrigin="Origin.CenterLeft"> @if (_accessRights.Edit) { - @ConstantString.Edit + + @ConstantString.Edit + } @if (_accessRights.Delete) { - @ConstantString.Delete + + @ConstantString.Delete + } @if (_accessRights.Download) { - @ConstantString.Download + + @ConstantString.Download + } } else { + StartIcon="@Icons.Material.Filled.DoNotTouch" + IconColor="Color.Secondary" Size="Size.Small" + Color="Color.Surface"> @ConstantString.NoAllowed } - +
- @context.Item.Title - @context.Item.Description + @context.Item.Title + + @context.Item.Description +
- +
-
+
@switch (context.Item.Status) { case JobStatus.Queueing: - @context.Item.Status + + @context.Item.Status + break; case JobStatus.Doing: - @context.Item.Status + + @context.Item.Status + break; case JobStatus.Done: - @context.Item.Status + + @context.Item.Status + break; default: - @context.Item.Status + + @context.Item.Status + break; }
-
- +
+
- +
- - @context.Item.DocumentType + + + @context.Item.DocumentType +
- + @if (UserProfile?.UserId == context.Item.CreatedByUser?.Id) { - Me + Me } else {
@context.Item.CreatedByUser?.DisplayName - @context.Item.CreatedByUser?.Email + + @context.Item.CreatedByUser?.Email +
}
- + -
- @context.Item.TenantName -
+ @context.Item.TenantName
@@ -186,78 +213,93 @@ - @code { - [CascadingParameter] protected Task AuthState { get; set; } = default!; - [CascadingParameter] private UserProfile? UserProfile { get; set; } - private string? Title { get; set; } + #region Fields and Properties - private HashSet _selectedItems = new(); + [CascadingParameter] protected Task _authState { get; set; } = default!; + [CascadingParameter] private UserProfile? UserProfile { get; set; } + private string? _title { get; set; } + private HashSet _selectedDocuments = new(); private readonly DocumentDto _currentDto = new(); - private MudDataGrid _table = null!; + private MudDataGrid _dataGrid = null!; private int _defaultPageSize = 15; private string _searchString = string.Empty; - private bool _downloading; - private bool _loading; + private bool _isDownloading; + private bool _isLoading; + private DocumentsWithPaginationQuery Query { get; set; } = new(); + private DocumentsAccessRights _accessRights = new(); - private string _tenantId = string.Empty; - private DocumentsWithPaginationQuery Query { get; set; } = default!; + #endregion - private DocumentsAccessRights _accessRights = new(); + #region Lifecycle Methods protected override async Task OnInitializedAsync() { - - Title = L[_currentDto.GetClassDescription()]; - _accessRights =await PermissionService.GetAccessRightsAsync(); - Client.JobCompletedEvent += _client_JobCompleted; - Client.JobStartedEvent += _client_JobStarted; - await Client.StartAsync(); + _title = _localizer[_currentDto.GetClassDescription()]; + _accessRights = await PermissionService.GetAccessRightsAsync(); + + // Subscribe to hub events + _hubClient.JobCompletedEvent += OnJobCompleted; + _hubClient.JobStartedEvent += OnJobStarted; + await _hubClient.StartAsync(); + Query = new DocumentsWithPaginationQuery { CurrentUser = UserProfile }; } - - private void _client_JobCompleted(object? sender, JobCompletedEventArgs e) + + public void Dispose() + { + // Unsubscribe from hub events + _hubClient.JobCompletedEvent -= OnJobCompleted; + _hubClient.JobStartedEvent -= OnJobStarted; + } + + #endregion + + #region Hub Event Handlers + + private void OnJobCompleted(object? sender, JobCompletedEventArgs e) { + // Refresh grid data and display a notification based on job result. InvokeAsync(async () => { - await _table.ReloadServerData(); + await _dataGrid.ReloadServerData(); if (e.Message.StartsWith("Error")) { - Snackbar.Add(string.Format(L["{0}"], e.Id), Severity.Error); + Snackbar.Add(string.Format(_localizer["{0}"], e.Id), Severity.Error); } else { - Snackbar.Add(string.Format(L["{0}: {1} recognize completed."], e.Id, e.Message), Severity.Success); + Snackbar.Add(string.Format(_localizer["{0}: {1} completed."], e.Id, e.Message), Severity.Success); } - }); } - private void _client_JobStarted(object? sender, JobStartedEventArgs e) + private void OnJobStarted(object? sender, JobStartedEventArgs e) { + // Refresh grid data and notify job start. InvokeAsync(async () => { - await _table.ReloadServerData(); - Snackbar.Add(string.Format(L["{0}: {1} job started."], e.Id, e.Message), Severity.Info); + await _dataGrid.ReloadServerData(); + Snackbar.Add(string.Format(_localizer["{0}: {1} job started."], e.Id, e.Message), Severity.Info); }); } + #endregion - public void Dispose() - { - Client.JobCompletedEvent -= _client_JobCompleted; - Client.JobStartedEvent -= _client_JobStarted; - } + #region Grid and Search private async Task> ServerReload(GridState state) { + _isLoading = true; try { - _loading = true; Query.Keyword = _searchString; Query.CurrentUser = UserProfile; - Query.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; - Query.SortDirection = state.SortDefinitions.FirstOrDefault() == null ? SortDirection.Descending.ToString() : state.SortDefinitions.First().Descending ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); + var sortDef = state.SortDefinitions.FirstOrDefault(); + Query.OrderBy = sortDef?.SortBy ?? "Id"; + Query.SortDirection = sortDef == null + ? SortDirection.Descending.ToString() + : sortDef.Descending ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); Query.PageNumber = state.Page + 1; Query.PageSize = state.PageSize; var result = await Mediator.Send(Query); @@ -265,36 +307,35 @@ } finally { - _loading = false; + _isLoading = false; } } - private void OnFilterChanged(string s) + private async Task OnChangedListView(DocumentListView listView) { - InvokeAsync(async () => { await _table.ReloadServerData(); }); - } - - private async Task OnChangedListView(DocumentListView listview) - { - Query.ListView = listview; - await _table.ReloadServerData(); + Query.ListView = listView; + await _dataGrid.ReloadServerData(); } private async Task OnSearch(string text) { DocumentCacheKey.Refresh(); - _selectedItems = new HashSet(); + _selectedDocuments = new HashSet(); _searchString = text; - await _table.ReloadServerData(); + await _dataGrid.ReloadServerData(); } private async Task OnRefresh() { - _selectedItems = new HashSet(); + _selectedDocuments = new HashSet(); _searchString = string.Empty; - await _table.ReloadServerData(); + await _dataGrid.ReloadServerData(); } + #endregion + + #region Create, Edit, Delete Documents + private async Task OnCreate() { var command = new AddEditDocumentCommand { DocumentType = DocumentType.Document }; @@ -303,12 +344,11 @@ { x => x.Model, command } }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }; - var dialog = await DialogService.ShowAsync - (L["Upload Pictures"], parameters, options); - var state = await dialog.Result; - if (state is not null && !state.Canceled) + var dialog = await DialogService.ShowAsync(_localizer["Upload Pictures"], parameters, options); + var result = await dialog.Result; + if (result is not null && !result.Canceled) { - await _table.ReloadServerData(); + await _dataGrid.ReloadServerData(); Snackbar.Add(ConstantString.UploadSuccess, Severity.Info); } } @@ -321,13 +361,13 @@ { nameof(DocumentFormDialog.Model), command } }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; - var dialog = await DialogService.ShowAsync(string.Format(ConstantString.EditTheItem, L["Document"]), parameters, options); - - - var state = await dialog.Result; - if (state is not null && !state.Canceled) + var dialog = await DialogService.ShowAsync( + string.Format(ConstantString.EditTheItem, _localizer["Document"]), + parameters, options); + var result = await dialog.Result; + if (result is not null && !result.Canceled) { - await _table.ReloadServerData(); + await _dataGrid.ReloadServerData(); } } @@ -335,50 +375,56 @@ { var command = new DeleteDocumentCommand(new[] { dto.Id }); var contentText = string.Format(ConstantString.DeleteConfirmation, dto.Title); - await DialogServiceHelper.ShowDeleteConfirmationDialogAsync(command, string.Format(ConstantString.DeleteTheItem, L["Document"]), contentText, async () => - { - await InvokeAsync(async () => - { - await _table.ReloadServerData(); - _selectedItems.Clear(); - }); - - }); + await DialogServiceHelper.ShowDeleteConfirmationDialogAsync( + command, + string.Format(ConstantString.DeleteTheItem, _localizer["Document"]), + contentText, + async () => + { + await _dataGrid.ReloadServerData(); + _selectedDocuments.Clear(); + }); } private async Task OnDeleteChecked() { - var command = new DeleteDocumentCommand(_selectedItems.Select(x => x.Id).ToArray()); - var contentText = string.Format(ConstantString.DeleteConfirmWithSelected, _selectedItems.Count); - await DialogServiceHelper.ShowDeleteConfirmationDialogAsync(command, string.Format(ConstantString.DeleteTheItem, L["Document"]), contentText, async () => - { - await InvokeAsync(async () => - { - await _table.ReloadServerData(); - _selectedItems.Clear(); - }); - }); + var command = new DeleteDocumentCommand(_selectedDocuments.Select(x => x.Id).ToArray()); + var contentText = string.Format(ConstantString.DeleteConfirmWithSelected, _selectedDocuments.Count); + await DialogServiceHelper.ShowDeleteConfirmationDialogAsync( + command, + string.Format(ConstantString.DeleteTheItem, _localizer["Document"]), + contentText, + async () => + { + await _dataGrid.ReloadServerData(); + _selectedDocuments.Clear(); + }); } + #endregion + + #region Download Document + private async Task OnDownload(DocumentDto dto) { try { - _downloading = true; + _isDownloading = true; var file = await Mediator.Send(new GetFileStreamQuery(dto.Id)); if (file.Item2 != null) { - await BlazorDownloadFileService.DownloadFileAsync(file.Item1, file.Item2, "application/octet-stream"); + await _downloadFileService.DownloadFileAsync(file.Item1, file.Item2, "application/octet-stream"); } } - catch (Exception e) + catch (Exception ex) { - Snackbar.Add($"{e.Message}", Severity.Error); + Snackbar.Add(ex.Message, Severity.Error); } finally { - _downloading = false; + _isDownloading = false; } } -} \ No newline at end of file + #endregion +} diff --git a/src/Server.UI/Pages/Identity/Roles/Roles.razor b/src/Server.UI/Pages/Identity/Roles/Roles.razor index 5c8be3cb5..a598e7e32 100644 --- a/src/Server.UI/Pages/Identity/Roles/Roles.razor +++ b/src/Server.UI/Pages/Identity/Roles/Roles.razor @@ -12,66 +12,63 @@ @attribute [Authorize(Policy = Permissions.Roles.View)] @inherits OwningComponentBase -@inject ITenantService TenantsService -@inject IRoleService RoleService -@inject IFusionCache FusionCache -@inject IStringLocalizer L +@inject ITenantService _tenantService +@inject IRoleService _roleService +@inject IFusionCache _fusionCache +@inject IStringLocalizer _localizer -@Title +@_title - + @bind-SelectedItems="_selectedRoles" + Loading="@_isLoading" + ServerData="ServerReload"> - + - @Title - - @L["ALL"] - @foreach (var tenant in TenantsService.DataSource) + @_title + + @_localizer["ALL"] + @foreach (var tenant in _tenantService.DataSource) { @tenant.Name } - + - + @ConstantString.Refresh @if (_accessRights.Create) { + OnClick="OnCreate"> @ConstantString.New } @if (_accessRights.Delete) { - + @ConstantString.Delete } @if (_accessRights.Export) { - + @ConstantString.Export } @@ -80,10 +77,8 @@ - + @ConstantString.Import @@ -94,9 +89,9 @@ @if (_accessRights.Search) { - + } @@ -109,8 +104,8 @@ @if (_accessRights.Edit || _accessRights.Delete || _accessRights.ManagePermissions) { + Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" + IconColor="Color.Info" AnchorOrigin="Origin.CenterLeft"> @if (_accessRights.Edit) { @ConstantString.Edit @@ -121,30 +116,29 @@ } @if (_accessRights.ManagePermissions) { - @L["Set Permissions"] + @_localizer["Set Permissions"] } } else { - + @ConstantString.NoAllowed } - + @context.Item.TenantName - @L["Selected"]: @_selectedItems.Count + @_localizer["Selected"]: @_selectedRoles.Count - - + +
@context.Item.Description @@ -158,63 +152,58 @@ + Waiting="@_isProcessing" + OnOpenChanged="OnPermissionsDrawerOpenChanged" + Open="_showPermissionsDrawer" + Permissions="_permissions" + OnAssignChanged="OnAssignChangedHandler"> @code { #region Fields and Properties - [CascadingParameter] private Task AuthState { get; set; } = default!; - private RoleManager RoleManager = null!; - private string? Title { get; set; } - private bool _processing; + [CascadingParameter] private Task _authState { get; set; } = default!; + private RoleManager _roleManager = null!; + private string? _title { get; set; } + private bool _isProcessing; private string _currentRoleName = string.Empty; private int _defaultPageSize = 15; - private HashSet _selectedItems = new(); - // Used only for retrieving member descriptions + private HashSet _selectedRoles = new(); private readonly ApplicationRoleDto _currentDto = new(); private string _searchString = string.Empty; private string _selectedTenantId = " "; - private TimeSpan RefreshInterval => TimeSpan.FromHours(2); + private TimeSpan _refreshInterval => TimeSpan.FromHours(2); private IList _permissions = new List(); - private MudDataGrid _table = null!; - + private MudDataGrid _dataGrid = null!; private bool _showPermissionsDrawer; - private bool _loading; - private bool _uploading; - private bool _exporting; - private PermissionHelper PermissionHelper = null!; + private bool _isLoading; + private bool _isUploading; + private bool _isExporting; + private PermissionHelper _permissionHelper = null!; private RolesAccessRights _accessRights = new(); #endregion - #region Lifecycle + #region Lifecycle Methods protected override async Task OnInitializedAsync() { InitializeServices(); - Title = L[_currentDto.GetClassDescription()]; + _title = _localizer[_currentDto.GetClassDescription()]; _accessRights = await PermissionService.GetAccessRightsAsync(); - } - + private void InitializeServices() { - RoleManager = ScopedServices.GetRequiredService>(); - PermissionHelper = ScopedServices.GetRequiredService(); + _roleManager = ScopedServices.GetRequiredService>(); + _permissionHelper = ScopedServices.GetRequiredService(); } #endregion #region Grid and Search - /// - /// Creates the predicate used to filter roles by search text and tenant. - /// + // Create the search predicate for filtering roles by name, description, and tenant. private Expression> CreateSearchPredicate() { return role => @@ -222,18 +211,16 @@ OnAssignChanged="OnAssignChangedHandler"> (_selectedTenantId == " " || role.TenantId == _selectedTenantId); } - /// - /// Retrieves the grid data from the server. - /// + // Retrieve grid data from the server. private async Task> ServerReload(GridState state) { - _loading = true; + _isLoading = true; try { - var searchPredicate = CreateSearchPredicate(); - var totalCount = await RoleManager.Roles.CountAsync(searchPredicate); - var roles = await RoleManager.Roles - .Where(searchPredicate) + var predicate = CreateSearchPredicate(); + var totalCount = await _roleManager.Roles.CountAsync(predicate); + var roles = await _roleManager.Roles + .Where(predicate) .EfOrderBySortDefinitions(state) .Skip(state.Page * state.PageSize) .Take(state.PageSize) @@ -244,80 +231,68 @@ OnAssignChanged="OnAssignChangedHandler"> } finally { - _loading = false; + _isLoading = false; } } - /// - /// Handles tenant selection changes. - /// - private async Task OnChangedListView(string tenantId) + // Handle tenant selection changes. + private async Task OnTenantChanged(string tenantId) { _selectedTenantId = tenantId; - await _table.ReloadServerData(); + await _dataGrid.ReloadServerData(); } - /// - /// Triggers a new search. - /// + // Handle search text changes. private async Task OnSearch(string text) { - if (_loading) return; + if (_isLoading) return; _searchString = text.ToLower(); - await _table.ReloadServerData(); + await _dataGrid.ReloadServerData(); } - /// - /// Refreshes the grid data. - /// + // Refresh the grid data. private async Task OnRefresh() { await InvokeAsync(async () => { - _selectedItems = new HashSet(); - RoleService.Refresh(); - await _table.ReloadServerData(); + _selectedRoles = new HashSet(); + _roleService.Refresh(); + await _dataGrid.ReloadServerData(); }); } #endregion - #region Create / Edit Roles + #region Create and Edit Roles - /// - /// Opens the dialog to create a new role. - /// + // Open dialog to create a new role. private async Task OnCreate() { var newRoleDto = new ApplicationRoleDto { Name = string.Empty }; - await ShowRoleDialog(newRoleDto, L["Create a new role"], async role => + await ShowRoleDialog(newRoleDto, _localizer["Create a new role"], async role => { - return await RoleManager.CreateAsync(role); + return await _roleManager.CreateAsync(role); }); } - /// - /// Opens the dialog to edit an existing role. - /// + // Open dialog to edit an existing role. private async Task OnEdit(ApplicationRoleDto item) { - await ShowRoleDialog(item, L["Edit the role"], async role => + await ShowRoleDialog(item, _localizer["Edit the role"], async role => { - var existingRole = await RoleManager.FindByIdAsync(item.Id); + var existingRole = await _roleManager.FindByIdAsync(item.Id); if (existingRole is not null) { existingRole.TenantId = item.TenantId; existingRole.Description = item.Description; existingRole.Name = item.Name; - return await RoleManager.UpdateAsync(existingRole); + return await _roleManager.UpdateAsync(existingRole); } return IdentityResult.Failed(new IdentityError { Description = "Role not found." }); }); } - /// - /// Displays a role form dialog and processes the save action. - /// + // Display a role dialog and process the save action. private async Task ShowRoleDialog(ApplicationRoleDto model, string title, Func> saveAction) { var parameters = new DialogParameters @@ -326,26 +301,26 @@ OnAssignChanged="OnAssignChangedHandler"> }; var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; var dialog = await DialogService.ShowAsync(title, parameters, options); - var result = await dialog.Result; + var dialogResult = await dialog.Result; - if (result is not null && !result.Canceled) + if (dialogResult is not null && !dialogResult.Canceled) { var applicationRole = new ApplicationRole - { - TenantId = model.TenantId, - Name = model.Name, - Description = model.Description - }; + { + TenantId = model.TenantId, + Name = model.Name, + Description = model.Description + }; - var resultState = await saveAction(applicationRole); - if (resultState.Succeeded) + var result = await saveAction(applicationRole); + if (result.Succeeded) { Snackbar.Add(ConstantString.CreateSuccess, Severity.Info); await OnRefresh(); } else { - Snackbar.Add(string.Join(",", resultState.Errors.Select(x => x.Description)), Severity.Error); + Snackbar.Add(string.Join(",", result.Errors.Select(x => x.Description)), Severity.Error); } } } @@ -354,52 +329,45 @@ OnAssignChanged="OnAssignChangedHandler"> #region Permissions Handling - /// - /// Opens the permissions drawer and loads permissions for the selected role. - /// + // Open permissions drawer and load permissions for the selected role. private async Task OnSetPermissions(ApplicationRoleDto item) { _showPermissionsDrawer = true; _currentRoleName = item.Name!; - _permissions = await PermissionHelper.GetAllPermissionsByRoleId(item.Id); + _permissions = await _permissionHelper.GetAllPermissionsByRoleId(item.Id); } - private Task OnOpenChangedHandler(bool state) + // Update drawer open state. + private Task OnPermissionsDrawerOpenChanged(bool state) { _showPermissionsDrawer = state; return Task.CompletedTask; } - /// - /// Handles a single permission change. - /// + // Process a single permission change. private async Task OnAssignChangedHandler(PermissionModel model) { await ProcessPermissionChange(model, async (roleId, claim, assigned) => { - var role = await RoleManager.FindByIdAsync(roleId) + var role = await _roleManager.FindByIdAsync(roleId) ?? throw new NotFoundException($"Application role {roleId} not found."); if (assigned) { - await RoleManager.AddClaimAsync(role, claim); - Snackbar.Add(L["Permission assigned successfully"], Severity.Info); + await _roleManager.AddClaimAsync(role, claim); + Snackbar.Add(_localizer["Permission assigned successfully"], Severity.Info); } else { - await RoleManager.RemoveClaimAsync(role, claim); - Snackbar.Add(L["Permission removed successfully"], Severity.Info); + await _roleManager.RemoveClaimAsync(role, claim); + Snackbar.Add(_localizer["Permission removed successfully"], Severity.Info); } }); } - /// - /// Processes batch permission assignment changes. - /// + // Process batch permission changes. private async Task OnAssignAllChangedHandler(List models) { - var roleGroups = models - .GroupBy(m => m.RoleId) - .ToDictionary(g => g.Key!, g => g.ToList()); + var roleGroups = models.GroupBy(m => m.RoleId).ToDictionary(g => g.Key!, g => g.ToList()); foreach (var (roleId, permissionModels) in roleGroups) { @@ -414,63 +382,49 @@ OnAssignChanged="OnAssignChangedHandler"> await ProcessPermissionsBatch(roleId, assignedClaims, unassignedClaims); } - Snackbar.Add(L["Authorization has been changed"], Severity.Info); + Snackbar.Add(_localizer["Authorization has been changed"], Severity.Info); } - /// - /// Processes batch permission changes for a role. - /// + // Process permission batch for a role. private async Task ProcessPermissionsBatch(string roleId, List assignedClaims, List unassignedClaims) { - _processing = true; + _isProcessing = true; try { - var role = await RoleManager.FindByIdAsync(roleId) + var role = await _roleManager.FindByIdAsync(roleId) ?? throw new NotFoundException($"Application role {roleId} not found."); foreach (var claim in assignedClaims) { - await RoleManager.AddClaimAsync(role, claim); + await _roleManager.AddClaimAsync(role, claim); } - foreach (var claim in unassignedClaims) { - await RoleManager.RemoveClaimAsync(role, claim); + await _roleManager.RemoveClaimAsync(role, claim); } - - FusionCache.Remove($"get-claims-by-{roleId}"); - } - catch (Exception) - { - throw; + _fusionCache.Remove($"get-claims-by-{roleId}"); } finally { - _processing = false; + _isProcessing = false; } } - /// - /// Toggles a permission assignment and applies the change. - /// + // Toggle a permission and apply the change. private async Task ProcessPermissionChange(PermissionModel model, Func changeAction) { - _processing = true; + _isProcessing = true; try { var roleId = model.RoleId!; var claim = new Claim(model.ClaimType, model.ClaimValue); model.Assigned = !model.Assigned; await changeAction(roleId, claim, model.Assigned); - FusionCache.Remove($"get-claims-by-{roleId}"); - } - catch (Exception) - { - throw; + _fusionCache.Remove($"get-claims-by-{roleId}"); } finally { - _processing = false; + _isProcessing = false; } } @@ -478,9 +432,7 @@ OnAssignChanged="OnAssignChangedHandler"> #region Deletion - /// - /// Deletes a single role after confirmation. - /// + // Delete a single role after confirmation. private async Task OnDelete(ApplicationRoleDto dto) { await DialogServiceHelper.ShowConfirmationDialogAsync( @@ -488,10 +440,10 @@ OnAssignChanged="OnAssignChangedHandler"> string.Format(ConstantString.DeleteConfirmation, dto.Name), async () => { - var rolesToDelete = await RoleManager.Roles.Where(x => x.Id == dto.Id).ToListAsync(); + var rolesToDelete = await _roleManager.Roles.Where(x => x.Id == dto.Id).ToListAsync(); foreach (var role in rolesToDelete) { - var deleteResult = await RoleManager.DeleteAsync(role); + var deleteResult = await _roleManager.DeleteAsync(role); if (!deleteResult.Succeeded) { Snackbar.Add(string.Join(",", deleteResult.Errors.Select(x => x.Description)), Severity.Error); @@ -503,21 +455,19 @@ OnAssignChanged="OnAssignChangedHandler"> }); } - /// - /// Deletes all selected roles after confirmation. - /// + // Delete selected roles after confirmation. private async Task OnDeleteChecked() { await DialogServiceHelper.ShowConfirmationDialogAsync( ConstantString.DeleteConfirmationTitle, - string.Format(ConstantString.DeleteConfirmation, _selectedItems.Count), + string.Format(ConstantString.DeleteConfirmation, _selectedRoles.Count), async () => { - var deleteIds = _selectedItems.Select(x => x.Id).ToArray(); - var rolesToDelete = await RoleManager.Roles.Where(x => deleteIds.Contains(x.Id)).ToListAsync(); + var deleteIds = _selectedRoles.Select(x => x.Id).ToArray(); + var rolesToDelete = await _roleManager.Roles.Where(x => deleteIds.Contains(x.Id)).ToListAsync(); foreach (var role in rolesToDelete) { - var deleteResult = await RoleManager.DeleteAsync(role); + var deleteResult = await _roleManager.DeleteAsync(role); if (!deleteResult.Succeeded) { Snackbar.Add(string.Join(",", deleteResult.Errors.Select(x => x.Description)), Severity.Error); @@ -531,27 +481,23 @@ OnAssignChanged="OnAssignChangedHandler"> #endregion - #region Export / Import + #region Export and Import - /// - /// Handles exporting of role data. - /// + // Export role data (stub implementation). private Task OnExport() { - _exporting = true; - // Export logic to be implemented. - _exporting = false; + _isExporting = true; + // Implement export logic here + _isExporting = false; return Task.CompletedTask; } - /// - /// Handles importing role data from a file. - /// + // Import role data (stub implementation). private Task OnImportData(IBrowserFile file) { - _uploading = true; - // Import logic to be implemented. - _uploading = false; + _isUploading = true; + // Implement import logic here + _isUploading = false; return Task.CompletedTask; } diff --git a/src/Server.UI/Pages/Identity/Users/Components/UserForm.razor b/src/Server.UI/Pages/Identity/Users/Components/UserForm.razor index 24e45b746..2a3bd402b 100644 --- a/src/Server.UI/Pages/Identity/Users/Components/UserForm.razor +++ b/src/Server.UI/Pages/Identity/Users/Components/UserForm.razor @@ -1,9 +1,6 @@ @using CleanArchitecture.Blazor.Application.Features.Tenants.DTOs -@using ResizeMode = SixLabors.ImageSharp.Processing.ResizeMode -@using Size = SixLabors.ImageSharp.Size -@using Image = SixLabors.ImageSharp.Image -@using SixLabors.ImageSharp.Processing @using SixLabors.ImageSharp +@using SixLabors.ImageSharp.Processing @using SixLabors.ImageSharp.Formats.Png @using CleanArchitecture.Blazor.Application.Features.Identity.DTOs @using CleanArchitecture.Blazor.Domain.Common.Enums @@ -15,182 +12,151 @@ @inject ITenantService TenantsService @inject IStringLocalizer L - - - + + +
- - @if (string.IsNullOrEmpty(Model.ProfilePictureDataUrl)) - { - @Model.UserName.ToUpper().FirstOrDefault() - } - else - { - - - - } + + @if (string.IsNullOrEmpty(Model.ProfilePictureDataUrl)) + { + @Model.UserName.ToUpper().FirstOrDefault() + } + else + { + + } + - - + -
+ - - + Value="@Model.Tenant" + ValueChanged="TenantChanged" /> - + - - + -
- -
+
- + - + Label="@L[Model.GetMemberDescription(x => x.Superior)]" + @bind-Value="Model.Superior" /> - + - + - + - + - @L["Assign Roles"] @if (Roles.Any()) { - @for (var i = 0; i < Roles.Count; i++) + @foreach (var role in Roles) { - var x = i; - + } - } else { @L["No roles available for this tenant."] } - - + - + - + - +
@code { - [Parameter] - public UserProfile? UserProfile { get; set; } + [Parameter] public UserProfile? UserProfile { get; set; } + [EditorRequired][Parameter] public ApplicationUserDto Model { get; set; } = default!; + [EditorRequired][Parameter] public EventCallback OnFormSubmit { get; set; } + + private MudForm? _form; + private List Roles { get; set; } = new(); + private RoleManager RoleManager = null!; + public class CheckItem { public string Key { get; set; } = string.Empty; public bool Value { get; set; } } - private RoleManager RoleManager = null!; - [EditorRequired][Parameter] public ApplicationUserDto Model { get; set; } = default!; - - [EditorRequired][Parameter] public EventCallback OnFormSubmit { get; set; } - - - private MudForm? _form = default!; - private List Roles { get; set; } = new(); protected override async Task OnInitializedAsync() { RoleManager = ScopedServices.GetRequiredService>(); await GetRoles(Model.TenantId); } + private async Task GetRoles(string? tenantId) { - Roles = new(); - var array = await RoleManager.Roles.Where(x => x.TenantId == Model.TenantId).Select(x => x.Name).ToListAsync(); - foreach (var role in array) - { - if (Model.AssignedRoles != null && Model.AssignedRoles.Contains(role)) - { - Roles.Add(new CheckItem { Key = role!, Value = true }); - } - else - { - Roles.Add(new CheckItem { Key = role!, Value = false }); - } - } + var roles = await RoleManager.Roles + .Where(x => x.TenantId == tenantId) + .Select(x => x.Name) + .ToListAsync(); + Roles = roles.Select(r => new CheckItem { Key = r!, Value = Model.AssignedRoles?.Contains(r) ?? false }).ToList(); } - private async Task TenantChanged(TenantDto? x) + + private async Task TenantChanged(TenantDto? tenant) { - Model.TenantId = x?.Id; - Model.Tenant = x; + Model.TenantId = tenant?.Id; + Model.Tenant = tenant; await GetRoles(Model.TenantId); } + private async Task UploadPhoto(IBrowserFile file) { - using var fileStream = file.OpenReadStream(GlobalVariable.MaxAllowedSize); - using var memoryStream = new MemoryStream(); - await fileStream.CopyToAsync(memoryStream); - byte[] fileData = memoryStream.ToArray(); - - var uploadRequest = new UploadRequest(file.Name, UploadType.ProfilePicture, fileData, overwrite: true) - { - ResizeOptions = new ResizeOptions() - { - Mode = ResizeMode.Max, - Size = new Size(128, 128) - }, - Folder = Model.Id - }; - - var result = await UploadService.UploadAsync(uploadRequest); - Model.ProfilePictureDataUrl = result; + using var ms = new MemoryStream(); + await file.OpenReadStream(GlobalVariable.MaxAllowedSize).CopyToAsync(ms); + var resizedStream = await ImageProcessor.ResizeAndCompressToJpegAsync(ms,128,128,80); + var uploadRequest = new UploadRequest(file.Name, UploadType.ProfilePicture, resizedStream.ToArray(), overwrite: true, Model.Id); + Model.ProfilePictureDataUrl = await UploadService.UploadAsync(uploadRequest); Snackbar.Add(ConstantString.UploadSuccess, Severity.Info); } @@ -201,10 +167,9 @@ await _form.Validate(); if (_form.IsValid) { - Model.AssignedRoles = Roles.Where(x => x.Value).Select(x => x.Key).ToArray(); + Model.AssignedRoles = Roles.Where(r => r.Value).Select(r => r.Key).ToArray(); await OnFormSubmit.InvokeAsync(Model); } } } - -} \ No newline at end of file +} diff --git a/src/Server.UI/Pages/Identity/Users/Profile.razor b/src/Server.UI/Pages/Identity/Users/Profile.razor index 00f58fd79..4b9879a3d 100644 --- a/src/Server.UI/Pages/Identity/Users/Profile.razor +++ b/src/Server.UI/Pages/Identity/Users/Profile.razor @@ -10,15 +10,15 @@ @using CleanArchitecture.Blazor.Application.Common.Interfaces.Identity @using CleanArchitecture.Blazor.Server.UI.Services.JsInterop @using CleanArchitecture.Blazor.Domain.Common.Enums -@inherits OwningComponentBase -@inject IStringLocalizer L +@inject IStringLocalizer Localizer @inject IUploadService UploadService @inject IOnlineUserTracker OnlineUserTracker @inject UserProfileStateService UserProfileStateService -@Title +@inject IJSRuntime JS +@_title -@if (model is null) +@if (_profileModel is null) { } @@ -26,73 +26,75 @@ else { - - - + + + +
- - @if (string.IsNullOrEmpty(model.ProfilePictureDataUrl)) + + @if (string.IsNullOrEmpty(_profileModel.ProfilePictureDataUrl)) { - @(string.IsNullOrEmpty(model.UserName) ? "" : model.UserName.ToUpper().First()) + + @(string.IsNullOrEmpty(_profileModel.UserName) ? "" : _profileModel.UserName.ToUpper().First()) + } else { - + } - - - @if (model.AssignedRoles is not null) + @if (_profileModel.AssignedRoles is not null) {
- @foreach (var role in model.AssignedRoles) + @foreach (var role in _profileModel.AssignedRoles) { @role }
} -
- - - + + - - + -
+ + - + - + - + - + - + - + - + - + - + @if (_submitting) { @@ -107,32 +109,35 @@ else
- - + + + - + @if (_submitting) { @@ -140,93 +145,101 @@ else } else { - @L["Change Password"] + @Localizer["Change Password"] } - -
-
+ + +
} - - @code { + #region Fields and Service Injection + [Inject] protected IServiceProvider Services { get; init; } = null!; - private string _currentUserName = string.Empty; + [CascadingParameter] private Task AuthState { get; set; } = default!; - private UserProfile? UserProfile { get; set; } - private UserManager UserManager = null!; - public string Title { get; set; } = "Profile"; - private MudForm? _form; - private MudForm? _passwordform; + // Private fields using underscore prefix + private string _currentUserName = string.Empty; + private UserManager _userManager = null!; + private MudForm? _profileForm; + private MudForm? _passwordForm; private bool _submitting; - private ChangePasswordModel _changepassword { get; } = new(); private readonly List _orgData = new(); - public string Id => Guid.NewGuid().ToString(); - private ChangeUserProfileModel model = null!; + private ChangeUserProfileModel _profileModel = null!; + private ChangePasswordModel _changePasswordModel { get; } = new(); + public string Title { get; set; } = "Profile"; + private string _title => Title; - [CascadingParameter] private Task AuthState { get; set; } = default!; + #endregion + + #region Lifecycle Methods - private async void ActivePanelIndexChanged(int index) + protected override async Task OnInitializedAsync() + { + _userManager = Services.GetRequiredService>(); + var authState = await AuthState; + _currentUserName = authState.User.Identity?.Name ?? string.Empty; + await UserProfileStateService.InitializeAsync(_currentUserName); + // Map user profile data to the profile model + _profileModel = Mapper.Map(UserProfileStateService.UserProfile); + } + + + #endregion + + #region Organization Chart Methods + + // Triggered when the active tab changes + private async Task ActivePanelIndexChanged(int index) { if (index == 2) { - await LoadOrgData(); + await LoadOrgChartAsync(); } } - private async Task LoadOrgData() + // Load organization chart data and initialize the chart via JS interop + private async Task LoadOrgChartAsync() { + var users = await _userManager.Users + .Include(x => x.UserRoles).ThenInclude(x => x.Role) + .Include(x => x.Superior) + .ToListAsync(); - var list = await UserManager.Users.Include(x => x.UserRoles).ThenInclude(x => x.Role).Include(x => x.Superior).ToListAsync(); - - foreach (var item in list) + foreach (var user in users) { - var roles = await UserManager.GetRolesAsync(item); - var count = await UserManager.Users.Where(x => x.SuperiorId == item.Id).CountAsync(); - var orgitem = new OrgItem(); - orgitem.Id = item.Id; - orgitem.Name = item.DisplayName ?? item.UserName; - orgitem.Area = item.Tenant?.Name; - orgitem.ProfileUrl = item.ProfilePictureDataUrl; - orgitem.ImageUrl = item.ProfilePictureDataUrl; - if (_currentUserName == item.UserName) - orgitem.IsLoggedUser = true; - orgitem.Size = ""; - orgitem.Tags = item.PhoneNumber ?? item.Email; - if (roles != null && roles.Count > 0) - orgitem.PositionName = string.Join(',', roles); - orgitem.ParentId = item.SuperiorId; - - orgitem.DirectSubordinates = count; - _orgData.Add(orgitem); + var roles = await _userManager.GetRolesAsync(user); + var subordinateCount = await _userManager.Users.Where(x => x.SuperiorId == user.Id).CountAsync(); + var orgItem = new OrgItem + { + Id = user.Id, + Name = user.DisplayName ?? user.UserName, + Area = user.Tenant?.Name, + ProfileUrl = user.ProfilePictureDataUrl, + ImageUrl = user.ProfilePictureDataUrl, + IsLoggedUser = _currentUserName == user.UserName, + Size = string.Empty, + Tags = user.PhoneNumber ?? user.Email, + PositionName = roles != null && roles.Count > 0 ? string.Join(',', roles) : string.Empty, + ParentId = user.SuperiorId, + DirectSubordinates = subordinateCount + }; + _orgData.Add(orgItem); } await new OrgChart(JS).Create(_orgData); } - protected override async Task OnInitializedAsync() - { - UserManager = Services.GetRequiredService>(); - var state = await AuthState; - _currentUserName = state.User.Identity?.Name ?? string.Empty; - await UserProfileStateService.InitializeAsync(state.User.Identity?.Name ?? string.Empty); - model = Mapper.Map(UserProfileStateService.UserProfile); - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - } - + #endregion + #region File Upload Method private async Task UploadPhoto(IBrowserFile file) { @@ -234,52 +247,45 @@ else using var memoryStream = new MemoryStream(); await fileStream.CopyToAsync(memoryStream); byte[] fileData = memoryStream.ToArray(); - var user = await UserManager.FindByNameAsync(model.UserName) - ?? throw new NotFoundException($"The application user [{model.UserName}] was not found."); - var uploadRequest = new UploadRequest(file.Name, UploadType.ProfilePicture, fileData, overwrite: true) - { - ResizeOptions = new SixLabors.ImageSharp.Processing.ResizeOptions - { - Mode = SixLabors.ImageSharp.Processing.ResizeMode.Crop, - Size = new Size(128, 128) - }, - Folder = user.Id - }; + + var user = await _userManager.FindByNameAsync(_profileModel.UserName) + ?? throw new NotFoundException($"User [{_profileModel.UserName}] not found."); + var resizedStream = await ImageProcessor.ResizeAndCompressToJpegAsync(memoryStream,128,128,80); + var uploadRequest = new UploadRequest(file.Name, UploadType.ProfilePicture, fileData, overwrite: true, user.Id); + + var result = await UploadService.UploadAsync(uploadRequest); - model.ProfilePictureDataUrl = result; - user.ProfilePictureDataUrl = model.ProfilePictureDataUrl; - await UserManager.UpdateAsync(user); - Snackbar.Add(L["The avatar has been updated"], Severity.Info); + _profileModel.ProfilePictureDataUrl = result; + user.ProfilePictureDataUrl = _profileModel.ProfilePictureDataUrl; + await _userManager.UpdateAsync(user); + Snackbar.Add(Localizer["The avatar has been updated"], Severity.Info); UserProfileStateService.UpdateUserProfile(user.UserName!, user.ProfilePictureDataUrl, user.DisplayName, user.PhoneNumber, user.TimeZoneId, user.LanguageCode); - await OnlineUserTracker.Update(user.Id, - user.UserName ?? "", - user.DisplayName ?? "", - user.ProfilePictureDataUrl); + await OnlineUserTracker.Update(user.Id, user.UserName ?? "", user.DisplayName ?? "", user.ProfilePictureDataUrl); } - private async Task Submit() + #endregion + + #region Profile Update Submission + + private async Task SubmitProfileAsync() { _submitting = true; try { - await _form!.Validate(); - if (_form.IsValid) + await _profileForm!.Validate(); + if (_profileForm.IsValid) { - - var user = await UserManager.FindByNameAsync(_currentUserName) ?? throw new NotFoundException($"The application user [{_currentUserName}] was not found."); - user.PhoneNumber = model.PhoneNumber; - user.DisplayName = model.DisplayName; - user.TimeZoneId = model.TimeZoneId; - user.LanguageCode = model.LanguageCode; - user.ProfilePictureDataUrl = model.ProfilePictureDataUrl; - await UserManager.UpdateAsync(user); + var user = await _userManager.FindByNameAsync(_currentUserName) + ?? throw new NotFoundException($"User [{_currentUserName}] not found."); + user.PhoneNumber = _profileModel.PhoneNumber; + user.DisplayName = _profileModel.DisplayName; + user.TimeZoneId = _profileModel.TimeZoneId; + user.LanguageCode = _profileModel.LanguageCode; + user.ProfilePictureDataUrl = _profileModel.ProfilePictureDataUrl; + await _userManager.UpdateAsync(user); UserProfileStateService.UpdateUserProfile(user.UserName!, user.ProfilePictureDataUrl, user.DisplayName, user.PhoneNumber, user.TimeZoneId, user.LanguageCode); - await OnlineUserTracker.Update(user.Id, - user.UserName ?? "", - user.DisplayName ?? "", - user.ProfilePictureDataUrl ?? ""); - Snackbar.Add($"{ConstantString.SaveSuccess}", Severity.Info); - + await OnlineUserTracker.Update(user.Id, user.UserName ?? "", user.DisplayName ?? "", user.ProfilePictureDataUrl ?? ""); + Snackbar.Add(ConstantString.SaveSuccess, Severity.Info); } } finally @@ -288,23 +294,28 @@ else } } - private async Task ChangePassword() + #endregion + + #region Change Password Submission + + private async Task ChangePasswordAsync() { _submitting = true; try { - await _passwordform!.Validate(); - if (_passwordform!.IsValid) + await _passwordForm!.Validate(); + if (_passwordForm.IsValid) { - var user = await UserManager.FindByNameAsync(model.UserName) ?? throw new NotFoundException($"The application user [{model.UserName}] was not found."); ; - var result = await UserManager.ChangePasswordAsync(user, _changepassword.CurrentPassword, _changepassword.NewPassword); + var user = await _userManager.FindByNameAsync(_profileModel.UserName) + ?? throw new NotFoundException($"User [{_profileModel.UserName}] not found."); + var result = await _userManager.ChangePasswordAsync(user, _changePasswordModel.CurrentPassword, _changePasswordModel.NewPassword); if (result.Succeeded) { - Snackbar.Add($"{L["Password changed successfully"]}", Severity.Info); + Snackbar.Add(Localizer["Password changed successfully"], Severity.Info); } else { - Snackbar.Add($"{string.Join(",", result.Errors.Select(x => x.Description).ToArray())}", Severity.Error); + Snackbar.Add(string.Join(",", result.Errors.Select(e => e.Description)), Severity.Error); } } } @@ -314,5 +325,5 @@ else } } - -} \ No newline at end of file + #endregion +} diff --git a/src/Server.UI/Pages/Identity/Users/Users.razor b/src/Server.UI/Pages/Identity/Users/Users.razor index 5a1ed9669..6b8c0e5b1 100644 --- a/src/Server.UI/Pages/Identity/Users/Users.razor +++ b/src/Server.UI/Pages/Identity/Users/Users.razor @@ -26,81 +26,80 @@ @implements IDisposable @inject IOnlineUserTracker OnlineUserTracker -@inject BlazorDownloadFileService BlazorDownloadFileService +@inject BlazorDownloadFileService DownloadFileService @inject IUserService UserService -@inject ITenantService TenantsService +@inject ITenantService TenantService @inject IFusionCache FusionCache @inject IExcelService ExcelService @inject IMailService MailService @inject IRoleService RoleService -@inject IStringLocalizer L +@inject IStringLocalizer Localizer @inject ILogger Logger -@Title +@_title - + @bind-SelectedItems="_selectedUsers" + Loading="@_isLoading" + ServerData="@(LoadServerData)"> - @Title - - @L["ALL"] - @foreach (var item in TenantsService.DataSource) + @_title + + @Localizer["ALL"] + @foreach (var tenant in TenantService.DataSource) { - @item.Name + @tenant.Name } - @ConstantString.Refresh @if (_accessRights.Create) { + OnClick="CreateUser"> @ConstantString.New } @if (_accessRights.Delete) { - + @ConstantString.Delete } @if (_accessRights.Export) { - + @ConstantString.Export } @if (_accessRights.Import) { - + + Disabled="@_isUploading"> @ConstantString.Import @@ -112,13 +111,13 @@ @if (_accessRights.Search) { - - @foreach (var str in _roles.Select(x=>x.Name).Distinct()) + + @foreach (var role in _roles.Select(r => r.Name).Distinct()) { - @str + @role } - } @@ -127,33 +126,34 @@ - + - @if (_accessRights.Edit || _accessRights.Delete || _accessRights.ManageRoles || _accessRights.RestPassword || _accessRights.SendRestPasswordMail || _accessRights.ManagePermissions) + @if (_accessRights.Edit || _accessRights.Delete || _accessRights.ManageRoles || + _accessRights.RestPassword || _accessRights.SendRestPasswordMail || _accessRights.ManagePermissions) { @if (_accessRights.Edit) { - @ConstantString.Edit + @ConstantString.Edit } @if (_accessRights.Delete) { - @ConstantString.Delete + @ConstantString.Delete } @if (_accessRights.ManagePermissions) { - @L["Set Permissions"] + @Localizer["Set Permissions"] } @if (_accessRights.SendRestPasswordMail) { - @L["Send Reset Password Email"] + @Localizer["Send Reset Password Email"] } @if (_accessRights.RestPassword) { - @L["Reset Password"] + @Localizer["Reset Password"] } } @@ -167,69 +167,64 @@ } - + -
- @context.Item.Tenant?.Name -
+ @context.Item.Tenant?.Name
- @L["Selected"]: @_selectedItems.Count + @Localizer["Selected"]: @_selectedUsers.Count
- + - + - +
@context.Item.DisplayName
- + @context.Item.Superior?.UserName - - - + + @if (context.Item.AssignedRoles is not null) { - foreach (var str in context.Item.AssignedRoles) + foreach (var role in context.Item.AssignedRoles) { - @str + @role } } - +
@if (!context.Item.IsActive || (context.Item.LockoutEnd is not null && context.Item.LockoutEnd > DateTime.UtcNow)) { - - - + + } else { - - - + + }
- + - +
@@ -239,61 +234,72 @@
- - + @code { + #region Fields and Service Injection + [CascadingParameter] private Task AuthState { get; set; } = default!; [CascadingParameter] public UserProfile? UserProfile { get; set; } - private UserManager UserManager = null!; - private RoleManager RoleManager = null!; - private PermissionHelper PermissionHelper = null!; + + // Data management services + private UserManager _userManager = null!; + private RoleManager _roleManager = null!; + private PermissionHelper _permissionHelper = null!; + + // Page state and data private int _defaultPageSize = 15; - private HashSet _selectedItems = new(); - private readonly ApplicationUserDto _currentDto = new(); + private HashSet _selectedUsers = new(); + private readonly ApplicationUserDto _currentUserDto = new(); private string _searchString = string.Empty; private string _selectedTenantId = string.Empty; - private string Title { get; set; } = "Users"; + private string _title { get; set; } = "Users"; private IList _permissions = new List(); - private TimeSpan RefreshInterval => TimeSpan.FromHours(2); - - private UsersAccessRights _accessRights = new(); - private MudDataGrid _table = null!; - private bool _processing; + private bool _isProcessing; private bool _showPermissionsDrawer; - - private bool _loading; - private bool _exporting; - private bool _uploading; + private bool _isLoading; + private bool _isExporting; + private bool _isUploading; private bool _canViewOnlineStatus; private List _roles = new(); private string? _searchRole; + // Permissions control + private UsersAccessRights _accessRights = new(); + + // DataGrid reference + private MudDataGrid _dataGrid = null!; + + private TimeSpan RefreshInterval => TimeSpan.FromHours(2); + + #endregion + + #region Lifecycle and Initialization + protected override async Task OnInitializedAsync() { - Title = L[_currentDto.GetClassDescription()]; + _title = Localizer[_currentUserDto.GetClassDescription()]; InitializeServices(); _accessRights = await PermissionService.GetAccessRightsAsync(); _roles = RoleService.DataSource; } + private void InitializeServices() { - RoleManager = ScopedServices.GetRequiredService>(); - UserManager = ScopedServices.GetRequiredService>(); - PermissionHelper = ScopedServices.GetRequiredService(); + _roleManager = ScopedServices.GetRequiredService>(); + _userManager = ScopedServices.GetRequiredService>(); + _permissionHelper = ScopedServices.GetRequiredService(); } - - private async Task OnInvalidated(IComputed state) - { - await InvokeAsync(StateHasChanged); - } public void Dispose() { - + // Dispose resources if needed } + #endregion + + #region Data Loading and Searching private Expression> CreateSearchPredicate() { @@ -303,68 +309,71 @@ x.DisplayName!.Contains(_searchString) || x.PhoneNumber!.Contains(_searchString) || x.Provider!.Contains(_searchString)) && - (_searchRole == null || (_searchRole != null && x.UserRoles.Any(x => x.Role.Name == _searchRole))) && - (_selectedTenantId == "" || (_selectedTenantId != "" && x.TenantId == _selectedTenantId)); + (_searchRole == null || x.UserRoles.Any(ur => ur.Role.Name == _searchRole)) && + (string.IsNullOrEmpty(_selectedTenantId) || x.TenantId == _selectedTenantId); } - private async Task> ServerReload(GridState state) + + private async Task> LoadServerData(GridState state) { try { - _loading = true; - var searchPredicate = CreateSearchPredicate(); - var count = await UserManager.Users.CountAsync(searchPredicate); - var data = await UserManager.Users.Where(searchPredicate) - .Include(x => x.UserRoles).ThenInclude(ur => ur.Role) - .Include(x => x.CreatedByUser) - .Include(x => x.LastModifiedByUser) - .Include(x => x.Superior) - .EfOrderBySortDefinitions(state) - .Skip(state.Page * state.PageSize).Take(state.PageSize) - .ProjectTo(Mapper.ConfigurationProvider) - .ToListAsync(); - - return new GridData { TotalItems = count, Items = data }; + _isLoading = true; + var predicate = CreateSearchPredicate(); + var totalCount = await _userManager.Users.CountAsync(predicate); + var testd = await _userManager.Users.Where(predicate).Include(x => x.CreatedByUser).Include(x => x.LastModifiedByUser).ToListAsync(); + var data = await _userManager.Users.Where(predicate) + .Include(x => x.UserRoles).ThenInclude(ur => ur.Role) + .Include(x => x.CreatedByUser) + .Include(x => x.LastModifiedByUser) + .Include(x => x.Superior) + .EfOrderBySortDefinitions(state) + .Skip(state.Page * state.PageSize).Take(state.PageSize) + .ProjectTo(Mapper.ConfigurationProvider) + .ToListAsync(); + + return new GridData { TotalItems = totalCount, Items = data }; } finally { - _loading = false; - } - } - private async Task OnChangedListView(string tenantId) - { - _selectedTenantId = tenantId; - if (_selectedTenantId != string.Empty) - { - _roles = RoleService.DataSource.Where(x => x.TenantId == _selectedTenantId).ToList(); + _isLoading = false; } - await _table.ReloadServerData(); } - private async Task OnSearch(string text) + + private async Task SearchUsers(string searchText) { - if (_loading) - return; - _searchString = text.ToLower(); - await _table.ReloadServerData(); + if (_isLoading) return; + _searchString = searchText.ToLower(); + await _dataGrid.ReloadServerData(); } - private async Task OnSearchRole(string role) + private async Task SearchByRole(string role) { - if (_loading) - return; + if (_isLoading) return; _searchRole = role; - await _table.ReloadServerData(); + await _dataGrid.ReloadServerData(); } - private async Task OnRefresh() + private async Task TenantListViewChanged(string tenantId) { - await InvokeAsync(async () => + _selectedTenantId = tenantId; + if (!string.IsNullOrEmpty(_selectedTenantId)) { - _selectedItems = new HashSet(); - await _table.ReloadServerData(); - }); + _roles = RoleService.DataSource.Where(r => r.TenantId == _selectedTenantId).ToList(); + } + await _dataGrid.ReloadServerData(); } + + private async Task RefreshDataGrid() + { + _selectedUsers = new HashSet(); + await _dataGrid.ReloadServerData(); + } + + #endregion + #region User Creation, Editing, and Deletion - private async Task ShowUserDialog(ApplicationUserDto model, string title, Func processAction) + + private async Task ShowUserDialog(ApplicationUserDto model, string dialogTitle, Func processAction) { var parameters = new DialogParameters { @@ -372,7 +381,7 @@ { x => x.UserProfile, UserProfile } }; var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; - var dialog = await DialogService.ShowAsync(title, parameters, options); + var dialog = await DialogService.ShowAsync(dialogTitle, parameters, options); var result = await dialog.Result; if (result is not null && !result.Canceled) { @@ -380,10 +389,9 @@ } } - - private async Task ProcessUserCreation(ApplicationUserDto model) + private async Task CreateUserAsync(ApplicationUserDto model) { - var applicationUser = new ApplicationUser + var newUser = new ApplicationUser { Provider = model.Provider, DisplayName = model.DisplayName, @@ -396,71 +404,75 @@ EmailConfirmed = false, IsActive = model.IsActive, LanguageCode = model.LanguageCode, - TimeZoneId = model.TimeZoneId + TimeZoneId = model.TimeZoneId, + CreatedBy = UserProfile?.UserId, + Created = DateTime.UtcNow }; - var identityResult = await UserManager.CreateAsync(applicationUser); - if (!identityResult.Succeeded) + var result = await _userManager.CreateAsync(newUser); + if (!result.Succeeded) { - Snackbar.Add($"{string.Join(",", identityResult.Errors.Select(x => x.Description).ToArray())}", Severity.Error); + Snackbar.Add(string.Join(",", result.Errors.Select(e => e.Description)), Severity.Error); return; } - Snackbar.Add($"{L["New user created successfully."]}", Severity.Info); - await AssignRolesToUser(applicationUser, model.AssignedRoles); - Logger.LogInformation("Create a user succeeded. Username: {@UserName:l}, UserId: {@UserId}", applicationUser.UserName, applicationUser.Id); + Snackbar.Add(Localizer["New user created successfully."], Severity.Info); + await AssignRolesToUserAsync(newUser, model.AssignedRoles); + Logger.LogInformation("User created: {UserName} (ID: {UserId})", newUser.UserName, newUser.Id); UserService.Refresh(); - await OnRefresh(); + await RefreshDataGrid(); } - private async Task ProcessUserUpdate(ApplicationUserDto item) + + private async Task UpdateUserAsync(ApplicationUserDto model) { - var user = await UserManager.FindByIdAsync(item.Id!) ?? throw new NotFoundException($"The application user [{item.Id}] was not found."); - var roles = await UserManager.GetRolesAsync(user); - if (roles.Count > 0) + var user = await _userManager.FindByIdAsync(model.Id!) + ?? throw new NotFoundException($"User not found: {model.Id}"); + var roles = await _userManager.GetRolesAsync(user); + if (roles.Any()) { - await UserManager.RemoveFromRolesAsync(user, roles); + await _userManager.RemoveFromRolesAsync(user, roles); } - user.Email = item.Email; - user.PhoneNumber = item.PhoneNumber; - user.ProfilePictureDataUrl = item.ProfilePictureDataUrl; - user.DisplayName = item.DisplayName; - user.Provider = item.Provider; - user.UserName = item.UserName; - user.IsActive = item.IsActive; - user.TenantId = item.TenantId; - user.SuperiorId = item.Superior?.Id; - user.LanguageCode = item.LanguageCode; - user.TimeZoneId = item.TimeZoneId; - var identityResult = await UserManager.UpdateAsync(user); - if (identityResult.Succeeded) - { - if (item.AssignedRoles is not null && item.AssignedRoles.Length > 0) + user.Email = model.Email; + user.PhoneNumber = model.PhoneNumber; + user.ProfilePictureDataUrl = model.ProfilePictureDataUrl; + user.DisplayName = model.DisplayName; + user.Provider = model.Provider; + user.UserName = model.UserName; + user.IsActive = model.IsActive; + user.TenantId = model.TenantId; + user.SuperiorId = model.Superior?.Id; + user.LanguageCode = model.LanguageCode; + user.TimeZoneId = model.TimeZoneId; + user.LastModifiedBy = UserProfile?.UserId; + user.LastModified = DateTime.UtcNow; + var updateResult = await _userManager.UpdateAsync(user); + if (updateResult.Succeeded) + { + if (model.AssignedRoles is { Length: > 0 }) { - await UserManager.AddToRolesAsync(user, item.AssignedRoles); + await _userManager.AddToRolesAsync(user, model.AssignedRoles); } - - Snackbar.Add($"{L["The user updated successfully."]}", Severity.Info); - await OnRefresh(); + Snackbar.Add(Localizer["User updated successfully."], Severity.Info); + await RefreshDataGrid(); UserService.Refresh(); - await OnlineUserTracker.Update(item.Id, - item.UserName, - item.DisplayName ?? string.Empty, - item.ProfilePictureDataUrl ?? string.Empty); + await OnlineUserTracker.Update(model.Id, model.UserName, model.DisplayName ?? string.Empty, model.ProfilePictureDataUrl ?? string.Empty); } else { - Snackbar.Add($"{string.Join(",", identityResult.Errors.Select(x => x.Description).ToArray())}", Severity.Error); + Snackbar.Add(string.Join(",", updateResult.Errors.Select(e => e.Description)), Severity.Error); } } - private async Task AssignRolesToUser(ApplicationUser user, string[]? roles) + + private async Task AssignRolesToUserAsync(ApplicationUser user, string[]? roles) { if (roles is not null && roles.Length > 0) { - await UserManager.AddToRolesAsync(user, roles); + await _userManager.AddToRolesAsync(user, roles); } } - private async Task OnCreate() + + private async Task CreateUser() { var model = new ApplicationUserDto { @@ -471,27 +483,44 @@ TimeZoneId = TimeZoneInfo.Local.Id, LanguageCode = CultureInfo.CurrentCulture.Name }; - await ShowUserDialog(model, L["Create a new user"], ProcessUserCreation); + await ShowUserDialog(model, Localizer["Create a new user"], CreateUserAsync); } - private async Task OnEdit(ApplicationUserDto item) => - await ShowUserDialog(item, L["Edit the user"], ProcessUserUpdate); + private async Task EditUser(ApplicationUserDto model) => + await ShowUserDialog(model, Localizer["Edit the user"], UpdateUserAsync); - private async Task ShowDeleteConfirmationDialogAsync(ApplicationUserDto dto, Func onConfirmed) + private async Task ShowDeleteConfirmationAsync(ApplicationUserDto model, Func onConfirmed) { - var message = string.Format(ConstantString.DeleteConfirmation, dto.UserName); + var message = string.Format(ConstantString.DeleteConfirmation, model.UserName); await DialogServiceHelper.ShowConfirmationDialogAsync(ConstantString.DeleteConfirmationTitle, message, onConfirmed); } - private async Task ProcessUserDeletion(ApplicationUserDto dto) + + private async Task DeleteUserAsync(ApplicationUserDto model) { - var user = await UserManager.FindByIdAsync(dto.Id) - ?? throw new NotFoundException($"User not found: {dto.Id}"); - var result = await UserManager.DeleteAsync(user); + var authState = await AuthState; + if (model.Id == authState.User.GetUserId()) + { + Snackbar.Add(Localizer["You cannot delete your own account!"], Severity.Error); + return; + } + await ShowDeleteConfirmationAsync(model, async () => + { + await ProcessUserDeletionAsync(model); + _selectedUsers.Clear(); + await RefreshDataGrid(); + }); + } + + private async Task ProcessUserDeletionAsync(ApplicationUserDto model) + { + var user = await _userManager.FindByIdAsync(model.Id) + ?? throw new NotFoundException($"User not found: {model.Id}"); + var result = await _userManager.DeleteAsync(user); if (result.Succeeded) { Logger.LogInformation("User deleted: {UserName} (ID: {UserId})", user.UserName, user.Id); Snackbar.Add(ConstantString.DeleteSuccess, Severity.Info); - await OnRefresh(); + await RefreshDataGrid(); UserService.Refresh(); } else @@ -499,42 +528,27 @@ Snackbar.Add(string.Join(",", result.Errors.Select(e => e.Description)), Severity.Error); } } - private async Task OnDelete(ApplicationUserDto dto) - { - var state = await AuthState; - if (dto.Id == state.User.GetUserId()) - { - Snackbar.Add(L["You cannot delete your own account!"], Severity.Error); - return; - } - await ShowDeleteConfirmationDialogAsync(dto, async () => - { - await ProcessUserDeletion(dto); - _selectedItems.Clear(); - await OnRefresh(); - }); - } - private async Task OnDeleteChecked() + private async Task DeleteCheckedUsersAsync() { - var state = await AuthState; - if (_selectedItems.Any(x => x.Id == state.User.GetUserId())) + var authState = await AuthState; + if (_selectedUsers.Any(u => u.Id == authState.User.GetUserId())) { - Snackbar.Add(L["You cannot delete your own account!"], Severity.Error); + Snackbar.Add(Localizer["You cannot delete your own account!"], Severity.Error); return; } - var message = string.Format(ConstantString.DeleteConfirmation, _selectedItems.Count); - await DialogServiceHelper.ShowConfirmationDialogAsync(ConstantString.DeleteConfirmationTitle, message, ProcessSelectedUsersDeletion); + var message = string.Format(ConstantString.DeleteConfirmation, _selectedUsers.Count); + await DialogServiceHelper.ShowConfirmationDialogAsync(ConstantString.DeleteConfirmationTitle, message, DeleteSelectedUsersAsync); } - private async Task ProcessSelectedUsersDeletion() + private async Task DeleteSelectedUsersAsync() { - var deleteIds = _selectedItems.Select(x => x.Id).ToArray(); - var deleteUsers = await UserManager.Users.Where(x => deleteIds.Contains(x.Id)).ToListAsync(); + var deleteIds = _selectedUsers.Select(u => u.Id).ToArray(); + var usersToDelete = await _userManager.Users.Where(x => deleteIds.Contains(x.Id)).ToListAsync(); - foreach (var user in deleteUsers) + foreach (var user in usersToDelete) { - var result = await UserManager.DeleteAsync(user); + var result = await _userManager.DeleteAsync(user); if (!result.Succeeded) { Snackbar.Add(string.Join(",", result.Errors.Select(e => e.Description)), Severity.Error); @@ -543,63 +557,68 @@ Logger.LogInformation("User deleted: {UserName} (ID: {UserId})", user.UserName, user.Id); } Snackbar.Add(ConstantString.DeleteSuccess, Severity.Info); - await OnRefresh(); + await RefreshDataGrid(); UserService.Refresh(); } + #endregion #region Email Verification and Password Reset - private async Task SendVerify(ApplicationUserDto item) + + private async Task SendVerificationEmail(ApplicationUserDto model) { - var user = await UserManager.FindByIdAsync(item.Id) - ?? throw new NotFoundException($"User not found: {item.Id}"); - var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + var user = await _userManager.FindByIdAsync(model.Id) + ?? throw new NotFoundException($"User not found: {model.Id}"); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); var callbackUrl = Navigation.GetUriWithQueryParameters( Navigation.ToAbsoluteUri(ConfirmEmail.PageUrl).AbsoluteUri, - new Dictionary { ["userId"] = item.Id, ["code"] = code, ["returnUrl"] = "/" }); - await Mediator.Publish(new UserActivationNotification(callbackUrl, item.Email, item.Id, item.UserName)); - Snackbar.Add(string.Format(L["Verification email sent to {0}."], item.Email), Severity.Info); - Logger.LogInformation("Verification email sent to user {UserName} (ID: {UserId})", item.UserName, item.Id); + new Dictionary { ["userId"] = model.Id, ["code"] = code, ["returnUrl"] = "/" }); + await Mediator.Publish(new UserActivationNotification(callbackUrl, model.Email, model.Id, model.UserName)); + Snackbar.Add(string.Format(Localizer["Verification email sent to {0}."], model.Email), Severity.Info); + Logger.LogInformation("Verification email sent to {UserName} (ID: {UserId})", model.UserName, model.Id); } - private async Task OnSendResetPassword(ApplicationUserDto item) + + private async Task SendResetPasswordEmail(ApplicationUserDto model) { - var user = await UserManager.FindByIdAsync(item.Id) - ?? throw new NotFoundException($"User not found: {item.Id}"); - var code = await UserManager.GeneratePasswordResetTokenAsync(user); + var user = await _userManager.FindByIdAsync(model.Id) + ?? throw new NotFoundException($"User not found: {model.Id}"); + var code = await _userManager.GeneratePasswordResetTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); var callbackUrl = Navigation.GetUriWithQueryParameters( Navigation.ToAbsoluteUri(ResetPassword.PageUrl).AbsoluteUri, new Dictionary { ["userId"] = user.Id, ["token"] = code }); - await Mediator.Publish(new ResetPasswordNotification(callbackUrl, item.Email, item.UserName)); - Snackbar.Add(string.Format(L["A new password for {0} has been sent via email. The user will be required to enter a new password upon initial login."], item.UserName), Severity.Info); + await Mediator.Publish(new ResetPasswordNotification(callbackUrl, model.Email, model.UserName)); + Snackbar.Add(string.Format(Localizer["A new password for {0} has been sent via email. The user will be required to change it upon first login."], model.UserName), Severity.Info); } - private async Task OnResetPassword(ApplicationUserDto item) + + private async Task ResetPasswordDialog(ApplicationUserDto model) { - var model = new ResetPasswordFormModel { Id = item.Id, DisplayName = item.DisplayName, UserName = item.UserName, ProfilePictureDataUrl = item.ProfilePictureDataUrl }; - var parameters = new DialogParameters { { x => x.Model, model } }; + var resetModel = new ResetPasswordFormModel { Id = model.Id, DisplayName = model.DisplayName, UserName = model.UserName, ProfilePictureDataUrl = model.ProfilePictureDataUrl }; + var parameters = new DialogParameters { { x => x.Model, resetModel } }; var options = new DialogOptions { CloseOnEscapeKey = true, CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; - var dialog = await DialogService.ShowAsync(L["Set Password"], parameters, options); + var dialog = await DialogService.ShowAsync(Localizer["Set Password"], parameters, options); var result = await dialog.Result; if (result is not null && !result.Canceled) { - await ProcessPasswordReset(item, model); + await ProcessPasswordResetAsync(model, resetModel); } } - private async Task ProcessPasswordReset(ApplicationUserDto item, ResetPasswordFormModel model) + + private async Task ProcessPasswordResetAsync(ApplicationUserDto model, ResetPasswordFormModel resetModel) { - var user = await UserManager.FindByIdAsync(item.Id!) - ?? throw new NotFoundException($"User not found: {item.Id}"); - var token = await UserManager.GeneratePasswordResetTokenAsync(user); - var result = await UserManager.ResetPasswordAsync(user, token, model.Password!); + var user = await _userManager.FindByIdAsync(model.Id!) + ?? throw new NotFoundException($"User not found: {model.Id}"); + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, resetModel.Password!); if (result.Succeeded) { if (!user.EmailConfirmed) { user.EmailConfirmed = true; - await UserManager.UpdateAsync(user); + await _userManager.UpdateAsync(user); } - Snackbar.Add(L["Reset password successfully."], Severity.Info); + Snackbar.Add(Localizer["Reset password successfully."], Severity.Info); } else { @@ -610,31 +629,27 @@ #endregion #region User Activation and Permission Management - private async Task SetActive(ApplicationUserDto item) - { - var user = await UserManager.FindByIdAsync(item.Id!) - ?? throw new NotFoundException($"User not found: {item.Id}"); - await ToggleUserActiveState(user, item); - } - private async Task ToggleUserActiveState(ApplicationUser user, ApplicationUserDto item) + private async Task ToggleUserActiveStatusAsync(ApplicationUserDto model) { + var user = await _userManager.FindByIdAsync(model.Id!) + ?? throw new NotFoundException($"User not found: {model.Id}"); if (user.IsActive) - await DeactivateUser(user, item); + await DeactivateUserAsync(user, model); else - await ActivateUser(user, item); + await ActivateUserAsync(user, model); } - private async Task ActivateUser(ApplicationUser user, ApplicationUserDto item) + private async Task ActivateUserAsync(ApplicationUser user, ApplicationUserDto model) { user.IsActive = true; user.LockoutEnd = null; - var result = await UserManager.UpdateAsync(user); + var result = await _userManager.UpdateAsync(user); if (result.Succeeded) { - item.IsActive = true; - item.LockoutEnd = null; - Snackbar.Add(L["The user has been activated."], Severity.Info); + model.IsActive = true; + model.LockoutEnd = null; + Snackbar.Add(Localizer["The user has been activated."], Severity.Info); } else { @@ -642,16 +657,16 @@ } } - private async Task DeactivateUser(ApplicationUser user, ApplicationUserDto item) + private async Task DeactivateUserAsync(ApplicationUser user, ApplicationUserDto model) { user.IsActive = false; user.LockoutEnd = DateTimeOffset.MaxValue; - var result = await UserManager.UpdateAsync(user); + var result = await _userManager.UpdateAsync(user); if (result.Succeeded) { - item.IsActive = false; - item.LockoutEnd = DateTimeOffset.MaxValue; - Snackbar.Add(L["The user has been inactivated."], Severity.Info); + model.IsActive = false; + model.LockoutEnd = DateTimeOffset.MaxValue; + Snackbar.Add(Localizer["The user has been inactivated."], Severity.Info); } else { @@ -659,104 +674,104 @@ } } - private async Task OnSetPermissions(ApplicationUserDto item) + private async Task OpenPermissionsDrawerAsync(ApplicationUserDto model) { _showPermissionsDrawer = true; - _permissions = await PermissionHelper.GetAllPermissionsByUserId(item.Id); + _permissions = await _permissionHelper.GetAllPermissionsByUserId(model.Id); } - private Task OnOpenChangedHandler(bool state) + private Task HandlePermissionsDrawerOpenChanged(bool state) { _showPermissionsDrawer = state; return Task.CompletedTask; } - private async Task OnAssignAllChangedHandler(List models) + private async Task AssignAllPermissionsAsync(List models) { - _processing = true; + _isProcessing = true; try { var userId = models.First().UserId; - var user = await UserManager.FindByIdAsync(userId!) + var user = await _userManager.FindByIdAsync(userId!) ?? throw new NotFoundException($"User not found: {userId}"); foreach (var model in models) { - await ProcessPermissionChange(user, model); + await UpdateUserPermissionAsync(user, model); } - Snackbar.Add(L["Authorization has been changed."], Severity.Info); - await ClearClaimsCache(user.Id); + Snackbar.Add(Localizer["Authorization has been changed."], Severity.Info); + await ClearUserClaimsCacheAsync(user.Id); } finally { - _processing = false; + _isProcessing = false; } } - private async Task ProcessPermissionChange(ApplicationUser user, PermissionModel model) + private async Task UpdateUserPermissionAsync(ApplicationUser user, PermissionModel model) { if (model.Assigned) { if (!string.IsNullOrEmpty(model.ClaimType) && !string.IsNullOrEmpty(model.ClaimValue)) - await UserManager.AddClaimAsync(user, new Claim(model.ClaimType, model.ClaimValue)); + await _userManager.AddClaimAsync(user, new Claim(model.ClaimType, model.ClaimValue)); } else { - var existingClaim = (await UserManager.GetClaimsAsync(user)).FirstOrDefault(c => c.Value == model.ClaimValue); + var existingClaim = (await _userManager.GetClaimsAsync(user)).FirstOrDefault(c => c.Value == model.ClaimValue); if (existingClaim is not null) - await UserManager.RemoveClaimAsync(user, existingClaim); + await _userManager.RemoveClaimAsync(user, existingClaim); } } - private async Task ClearClaimsCache(string userId) + private async Task ClearUserClaimsCacheAsync(string userId) { - var key = $"get-claims-by-{userId}"; - FusionCache.Remove(key); + var cacheKey = $"get-claims-by-{userId}"; + FusionCache.Remove(cacheKey); await Task.Delay(300); } - private async Task OnAssignChangedHandler(PermissionModel model) + private async Task HandlePermissionChangedAsync(PermissionModel model) { - _processing = true; + _isProcessing = true; try { var userId = model.UserId!; - var user = await UserManager.FindByIdAsync(userId) + var user = await _userManager.FindByIdAsync(userId) ?? throw new NotFoundException($"User not found: {userId}"); model.Assigned = !model.Assigned; if (model.Assigned) { if (!string.IsNullOrEmpty(model.ClaimType) && !string.IsNullOrEmpty(model.ClaimValue)) { - await UserManager.AddClaimAsync(user, new Claim(model.ClaimType, model.ClaimValue)); - Snackbar.Add(L["Permission assigned successfully."], Severity.Info); + await _userManager.AddClaimAsync(user, new Claim(model.ClaimType, model.ClaimValue)); + Snackbar.Add(Localizer["Permission assigned successfully."], Severity.Info); } } else { - var existingClaim = (await UserManager.GetClaimsAsync(user)).FirstOrDefault(c => c.Value == model.ClaimValue); + var existingClaim = (await _userManager.GetClaimsAsync(user)).FirstOrDefault(c => c.Value == model.ClaimValue); if (existingClaim is not null) - await UserManager.RemoveClaimAsync(user, existingClaim); - Snackbar.Add(L["Permission removed successfully."], Severity.Info); + await _userManager.RemoveClaimAsync(user, existingClaim); + Snackbar.Add(Localizer["Permission removed successfully."], Severity.Info); } - await ClearClaimsCache(user.Id); + await ClearUserClaimsCacheAsync(user.Id); } finally { - _processing = false; + _isProcessing = false; } } - #endregion + #endregion + #region Import and Export - #region export and import - private async Task OnExport() + private async Task ExportUsersAsync() { - _exporting = true; + _isExporting = true; try { var predicate = CreateSearchPredicate(); - var items = await UserManager.Users.Where(predicate) + var users = await _userManager.Users.Where(predicate) .Select(x => new ApplicationUserDto { Id = x.Id, @@ -770,78 +785,81 @@ TenantId = x.TenantId }).ToListAsync(); - var result = await ExportUsers(items); - await BlazorDownloadFileService.DownloadFileAsync($"{L["Users"]}.xlsx", result, "application/octet-stream"); + var fileData = await ExportUsersToExcelAsync(users); + await DownloadFileService.DownloadFileAsync($"{Localizer["Users"]}.xlsx", fileData, "application/octet-stream"); Snackbar.Add(ConstantString.ExportSuccess, Severity.Info); } - finally { _exporting = false; } + finally + { + _isExporting = false; + } } - private async Task ExportUsers(List items) => - await ExcelService.ExportAsync(items, new Dictionary> - { - { L["Id"], item => item.Id }, - { L["User Name"], item => item.UserName }, - { L["Full Name"], item => item.DisplayName }, - { L["Email"], item => item.Email }, - { L["Phone Number"], item => item.PhoneNumber }, - { L["Time Zone"], item => item.TimeZoneId }, - { L["Language"], item => item.LanguageCode }, - { L["Tenant Id"], item => item.TenantId }, - { L["Superior Id"], item => item.SuperiorId } - }, L["Users"]); - - private async Task OnImportData(IBrowserFile file) - { - _uploading = true; + private async Task ExportUsersToExcelAsync(List users) => + await ExcelService.ExportAsync(users, new Dictionary> + { + { Localizer["Id"], item => item.Id }, + { Localizer["User Name"], item => item.UserName }, + { Localizer["Full Name"], item => item.DisplayName }, + { Localizer["Email"], item => item.Email }, + { Localizer["Phone Number"], item => item.PhoneNumber }, + { Localizer["Time Zone"], item => item.TimeZoneId }, + { Localizer["Language"], item => item.LanguageCode }, + { Localizer["Tenant Id"], item => item.TenantId }, + { Localizer["Superior Id"], item => item.SuperiorId } + }, Localizer["Users"]); + + private async Task ImportDataAsync(IBrowserFile file) + { + _isUploading = true; try { using var stream = new MemoryStream(); await file.OpenReadStream(GlobalVariable.MaxAllowedSize).CopyToAsync(stream); - var result = await ImportUsers(stream); - if (result?.Succeeded == true) + var importResult = await ImportUsersFromExcelAsync(stream); + if (importResult?.Succeeded == true) { - if (result.Data != null) + if (importResult.Data != null) { - await ProcessImportedUsers(result.Data); - await _table.ReloadServerData(); + await ProcessImportedUsersAsync(importResult.Data); + await _dataGrid.ReloadServerData(); } Snackbar.Add(ConstantString.ImportSuccess, Severity.Info); } - else if (result?.Errors != null) + else if (importResult?.Errors != null) { - foreach (var error in result.Errors) + foreach (var error in importResult.Errors) { Snackbar.Add(error, Severity.Error); } } } - finally { _uploading = false; } + finally { _isUploading = false; } } - private async Task>> ImportUsers(MemoryStream stream) => + private async Task>> ImportUsersFromExcelAsync(MemoryStream stream) => await ExcelService.ImportAsync(stream.ToArray(), new Dictionary> - { - { L["User Name"], (row, item) => item.UserName = row[L["User Name"]]?.ToString() }, - { L["Full Name"], (row, item) => item.DisplayName = row[L["Full Name"]]?.ToString() }, - { L["Email"], (row, item) => item.Email = row[L["Email"]]?.ToString() }, - { L["Phone Number"], (row, item) => item.PhoneNumber = row[L["Phone Number"]]?.ToString() }, - { L["Time Zone"], (row, item) => item.TimeZoneId = row[L["Time Zone"]]?.ToString() }, - { L["Language"], (row, item) => item.LanguageCode = row[L["Language"]]?.ToString() }, - { L["Tenant Id"], (row, item) => item.TenantId = row[L["Tenant Id"]]?.ToString() }, - { L["Superior Id"], (row, item) => item.SuperiorId = row[L["Superior Id"]]?.ToString() } - }, L["Users"]); - - private async Task ProcessImportedUsers(IEnumerable users) + { + { Localizer["User Name"], (row, item) => item.UserName = row[Localizer["User Name"]]?.ToString() }, + { Localizer["Full Name"], (row, item) => item.DisplayName = row[Localizer["Full Name"]]?.ToString() }, + { Localizer["Email"], (row, item) => item.Email = row[Localizer["Email"]]?.ToString() }, + { Localizer["Phone Number"], (row, item) => item.PhoneNumber = row[Localizer["Phone Number"]]?.ToString() }, + { Localizer["Time Zone"], (row, item) => item.TimeZoneId = row[Localizer["Time Zone"]]?.ToString() }, + { Localizer["Language"], (row, item) => item.LanguageCode = row[Localizer["Language"]]?.ToString() }, + { Localizer["Tenant Id"], (row, item) => item.TenantId = row[Localizer["Tenant Id"]]?.ToString() }, + { Localizer["Superior Id"], (row, item) => item.SuperiorId = row[Localizer["Superior Id"]]?.ToString() } + }, Localizer["Users"]); + + private async Task ProcessImportedUsersAsync(IEnumerable users) { foreach (var user in users) { - if (!UserManager.Users.Any(x => x.UserName == user.UserName)) + if (!_userManager.Users.Any(x => x.UserName == user.UserName)) { - var result = await UserManager.CreateAsync(user); + var result = await _userManager.CreateAsync(user); if (result.Succeeded) { - await UserManager.AddToRolesAsync(user, new[] { RoleName.Basic }); + await _userManager.AddToRolesAsync(user, new[] { RoleName.Basic }); } else { @@ -850,18 +868,20 @@ } } } + #endregion - private async Task SendWelcomeNotification(string toEmail, string userName) + #region Welcome Email + + private async Task SendWelcomeEmailNotificationAsync(string email, string userName) { var callbackUrl = Navigation.GetUriWithQueryParameters( Navigation.ToAbsoluteUri(Login.PageUrl).AbsoluteUri, new Dictionary { ["returnUrl"] = "/" }); - await Mediator.Publish(new SendWelcomeNotification(callbackUrl, toEmail, userName)); - Logger.LogInformation("{UserName} Activated Successfully!", toEmail); + await Mediator.Publish(new SendWelcomeNotification(callbackUrl, email, userName)); + Logger.LogInformation("{UserName} activated successfully!", email); } - - -} \ No newline at end of file + #endregion +} diff --git a/src/Server.UI/Pages/PicklistSets/PicklistSets.razor b/src/Server.UI/Pages/PicklistSets/PicklistSets.razor index fe80d7463..f3e6413fb 100644 --- a/src/Server.UI/Pages/PicklistSets/PicklistSets.razor +++ b/src/Server.UI/Pages/PicklistSets/PicklistSets.razor @@ -18,7 +18,6 @@ @Title - @@ -91,7 +90,7 @@ { - } @@ -138,7 +137,7 @@ private Picklist? _searchPicklist; private int _defaultPageSize = 15; - private PicklistSetsWithPaginationQuery Query { get; set; } = new(); + private PicklistSetsWithPaginationQuery _picklistQuery { get; set; } = new(); private PicklistSetsAccessRights _accessRights = new(); @@ -165,16 +164,16 @@ try { _loading = true; - Query.CurrentUser = UserProfile; - Query.Keyword = _searchString; - Query.Picklist = _searchPicklist; - Query.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; - Query.SortDirection = (state.SortDefinitions.FirstOrDefault()?.Descending ?? true) + _picklistQuery.CurrentUser = UserProfile; + _picklistQuery.Keyword = _searchString; + _picklistQuery.Picklist = _searchPicklist; + _picklistQuery.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; + _picklistQuery.SortDirection = (state.SortDefinitions.FirstOrDefault()?.Descending ?? true) ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); - Query.PageNumber = state.Page + 1; - Query.PageSize = state.PageSize; - var result = await Mediator.Send(Query); + _picklistQuery.PageNumber = state.Page + 1; + _picklistQuery.PageSize = state.PageSize; + var result = await Mediator.Send(_picklistQuery); return new GridData { TotalItems = result.TotalItems, Items = result.Items }; } finally @@ -185,7 +184,7 @@ private async Task OnChangedListView(PickListView listview) { - Query.ListView = listview; + _picklistQuery.ListView = listview; await _table.ReloadServerData(); } diff --git a/src/Server.UI/Pages/Products/Components/ProductFormDialog.razor b/src/Server.UI/Pages/Products/Components/ProductFormDialog.razor index 2351751eb..1cdee7bad 100644 --- a/src/Server.UI/Pages/Products/Components/ProductFormDialog.razor +++ b/src/Server.UI/Pages/Products/Components/ProductFormDialog.razor @@ -15,12 +15,12 @@ - - + + - @@ -28,17 +28,17 @@ + For="@(() => _model.Description)" + @bind-Value="_model.Description"> + @bind-Value="_model.Brand"> @@ -48,18 +48,18 @@ + @bind-Value="_model.Price"> + @bind-Value="_model.Unit"> @@ -69,8 +69,8 @@ @L["The recommended size for uploading images is 640X320"] - @if (Model.Pictures is not null) + @if (_model.Pictures is not null) { - @foreach (var dto in Model.Pictures) + @foreach (var dto in _model.Pictures) { @@ -122,7 +122,7 @@ @code { [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; - [EditorRequired][Parameter] public AddEditProductCommand Model { get; set; } = default!; + [EditorRequired][Parameter] public AddEditProductCommand _model { get; set; } = default!; [Parameter] public Action? Refresh { get; set; } @@ -135,7 +135,7 @@ private async Task DeleteImage(ProductImage picture) { - if (Model.Pictures != null) + if (_model.Pictures != null) { var parameters = new DialogParameters { @@ -147,7 +147,7 @@ if (state is not null && !state.Canceled) { - Model.Pictures.Remove(picture); + _model.Pictures.Remove(picture); await UploadService.RemoveAsync(picture.Url); } } @@ -185,14 +185,8 @@ } else { - - var result = await UploadService.UploadAsync(new UploadRequest(filename, UploadType.Product, attachStream.ToArray(), overwrite: false, null, - new SixLabors.ImageSharp.Processing.ResizeOptions - { - Mode = SixLabors.ImageSharp.Processing.ResizeMode.Crop, - Size = new Size(640, 320) - })); - + var resizedStream = await ImageProcessor.ResizeAndCompressToJpegAsync(attachStream,640,320); + var result = await UploadService.UploadAsync(new UploadRequest(filename, UploadType.Product, resizedStream.ToArray(), overwrite: false, null)); list.Add(new ProductImage { Name = filename, Size = attachStream.Length, Url = result }); } } @@ -204,10 +198,10 @@ Snackbar.Add(L["Upload pictures successfully"], Severity.Info); - if (Model.Pictures is null) - Model.Pictures = list; + if (_model.Pictures is null) + _model.Pictures = list; else - Model.Pictures.AddRange(list); + _model.Pictures.AddRange(list); } finally { @@ -225,18 +219,16 @@ if (!_form!.IsValid) return; - var result = await Mediator.Send(Model); + var result = await Mediator.Send(_model); result.Match( data => { MudDialog.Close(DialogResult.Ok(true)); Snackbar.Add(ConstantString.SaveSuccess, Severity.Info); - return data; }, errors => { Snackbar.Add(errors, Severity.Error); - return 0; }); } finally @@ -253,19 +245,17 @@ await _form!.Validate().ConfigureAwait(false); if (!_form!.IsValid) return; - var result = await Mediator.Send(Model); + var result = await Mediator.Send(_model); result.Match( data => { Snackbar.Add(ConstantString.SaveSuccess, Severity.Info); Refresh?.Invoke(); - Model = new AddEditProductCommand(); - return data; + _model = new AddEditProductCommand(); }, errors => { Snackbar.Add(result.ErrorMessage, Severity.Error); - return 0; }); } finally diff --git a/src/Server.UI/Pages/Products/Products.razor b/src/Server.UI/Pages/Products/Products.razor index 8457b87cb..47135fa65 100644 --- a/src/Server.UI/Pages/Products/Products.razor +++ b/src/Server.UI/Pages/Products/Products.razor @@ -16,24 +16,30 @@ @Title - + + + + Hover="true" @ref="_productsGrid"> @L[_currentDto.GetClassDescription()] - + @@ -47,39 +53,45 @@ @if (_accessRights.Create) { + OnClick="OnCreateProduct"> @ConstantString.New } - + @if (_accessRights.Create) { - @ConstantString.Clone + + @ConstantString.Clone + } @if (_accessRights.Delete) { + OnClick="OnDeleteSelectedProducts"> @ConstantString.Delete } @if (_accessRights.Export) { - + @ConstantString.Export - + @ConstantString.ExportPDF } @if (_accessRights.Import) { - + - @ConstantString.Import @@ -93,8 +105,13 @@ @if (_accessRights.Search) { - + } @@ -107,31 +124,39 @@ @if (_accessRights.Edit || _accessRights.Delete) { - + IconColor="Color.Info" + AnchorOrigin="Origin.CenterLeft"> @if (_accessRights.Edit) { - @ConstantString.Edit + + @ConstantString.Edit + } @if (_accessRights.Delete) { - @ConstantString.Delete + + @ConstantString.Delete + } } else { - } - @ConstantString.Selected: @_selectedItems.Count @@ -141,7 +166,9 @@
@context.Item.Name - @context.Item.Description + + @context.Item.Description +
@@ -173,12 +200,11 @@ @code { - [CascadingParameter] private Task AuthState { get; set; } = default!; [CascadingParameter] private UserProfile? UserProfile { get; set; } public string? Title { get; private set; } private HashSet _selectedItems = new(); - private MudDataGrid _table = default!; + private MudDataGrid _productsGrid = default!; private ProductDto _currentDto = new(); private ProductsAccessRights _accessRights = new(); private bool _loading; @@ -187,10 +213,7 @@ private bool _pdfExporting; private int _defaultPageSize = 15; - - - private ProductsWithPaginationQuery Query { get; } = new(); - + private ProductsWithPaginationQuery _productsQuery { get; } = new(); protected override async Task OnInitializedAsync() { @@ -203,13 +226,14 @@ try { _loading = true; - Query.CurrentUser = UserProfile; - Query.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; - Query.SortDirection = state.SortDefinitions.FirstOrDefault()?.Descending ?? true ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); - Query.PageNumber = state.Page + 1; - Query.PageSize = state.PageSize; - var result = await Mediator.Send(Query).ConfigureAwait(false); - + _productsQuery.CurrentUser = UserProfile; + _productsQuery.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; + _productsQuery.SortDirection = state.SortDefinitions.FirstOrDefault()?.Descending ?? true + ? SortDirection.Descending.ToString() + : SortDirection.Ascending.ToString(); + _productsQuery.PageNumber = state.Page + 1; + _productsQuery.PageSize = state.PageSize; + var result = await Mediator.Send(_productsQuery).ConfigureAwait(false); return new GridData { TotalItems = result.TotalItems, Items = result.Items }; } finally @@ -218,57 +242,56 @@ } } - private void ConditionChanged(string s) + private void OnConditionChanged(string condition) { - InvokeAsync(_table.ReloadServerData); + InvokeAsync(_productsGrid.ReloadServerData); } private async Task OnSearch(string text) { _selectedItems = new HashSet(); - Query.Keyword = text; - await _table.ReloadServerData(); + _productsQuery.Keyword = text; + await _productsGrid.ReloadServerData(); } - private async Task OnChangedListView(ProductListView listview) + private async Task OnListViewChanged(ProductListView listview) { - Query.ListView = listview; - await _table.ReloadServerData(); + _productsQuery.ListView = listview; + await _productsGrid.ReloadServerData(); } private async Task OnRefresh() { ProductCacheKey.Refresh(); _selectedItems = new HashSet(); - Query.Keyword = string.Empty; - await _table.ReloadServerData(); + _productsQuery.Keyword = string.Empty; + await _productsGrid.ReloadServerData(); } - private async Task ShowEditFormDialog(string title, AddEditProductCommand command) + + private async Task ShowProductEditFormDialog(string title, AddEditProductCommand command) { var parameters = new DialogParameters { - { x => x.Refresh, () => _table.ReloadServerData() }, - { x => x.Model, command } + { x => x.Refresh, () => _productsGrid.ReloadServerData() }, + { x => x._model, command } }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; var dialog = await DialogService.ShowAsync(title, parameters, options); var state = await dialog.Result; - if (state is not null && !state.Canceled) { - await _table.ReloadServerData(); + await _productsGrid.ReloadServerData(); _selectedItems.Clear(); } } - private async Task OnCreate() + private async Task OnCreateProduct() { var command = new AddEditProductCommand { Pictures = new List() }; - - await ShowEditFormDialog(string.Format(ConstantString.CreateAnItem, L["Product"]), command); + await ShowProductEditFormDialog(string.Format(ConstantString.CreateAnItem, L["Product"]), command); } - private async Task OnClone() + private async Task OnCloneProduct() { var copy = _selectedItems.First(); var command = new AddEditProductCommand() @@ -279,18 +302,17 @@ Price = copy.Price, Unit = copy.Unit, Pictures = copy.Pictures - }; - await ShowEditFormDialog(string.Format(ConstantString.CreateAnItem, L["Product"]), command); + await ShowProductEditFormDialog(string.Format(ConstantString.CreateAnItem, L["Product"]), command); } - private async Task OnEdit(ProductDto dto) + private async Task OnEditProduct(ProductDto dto) { var command = Mapper.Map(dto); - await ShowEditFormDialog(string.Format(ConstantString.EditTheItem, L["Product"]), command); + await ShowProductEditFormDialog(string.Format(ConstantString.EditTheItem, L["Product"]), command); } - private async Task OnDelete(ProductDto dto) + private async Task OnDeleteProduct(ProductDto dto) { var contentText = string.Format(ConstantString.DeleteConfirmation, dto.Name); var command = new DeleteProductCommand(new[] { dto.Id }); @@ -298,13 +320,13 @@ { await InvokeAsync(async () => { - await _table.ReloadServerData(); + await _productsGrid.ReloadServerData(); _selectedItems.Clear(); }); }); } - private async Task OnDeleteChecked() + private async Task OnDeleteSelectedProducts() { var contentText = string.Format(ConstantString.DeleteConfirmWithSelected, _selectedItems.Count); var command = new DeleteProductCommand(_selectedItems.Select(x => x.Id).ToArray()); @@ -312,11 +334,10 @@ { await InvokeAsync(async () => { - await _table.ReloadServerData(); + await _productsGrid.ReloadServerData(); _selectedItems.Clear(); }); }); - } private async Task OnExport() @@ -324,15 +345,17 @@ _exporting = true; var request = new ExportProductsQuery { - Brand = Query.Brand, - Name = Query.Name, - MinPrice = Query.MinPrice, - MaxPrice = Query.MaxPrice, - Unit = Query.Unit, - Keyword = Query.Keyword, - ListView = Query.ListView, - OrderBy = _table.SortDefinitions.Values.FirstOrDefault()?.SortBy ?? "Id", - SortDirection = _table.SortDefinitions.Values.FirstOrDefault()?.Descending ?? false ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(), + Brand = _productsQuery.Brand, + Name = _productsQuery.Name, + MinPrice = _productsQuery.MinPrice, + MaxPrice = _productsQuery.MaxPrice, + Unit = _productsQuery.Unit, + Keyword = _productsQuery.Keyword, + ListView = _productsQuery.ListView, + OrderBy = _productsGrid.SortDefinitions.Values.FirstOrDefault()?.SortBy ?? "Id", + SortDirection = _productsGrid.SortDefinitions.Values.FirstOrDefault()?.Descending ?? false + ? SortDirection.Descending.ToString() + : SortDirection.Ascending.ToString(), CurrentUser = UserProfile, ExportType = ExportType.Excel }; @@ -346,7 +369,6 @@ { Snackbar.Add($"{result.ErrorMessage}", Severity.Error); } - _exporting = false; } @@ -355,16 +377,18 @@ _pdfExporting = true; var request = new ExportProductsQuery { - Brand = Query.Brand, - Name = Query.Name, - MinPrice = Query.MinPrice, - MaxPrice = Query.MaxPrice, - Unit = Query.Unit, - Keyword = Query.Keyword, - ListView = Query.ListView, + Brand = _productsQuery.Brand, + Name = _productsQuery.Name, + MinPrice = _productsQuery.MinPrice, + MaxPrice = _productsQuery.MaxPrice, + Unit = _productsQuery.Unit, + Keyword = _productsQuery.Keyword, + ListView = _productsQuery.ListView, CurrentUser = UserProfile, - OrderBy = _table.SortDefinitions.Values.FirstOrDefault()?.SortBy ?? "Id", - SortDirection = _table.SortDefinitions.Values.FirstOrDefault()?.Descending ?? false ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(), + OrderBy = _productsGrid.SortDefinitions.Values.FirstOrDefault()?.SortBy ?? "Id", + SortDirection = _productsGrid.SortDefinitions.Values.FirstOrDefault()?.Descending ?? false + ? SortDirection.Descending.ToString() + : SortDirection.Ascending.ToString(), ExportType = ExportType.PDF }; var result = await Mediator.Send(request); @@ -377,21 +401,19 @@ { Snackbar.Add($"{result.ErrorMessage}", Severity.Error); } - _pdfExporting = false; } - private async Task OnImportData(IBrowserFile file) + private async Task OnFileImport(IBrowserFile file) { _uploading = true; var stream = new MemoryStream(); await file.OpenReadStream(GlobalVariable.MaxAllowedSize).CopyToAsync(stream); var command = new ImportProductsCommand(file.Name, stream.ToArray()); - var result = await Mediator.Send(command); if (result.Succeeded) { - await _table.ReloadServerData(); + await _productsGrid.ReloadServerData(); Snackbar.Add($"{ConstantString.ImportSuccess}", Severity.Info); } else @@ -401,8 +423,6 @@ Snackbar.Add($"{msg}", Severity.Error); } } - _uploading = false; } - -} \ No newline at end of file +} diff --git a/src/Server.UI/Pages/SystemManagement/AuditTrails.razor b/src/Server.UI/Pages/SystemManagement/AuditTrails.razor index 35fa57dc0..2b5e9fe60 100644 --- a/src/Server.UI/Pages/SystemManagement/AuditTrails.razor +++ b/src/Server.UI/Pages/SystemManagement/AuditTrails.razor @@ -13,41 +13,60 @@ + Hover="true" @ref="_auditTrailGrid"> + @Title - + + - @ConstantString.Refresh - - + + + + - + - - @@ -84,11 +103,11 @@ @if (context.Item.IsSuccessful) { - + } else { - + } @L["History data"] @@ -103,7 +122,11 @@ - @foreach (var field in context.Item.NewValues?.Any() ?? false ? context.Item.NewValues : context.Item.OldValues?.Any() ?? false ? context.Item.OldValues : new Dictionary()) + @foreach (var field in (context.Item.NewValues?.Any() ?? false + ? context.Item.NewValues + : context.Item.OldValues?.Any() ?? false + ? context.Item.OldValues + : new Dictionary())) { @field.Key @@ -115,13 +138,13 @@
- @if (@context.Item.IsSuccessful == false) + @if (context.Item.IsSuccessful == false) { - + @context.Item.ErrorMessage } - + @context.Item.DebugView @@ -139,34 +162,43 @@
-@code -{ +@code { + // Public page title public string Title { get; private set; } = "Audit Trails"; - private MudDataGrid _table = null!; + + // Private fields using underscore and camelCase naming + private MudDataGrid _auditTrailGrid = null!; private bool _loading; private int _defaultPageSize = 15; private readonly AuditTrailDto _currentDto = new(); - private AuditTrailsWithPaginationQuery Query { get; } = new(); + + // _auditTrailsQuery parameters for pagination; using PascalCase for public properties and _camelCase for private fields + private AuditTrailsWithPaginationQuery _auditTrailsQuery { get; } = new(); + [CascadingParameter] private UserProfile? UserProfile { get; set; } private AuditTrailsAccessRights _accessRights = new(); + protected override async Task OnInitializedAsync() { + // Set page title based on current DTO's class description Title = L[_currentDto.GetClassDescription()]; _accessRights = await PermissionService.GetAccessRightsAsync(); } + // Server data loading callback for the grid private async Task> ServerReload(GridState state) { try { _loading = true; - Query.CurrentUser = UserProfile; - Query.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; - Query.SortDirection = state.SortDefinitions.FirstOrDefault()?.Descending ?? true ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); - Query.PageNumber = state.Page + 1; - Query.PageSize = state.PageSize; - - var result = await Mediator.Send(Query).ConfigureAwait(false); + _auditTrailsQuery.CurrentUser = UserProfile; + _auditTrailsQuery.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; + _auditTrailsQuery.SortDirection = (state.SortDefinitions.FirstOrDefault()?.Descending ?? true) + ? SortDirection.Descending.ToString() + : SortDirection.Ascending.ToString(); + _auditTrailsQuery.PageNumber = state.Page + 1; + _auditTrailsQuery.PageSize = state.PageSize; + var result = await Mediator.Send(_auditTrailsQuery).ConfigureAwait(false); return new GridData { TotalItems = result.TotalItems, Items = result.Items }; } finally @@ -175,34 +207,39 @@ } } - private async Task OnChangedListView(AuditTrailListView listview) + // Event handler for when the list view is changed + private async Task OnListViewChanged(AuditTrailListView listview) { - Query.ListView = listview; - await _table.ReloadServerData(); + _auditTrailsQuery.ListView = listview; + await _auditTrailGrid.ReloadServerData(); } - private async Task OnSearch(string text) + // Event handler for keyword search + private async Task OnKeywordSearch(string text) { - Query.Keyword = text; - await _table.ReloadServerData(); + _auditTrailsQuery.Keyword = text; + await _auditTrailGrid.ReloadServerData(); } - private async Task OnSearch(AuditType? val) + // Event handler for audit type search + private async Task OnAuditTypeSearch(AuditType? auditType) { - Query.AuditType = val; - await _table.ReloadServerData(); + _auditTrailsQuery.AuditType = auditType; + await _auditTrailGrid.ReloadServerData(); } - private async Task OnRefresh() + // Refresh callback + private async Task OnRefreshAuditTrails() { AuditTrailsCacheKey.Refresh(); - Query.Keyword = string.Empty; - await _table.ReloadServerData(); + _auditTrailsQuery.Keyword = string.Empty; + await _auditTrailGrid.ReloadServerData(); } - private Task OnShowDetail(AuditTrailDto dto) + // Toggle detail view for a specific audit trail + private Task OnToggleDetail(AuditTrailDto dto) { dto.ShowDetails = !dto.ShowDetails; return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Server.UI/Pages/SystemManagement/SystemLogs.razor b/src/Server.UI/Pages/SystemManagement/SystemLogs.razor index 3d3736961..6ddb261c8 100644 --- a/src/Server.UI/Pages/SystemManagement/SystemLogs.razor +++ b/src/Server.UI/Pages/SystemManagement/SystemLogs.razor @@ -18,7 +18,6 @@ FixedHeader="true" FixedFooter="false" RowsPerPage="@_defaultPageSize" - Height="calc(100vh - 360px)" Style="min-height:700px" Loading="@_loading" ColumnResizeMode="ResizeMode.Column" @@ -29,7 +28,7 @@ @Title - + @@ -51,8 +50,8 @@ } - - + @@ -138,12 +137,11 @@ private MudDataGrid _table = default!; private readonly SystemLogDto _currentDto = new(); private readonly int _defaultPageSize = 15; - private TimeSpan _localTimeOffset => UserProfile?.LocalTimeOffset ?? TimeSpan.Zero; private bool _loading; private bool _clearing; private LogsAccessRights _accessRights = new(); - private SystemLogsWithPaginationQuery Query { get; } = new(); + private SystemLogsWithPaginationQuery _systemLogsQuery { get; } = new(); protected override async Task OnInitializedAsync() { @@ -157,12 +155,12 @@ try { _loading = true; - Query.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; - Query.SortDirection = state.SortDefinitions.FirstOrDefault()?.Descending ?? true ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); - Query.PageNumber = state.Page + 1; - Query.PageSize = state.PageSize; - Query.LocalTimeOffset = _localTimeOffset; - var result = await Mediator.Send(Query).ConfigureAwait(false); + _systemLogsQuery.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; + _systemLogsQuery.SortDirection = state.SortDefinitions.FirstOrDefault()?.Descending ?? true ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); + _systemLogsQuery.PageNumber = state.Page + 1; + _systemLogsQuery.PageSize = state.PageSize; + _systemLogsQuery.CurrentUser = UserProfile; + var result = await Mediator.Send(_systemLogsQuery).ConfigureAwait(false); return new GridData { TotalItems = result.TotalItems, Items = result.Items }; } @@ -174,25 +172,25 @@ private async Task OnChangedListView(SystemLogListView listview) { - Query.ListView = listview; + _systemLogsQuery.ListView = listview; await _table.ReloadServerData(); } private async Task OnChangedLevel(LogLevel? level) { - Query.Level = level; + _systemLogsQuery.Level = level; await _table.ReloadServerData(); } private async Task OnSearch(string text) { - Query.Keyword = text; + _systemLogsQuery.Keyword = text; await _table.ReloadServerData(); } private async Task OnRefresh() { - Query.Keyword = string.Empty; + _systemLogsQuery.Keyword = string.Empty; SystemLogsCacheKey.Refresh(); await _table.ReloadServerData(); } diff --git a/src/Server.UI/Pages/Tenants/TenantFormDialog.razor b/src/Server.UI/Pages/Tenants/TenantFormDialog.razor index ad518fda3..f42ee84b5 100644 --- a/src/Server.UI/Pages/Tenants/TenantFormDialog.razor +++ b/src/Server.UI/Pages/Tenants/TenantFormDialog.razor @@ -4,26 +4,26 @@ - + - - - + @@ -39,7 +39,7 @@ @code { [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; - [EditorRequired] [Parameter] public AddEditTenantCommand Model { get; set; } = default!; + [EditorRequired] [Parameter] public AddEditTenantCommand _model { get; set; } = default!; private MudForm? _form; private bool _saving; @@ -53,7 +53,7 @@ if (!_form!.IsValid) return; - var result = await Mediator.Send(Model); + var result = await Mediator.Send(_model); if (result.Succeeded) { diff --git a/src/Server.UI/Pages/Tenants/Tenants.razor b/src/Server.UI/Pages/Tenants/Tenants.razor index 64443cb4f..0389962c2 100644 --- a/src/Server.UI/Pages/Tenants/Tenants.razor +++ b/src/Server.UI/Pages/Tenants/Tenants.razor @@ -13,43 +13,40 @@ + Hover="true" @ref="_tenantGrid"> - + + @Title - + - @ConstantString.Refresh @if (_accessRights.Create) { - + @ConstantString.New } @if (_accessRights.Delete) { - + @ConstantString.Delete } @@ -57,8 +54,13 @@ @if (_accessRights.Search) { - + } @@ -66,35 +68,45 @@ - + @if (_accessRights.Edit || _accessRights.Delete) { - + @if (_accessRights.Edit) { - @ConstantString.Edit + + @ConstantString.Edit + } @if (_accessRights.Delete) { - @ConstantString.Delete + + @ConstantString.Delete + } } else { + StartIcon="@Icons.Material.Filled.DoNotTouch" + IconColor="Color.Secondary" + Size="Size.Small" + Color="Color.Surface"> @ConstantString.NoAllowed } - + @L["Selected"]: @_selectedItems.Count @@ -102,53 +114,58 @@
- @context.Item.Description + + @context.Item.Description +
-
-
-@code -{ +@code { + // Cascading parameters [CascadingParameter] private Task AuthState { get; set; } = default!; + + // Public property for page title public string? Title { get; private set; } + + // Private fields private int _defaultPageSize = 15; private HashSet _selectedItems = new(); - private MudDataGrid _table = default!; + private MudDataGrid _tenantGrid = default!; private TenantDto _currentDto = new(); private TenantsAccessRights _accessRights = new(); private string _searchString = string.Empty; private bool _loading; - - private TenantsWithPaginationQuery Query { get; } = new(); + // Private pagination query (using a uniform naming convention) + private TenantsWithPaginationQuery _tenantsQuery { get; } = new(); protected override async Task OnInitializedAsync() { Title = L[_currentDto.GetClassDescription()]; _accessRights = await PermissionService.GetAccessRightsAsync(); } - + + // Server data loading callback private async Task> ServerReload(GridState state) { try { _loading = true; - Query.Keyword = _searchString; + _tenantsQuery.Keyword = _searchString; var sortDefinition = state.SortDefinitions.FirstOrDefault(); - Query.OrderBy = sortDefinition?.SortBy ?? "Id"; - Query.SortDirection = (sortDefinition != null && sortDefinition.Descending) + _tenantsQuery.OrderBy = sortDefinition?.SortBy ?? "Id"; + _tenantsQuery.SortDirection = (sortDefinition != null && sortDefinition.Descending) ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); - Query.PageNumber = state.Page + 1; - Query.PageSize = state.PageSize; - var result = await Mediator.Send(Query); + _tenantsQuery.PageNumber = state.Page + 1; + _tenantsQuery.PageSize = state.PageSize; + var result = await Mediator.Send(_tenantsQuery); return new GridData { TotalItems = result.TotalItems, Items = result.Items }; } finally @@ -157,60 +174,53 @@ } } - private async Task OnSearch(string text) + // Search event handler + private async Task OnSearchTenant(string text) { _selectedItems = new HashSet(); _searchString = text; - await _table.ReloadServerData(); + await _tenantGrid.ReloadServerData(); } - private async Task OnRefresh() + // Refresh event handler + private async Task OnRefreshTenants() { TenantCacheKey.Refresh(); _selectedItems = new HashSet(); _searchString = string.Empty; - await _table.ReloadServerData(); - } - private async Task ShowEditDialog(AddEditTenantCommand command, string title) - { - var parameters = new DialogParameters - { - { x => x.Model, command } - }; - var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }; - var dialog =await DialogService.ShowAsync(title, parameters, options); - var state = await dialog.Result; - if (state is not null && !state.Canceled) - { - await _table.ReloadServerData(); - } + await _tenantGrid.ReloadServerData(); } - private async Task OnCreate() + + // Create tenant event handler + private async Task OnCreateTenant() { - await ShowEditDialog(new AddEditTenantCommand(), L["Create a new Tenant"]); + await ShowEditTenantDialog(new AddEditTenantCommand(), L["Create a new Tenant"]); } - private async Task OnEdit(TenantDto dto) + // Edit tenant event handler + private async Task OnEditTenant(TenantDto dto) { var command = Mapper.Map(dto); - await ShowEditDialog(command, L["Edit the Tenant"]); + await ShowEditTenantDialog(command, L["Edit the Tenant"]); } - private async Task OnDelete(TenantDto dto) + // Delete tenant event handler + private async Task OnDeleteTenant(TenantDto dto) { var command = new DeleteTenantCommand(new[] { dto.Id }); var contentText = string.Format(ConstantString.DeleteConfirmationWithId, dto.Id); await DialogServiceHelper.ShowDeleteConfirmationDialogAsync(command, L["Delete the Tenant"], contentText, async () => { await InvokeAsync(async () => - { - await _table.ReloadServerData(); - _selectedItems.Clear(); - }); + { + await _tenantGrid.ReloadServerData(); + _selectedItems.Clear(); + }); }); } - private async Task OnDeleteChecked() + // Delete selected tenants event handler + private async Task OnDeleteSelectedTenants() { var command = new DeleteTenantCommand(_selectedItems.Select(x => x.Id).ToArray()); var contentText = string.Format(ConstantString.DeleteConfirmation, _selectedItems.Count); @@ -218,9 +228,25 @@ { await InvokeAsync(async () => { - await _table.ReloadServerData(); + await _tenantGrid.ReloadServerData(); _selectedItems.Clear(); }); }); } -} \ No newline at end of file + + // Helper method: Show edit dialog for tenant + private async Task ShowEditTenantDialog(AddEditTenantCommand command, string title) + { + var parameters = new DialogParameters + { + { x => x._model, command } + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = await DialogService.ShowAsync(title, parameters, options); + var state = await dialog.Result; + if (state is not null && !state.Canceled) + { + await _tenantGrid.ReloadServerData(); + } + } +} diff --git a/src/Server.UI/Services/ImageProcessor.cs b/src/Server.UI/Services/ImageProcessor.cs new file mode 100644 index 000000000..c74d00b2a --- /dev/null +++ b/src/Server.UI/Services/ImageProcessor.cs @@ -0,0 +1,62 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Formats.Jpeg; +using Size = SixLabors.ImageSharp.Size; +using ResizeMode = SixLabors.ImageSharp.Processing.ResizeMode; +namespace CleanArchitecture.Blazor.Server.UI.Services; +public static class ImageProcessor +{ + /// + /// Resizes and compresses an image from a stream to JPEG format + /// + /// The source stream containing the image + /// Maximum width of the resized image + /// Maximum height of the resized image + /// JPEG compression quality (0-100) + /// A memory stream containing the resized and compressed JPEG image + public static async Task ResizeAndCompressToJpegAsync( + Stream sourceStream, + int maxWidth = 1200, + int maxHeight = 1200, + int quality = 80) + { + // Ensure the stream is at the beginning + sourceStream.Position = 0; + + // Load image from stream + using var image = await Image.LoadAsync(sourceStream); + + // Resize the image, maintaining aspect ratio + image.Mutate(x => x.Resize(new ResizeOptions + { + Size = new Size(maxWidth, maxHeight), + Mode = ResizeMode.Max // Maintains aspect ratio + })); + + // Create new stream for the resized image + var resizedStream = new MemoryStream(); + + // Save as JPEG with compression + await image.SaveAsJpegAsync(resizedStream, new JpegEncoder + { + Quality = quality // Compression level (0-100) + }); + + // Reset position to beginning of stream + resizedStream.Position = 0; + + return resizedStream; + } + + /// + /// Checks if a file has an image extension + /// + /// The file name to check + /// True if the file has an image extension + public static bool IsImageFile(string fileName) + { + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || + ext == ".gif" || ext == ".bmp" || ext == ".webp"; + } +} \ No newline at end of file diff --git a/src/Server.UI/Themes/Theme.cs b/src/Server.UI/Themes/Theme.cs index 665b1c0b4..ccc1c95cb 100644 --- a/src/Server.UI/Themes/Theme.cs +++ b/src/Server.UI/Themes/Theme.cs @@ -26,7 +26,7 @@ public static MudTheme ApplicationTheme() Surface = "#ffffff", DrawerBackground = "#ffffff", TextPrimary = "#000000", - TextSecondary = "#333333", + TextSecondary = "#757575", SecondaryContrastText = "#F5F5F5", TextDisabled = "#B0B0B0", ActionDefault = "#80838b", @@ -61,7 +61,7 @@ public static MudTheme ApplicationTheme() DrawerText = "rgba(255, 255, 255, 0.75)", DrawerBackground = "#222222", TextPrimary = "#DADADA", - TextSecondary = "rgba(255, 255, 255, 0.7)", + TextSecondary = "#A6A6A6", TextDisabled = "rgba(255, 255, 255, 0.38)", ActionDefault = "#e8eaed", ActionDisabled = "rgba(255, 255, 255, 0.26)", @@ -165,45 +165,15 @@ public static MudTheme ApplicationTheme() { FontSize = "0.625rem", FontWeight = "400", - LineHeight = "1.5", + LineHeight = "1.4", + LetterSpacing = "normal" }, Overline = new OverlineTypography { FontSize = "0.625rem", FontWeight = "300", - LineHeight = "2", - } - }, - Shadows = new Shadow - { - Elevation = new[] - { - "none", - "0 2px 4px -1px rgba(6, 24, 44, 0.2)", - "0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12)", - "0 30px 60px rgba(0,0,0,0.12)", - "0 6px 12px -2px rgba(50,50,93,0.25),0 3px 7px -3px rgba(0,0,0,0.3)", - "0 50px 100px -20px rgba(50,50,93,0.25),0 30px 60px -30px rgba(0,0,0,0.3)", - "0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12)", - "0px 4px 5px -2px rgba(0,0,0,0.2),0px 7px 10px 1px rgba(0,0,0,0.14),0px 2px 16px 1px rgba(0,0,0,0.12)", - "0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)", - "0px 5px 6px -3px rgba(0,0,0,0.2),0px 9px 12px 1px rgba(0,0,0,0.14),0px 3px 16px 2px rgba(0,0,0,0.12)", - "0px 6px 6px -3px rgba(0,0,0,0.2),0px 10px 14px 1px rgba(0,0,0,0.14),0px 4px 18px 3px rgba(0,0,0,0.12)", - "0px 6px 7px -4px rgba(0,0,0,0.2),0px 11px 15px 1px rgba(0,0,0,0.14),0px 4px 20px 3px rgba(0,0,0,0.12)", - "0px 7px 8px -4px rgba(0,0,0,0.2),0px 12px 17px 2px rgba(0,0,0,0.14),0px 5px 22px 4px rgba(0,0,0,0.12)", - "0px 7px 8px -4px rgba(0,0,0,0.2),0px 13px 19px 2px rgba(0,0,0,0.14),0px 5px 24px 4px rgba(0,0,0,0.12)", - "0px 7px 9px -4px rgba(0,0,0,0.2),0px 14px 21px 2px rgba(0,0,0,0.14),0px 5px 26px 4px rgba(0,0,0,0.12)", - "0px 8px 9px -5px rgba(0,0,0,0.2),0px 15px 22px 2px rgba(0,0,0,0.14),0px 6px 28px 5px rgba(0,0,0,0.12)", - "0px 8px 10px -5px rgba(0,0,0,0.2),0px 16px 24px 2px rgba(0,0,0,0.14),0px 6px 30px 5px rgba(0,0,0,0.12)", - "0px 8px 11px -5px rgba(0,0,0,0.2),0px 17px 26px 2px rgba(0,0,0,0.14),0px 6px 32px 5px rgba(0,0,0,0.12)", - "0px 9px 11px -5px rgba(0,0,0,0.2),0px 18px 28px 2px rgba(0,0,0,0.14),0px 7px 34px 6px rgba(0,0,0,0.12)", - "0px 9px 12px -6px rgba(0,0,0,0.2),0px 19px 29px 2px rgba(0,0,0,0.14),0px 7px 36px 6px rgba(0,0,0,0.12)", - "0px 10px 13px -6px rgba(0,0,0,0.2),0px 20px 31px 3px rgba(0,0,0,0.14),0px 8px 38px 7px rgba(0,0,0,0.12)", - "0px 10px 13px -6px rgba(0,0,0,0.2),0px 21px 33px 3px rgba(0,0,0,0.14),0px 8px 40px 7px rgba(0,0,0,0.12)", - "0px 10px 14px -6px rgba(0,0,0,0.2),0px 22px 35px 3px rgba(0,0,0,0.14),0px 8px 42px 7px rgba(0,0,0,0.12)", - "0 50px 100px -20px rgba(50, 50, 93, 0.25), 0 30px 60px -30px rgba(0, 0, 0, 0.30)", - "2.8px 2.8px 2.2px rgba(0, 0, 0, 0.02),6.7px 6.7px 5.3px rgba(0, 0, 0, 0.028),12.5px 12.5px 10px rgba(0, 0, 0, 0.035),22.3px 22.3px 17.9px rgba(0, 0, 0, 0.042),41.8px 41.8px 33.4px rgba(0, 0, 0, 0.05),100px 100px 80px rgba(0, 0, 0, 0.07)", - "0px 0px 20px 0px rgba(0, 0, 0, 0.05)" + LineHeight = "1.5", + LetterSpacing = "0.1em" } } }; diff --git a/src/Server.UI/wwwroot/css/app.css b/src/Server.UI/wwwroot/css/app.css index a29e5abd1..86a772d4a 100644 --- a/src/Server.UI/wwwroot/css/app.css +++ b/src/Server.UI/wwwroot/css/app.css @@ -19,15 +19,16 @@ background-color: var(--mud-palette-surface); } -.mud-input-control > .mud-input-control-input-container > .mud-input-label-inputcontrol { - font-size: var(--mud-typography-subtitle1-size) !important; -} -.mud-input > input.mud-input-root, div.mud-input-slot.mud-input-root { - font-size: var(--mud-typography-default-size) !important; +.mud-input-control-margin-dense .mud-input > input.mud-input-root, +.mud-input-control-margin-dense div.mud-input-slot.mud-input-root { + font-size: 0.90em; } -.mud-input > textarea.mud-input-root { + +.mud-input-control > .mud-input-control-input-container > .mud-input-label-inputcontrol { font-size: var(--mud-typography-default-size) !important; } + + .mud-simple-table table * tr > td, .mud-simple-table table * tr th { font-size: var(--mud-typography-default-size) !important; } @@ -44,19 +45,10 @@ .mud-table-cell { font-size: var(--mud-typography-default-size) !important; } - - -.mud-typography-subtitle2 { - font-size: var(--mud-typography-subtitle2-size); - color: var(--mud-palette-text-secondary); -} -.mud-typography-body1 { - font-size: var(--mud-typography-body1-size); +.mud-table-dense .mud-table-cell { + font-size: var(--mud-typography-body2-size) !important; } -.mud-typography-body2 { - font-size: var(--mud-typography-body2-size); -} .mud-button-outlined-size-small { font-size: var(--mud-typography-body2-size);