diff --git a/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/0.png b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/0.png new file mode 100644 index 0000000000..de27c1bf4a Binary files /dev/null and b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/0.png differ diff --git a/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/1.png b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/1.png new file mode 100644 index 0000000000..df10f1e70e Binary files /dev/null and b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/1.png differ diff --git a/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/2.png b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/2.png new file mode 100644 index 0000000000..6df238e8f9 Binary files /dev/null and b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/2.png differ diff --git a/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/3.png b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/3.png new file mode 100644 index 0000000000..cff88fa288 Binary files /dev/null and b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/3.png differ diff --git a/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/POST.md b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/POST.md new file mode 100644 index 0000000000..3a29539a51 --- /dev/null +++ b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/POST.md @@ -0,0 +1,490 @@ +# Switching Between Organization Units + +In most companies, a user belongs to more than one organization. Also, in some applications, we need to filter the data shown depending on the logged-in user's organization. For such scenarios, allowing users to select one of the organizations they belong to is a good practice. + +![0](0.png) + +## Creating a Data Filter with Organization Units + +### IHasOrganization + +First, we need to create a data filter that filters data based on the organization unit. + +The `IHasOrganization` interface is used to define the organization unit property in the entity classes, and used to filter the data based on the organization unit. + +```csharp +public interface IHasOrganization +{ + public Guid? OrganizationId { get; set; } +} +``` + +```csharp +public class Book : AggregateRoot, IHasOrganization +{ + public string Name { get; set; } + + public string Isbn { get; set; } + + public Guid? OrganizationId { get; set; } +} +``` + +### Entity Framework Core DbContext Implementation + +We will override the `ShouldFilterEntity` and `CreateFilterExpression` methods in the `BookStoreDbContext` class to configure the data filter for the entity that implements the `IHasOrganization` interface. + +```csharp +public class BookStoreDbContext : AbpDbContext +{ + // Your others DbSet properties... + + public DbSet Books { get; set; } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // Your configure code... + + builder.Entity(b => + { + b.ToTable(BookStoreConsts.DbTablePrefix + "Book", BookStoreConsts.DbSchema); + b.ConfigureByConvention(); + }); + } + + public CurrentOrganizationIdProvider CurrentOrganizationIdProvider => LazyServiceProvider.LazyGetRequiredService(); + + protected override bool ShouldFilterEntity(IMutableEntityType entityType) + { + if (typeof(IHasOrganization).IsAssignableFrom(typeof(TEntity))) + { + return true; + } + + return base.ShouldFilterEntity(entityType); + } + + protected override Expression> CreateFilterExpression(ModelBuilder modelBuilder) + { + var expression = base.CreateFilterExpression(modelBuilder); + + if (typeof(IHasOrganization).IsAssignableFrom(typeof(TEntity))) + { + Expression> hasOrganizationIdFilter = e => EF.Property(e, "OrganizationId") == CurrentOrganizationIdProvider.CurrentOrganizationId; + expression = expression == null ? hasOrganizationIdFilter : QueryFilterExpressionHelper.CombineExpressions(expression, hasOrganizationIdFilter); + } + + return expression; + } +} +``` + +### The CurrentOrganizationIdProvider + +This class is used to get the current `organization id`, We will use the `AsyncLocal` class to store the current `organization id`, The `Change` method is used to change the current `organization id`. This service is registered as a singleton service. + +```csharp +public class CurrentOrganizationIdProvider : ISingletonDependency +{ + private readonly AsyncLocal _currentOrganizationId = new AsyncLocal(); + + public Guid? CurrentOrganizationId => _currentOrganizationId.Value; + + public virtual IDisposable Change(Guid? organizationId) + { + var parent = CurrentOrganizationId; + _currentOrganizationId.Value = organizationId; + return new DisposeAction(() => + { + _currentOrganizationId.Value = parent; + }); + } +} +``` + +## Domain Service Implementation + +We will store the current `organization id` in the cache for the logged-in user. at the same time, we want to store it per browser. So we also add the different browser info for every logged-in user. + +In the `BrowserInfoClaimsPrincipalContributor` class, We add a random `BrowserInfo` claim to the logged-in user. And we will use `user id` and `browser info `as a cache key. + +```csharp +public static class CurrentUserExtensions +{ + public static Guid? GetBrowserInfo(this ICurrentUser currentUser) + { + var claimValue = currentUser.FindClaimValue("BrowserInfo"); + if (claimValue != null && Guid.TryParse(claimValue, out var result)) + { + return result; + } + return null; + } +} + +public class BrowserInfoClaimsPrincipalContributor : IAbpClaimsPrincipalContributor, ITransientDependency +{ + public Task ContributeAsync(AbpClaimsPrincipalContributorContext context) + { + var identity = context.ClaimsPrincipal.Identities.FirstOrDefault(); + identity?.AddClaim(new Claim("BrowserInfo", Guid.NewGuid().ToString())); + return Task.CompletedTask; + } +} +``` + +## Application Service Implementation + +The `CurrentOrganizationAppService` to get/change the current organization for the logged-in user. `BookAppService` to get the books based on the current `organization id`. + +```csharp +[Authorize] +public class CurrentOrganizationAppService : BookStoreAppService, ICurrentOrganizationAppService +{ + private readonly IdentityUserManager _identityUserManager; + private readonly IDistributedCache _cache; + + public CurrentOrganizationAppService(IdentityUserManager identityUserManager, IDistributedCache cache) + { + _identityUserManager = identityUserManager; + _cache = cache; + } + + public virtual async Task> GetOrganizationListAsync() + { + var user = await _identityUserManager.FindByIdAsync(CurrentUser.GetId().ToString()); + var organizationUnits = await _identityUserManager.GetOrganizationUnitsAsync(user); + return organizationUnits.Select(ou => new OrganizationDto + { + Id = ou.Id, + DisplayName = ou.DisplayName + }).ToList(); + } + + public virtual async Task GetCurrentOrganizationIdAsync() + { + var cacheKey = CurrentUser.Id.ToString() + ":" + CurrentUser.GetBrowserInfo(); + return (await _cache.GetAsync(cacheKey))?.OrganizationId; + } + + public virtual async Task ChangeAsync(Guid? organizationId) + { + var cacheKey = CurrentUser.Id.ToString() + ":" + CurrentUser.GetBrowserInfo(); + await _cache.SetAsync(cacheKey, new CurrentOrganizationIdCacheItem + { + OrganizationId = organizationId + }); + } +} +``` + +```csharp +[Authorize] +public class BookAppService : BookStoreAppService, IBookAppService +{ + private readonly IBasicRepository _bookRepository; + + public BookAppService(IBasicRepository bookRepository) + { + _bookRepository = bookRepository; + } + + public virtual async Task> GetListAsync() + { + var books = await _bookRepository.GetListAsync(); + return books.Select(book => new BookDto + { + Id = book.Id, + Name = book.Name, + Isbn = book.Isbn, + OrganizationId = book.OrganizationId + }).ToList(); + } +} +``` + +## Seed Sample Data + +Let's seed some sample data for the `Book` and `Organization` entities. + +We added two organization units, `USA Branch` and `Turkey Branch`, and some books to each organization unit. Also, we added the `admin` user to both organization units. + +```csharp +public class BooksDataSeedContributor : IDataSeedContributor, ITransientDependency +{ + public Guid UsaBranchId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + public Guid TurkeyBranchId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + + private readonly IBasicRepository _bookRepository; + private readonly OrganizationUnitManager _organizationUnitManager; + private readonly IOrganizationUnitRepository _organizationUnitRepository; + private readonly IdentityUserManager _identityUserManager; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public BooksDataSeedContributor( + IBasicRepository bookRepository, + OrganizationUnitManager organizationUnitManager, + IOrganizationUnitRepository organizationUnitRepository, + IdentityUserManager identityUserManager, + IUnitOfWorkManager unitOfWorkManager) + { + _bookRepository = bookRepository; + _organizationUnitManager = organizationUnitManager; + _organizationUnitRepository = organizationUnitRepository; + _identityUserManager = identityUserManager; + _unitOfWorkManager = unitOfWorkManager; + } + + public virtual async Task SeedAsync(DataSeedContext context) + { + using (var uow = _unitOfWorkManager.Begin()) + { + var usa = await _organizationUnitRepository.FindAsync(UsaBranchId); + if (usa == null) + { + await _organizationUnitManager.CreateAsync(new OrganizationUnit(UsaBranchId, "USA Branch")); + } + + var turkey = await _organizationUnitRepository.FindAsync(TurkeyBranchId); + if (turkey == null) + { + await _organizationUnitManager.CreateAsync(new OrganizationUnit(TurkeyBranchId, "Turkey Branch")); + } + + await uow.SaveChangesAsync(); + + var admin = await _identityUserManager.FindByNameAsync("admin"); + Check.NotNull(admin, "admin"); + + await _identityUserManager.AddToOrganizationUnitAsync(admin.Id, UsaBranchId); + await _identityUserManager.AddToOrganizationUnitAsync(admin.Id, TurkeyBranchId); + + if (await _bookRepository.GetCountAsync() <= 0) + { + await _bookRepository.InsertAsync(new Book + { + Name = "1984", + Isbn = "978-0451524935", + OrganizationId = UsaBranchId + }); + + await _bookRepository.InsertAsync(new Book + { + Name = "Animal Farm", + Isbn = "978-0451526342", + OrganizationId = UsaBranchId + }); + + await _bookRepository.InsertAsync(new Book + { + Name = "Brave New World", + Isbn = "978-0060850524", + OrganizationId = UsaBranchId + }); + + await _bookRepository.InsertAsync(new Book + { + Name = "Fahrenheit 451", + Isbn = "978-1451673319", + OrganizationId = TurkeyBranchId + }); + + await _bookRepository.InsertAsync(new Book + { + Name = "The Catcher in the Rye", + Isbn = "978-0316769488", + OrganizationId = TurkeyBranchId + }); + + await _bookRepository.InsertAsync(new Book + { + Name = "To Kill a Mockingbird", + Isbn = "978-0061120084", + OrganizationId = TurkeyBranchId + }); + } + + await uow.CompleteAsync(); + } + } +} +``` + +## Web Page Implementation + +We will add a dropdown list to the top right corner of the page to allow users to select the organization they belong to. When the dropdown list changes, we will call the application service api to change the current `organization id`. + +```csharp +public class OrganizationUnitComponent : AbpViewComponent +{ + public async Task InvokeAsync() + { + var currentOrganizationAppService = LazyServiceProvider.GetRequiredService(); + var organizationDtos = await currentOrganizationAppService.GetOrganizationListAsync(); + var currentOrganizationId = await currentOrganizationAppService.GetCurrentOrganizationIdAsync(); + return View("/Components/OrganizationUnits/Default.cshtml", new OrganizationUnitComponentModel + { + CurrentOrganizationId = currentOrganizationId, + OrganizationDtos = organizationDtos + }); + } +} + +public class OrganizationUnitComponentModel +{ + public Guid? CurrentOrganizationId { get; set; } + + public List OrganizationDtos { get; set; } +} +``` + +```html +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using Volo.Abp.AspNetCore.Mvc.UI.Bundling.TagHelpers +@model BookStore.Web.Components.OrganizationUnits.OrganizationUnitComponentModel + +
+ + + + +
+ + +``` + +Add the `OrganizationUnitComponent` to the toolbar. + +```csharp +public class BookStoreToolbarContributor : IToolbarContributor +{ + public virtual Task ConfigureToolbarAsync(IToolbarConfigurationContext context) + { + // ... + + if (context.Toolbar.Name == StandardToolbars.Main) + { + context.Toolbar.Items.Add(new ToolbarItem(typeof(OrganizationUnitComponent)).RequireAuthenticated()); + } + + return Task.CompletedTask; + } +} +``` + +In addition, we also need to add a middleware after `UseAuthorization` to change the current `organization id`. + +```csharp +app.UseAuthorization(); +app.Use(async (httpContext, next) => +{ + var currentUser = httpContext.RequestServices.GetRequiredService(); + var cacheKey = currentUser.Id.ToString() + ":" + currentUser.GetBrowserInfo(); + var cache = httpContext.RequestServices.GetRequiredService>(); + var cacheItem = await cache.GetAsync(cacheKey); + if (cacheItem != null) + { + var currentOrganizationIdProvider = httpContext.RequestServices.GetRequiredService(); + currentOrganizationIdProvider.Change(cacheItem.OrganizationId); + } + await next(httpContext); +}); +// ... +``` + +The `Index` page will show the books based on the current `organization id`. + +```cshtml +public class IndexModel : BookStorePageModel +{ + public List Books { get; set; } = new List(); + public string? OrganizationName { get; set; } + + protected readonly IBookAppService BookAppService; + protected readonly ICurrentOrganizationAppService CurrentOrganizationAppService; + protected readonly IOrganizationUnitRepository OrganizationUnitRepository; + + public IndexModel( + IBookAppService bookAppService, + ICurrentOrganizationAppService currentOrganizationAppService, + IOrganizationUnitRepository organizationUnitRepository) + { + BookAppService = bookAppService; + CurrentOrganizationAppService = currentOrganizationAppService; + OrganizationUnitRepository = organizationUnitRepository; + } + + public async Task OnGetAsync() + { + if (CurrentUser.IsAuthenticated) + { + var currentOrganizationId = await CurrentOrganizationAppService.GetCurrentOrganizationIdAsync(); + if (currentOrganizationId.HasValue) + { + OrganizationName = (await OrganizationUnitRepository.GetAsync(currentOrganizationId.Value)).DisplayName; + } + + Books = await BookAppService.GetListAsync(); + } + } +} +``` + +```html +@page +@model BookStore.Web.Pages.IndexModel +@using Microsoft.AspNetCore.Mvc.Localization +@using BookStore.Localization +@inject IHtmlLocalizer L + +@if (!Model.OrganizationName.IsNullOrEmpty()) +{ +
The books belonging to @Model.OrganizationName organization
+} + +
    + @foreach(var book in Model.Books) + { +
  • Book Name: @book.Name, ISBN: @book.Isbn
  • + } +
+``` + +### Final UI + +The final UI will look like this: + +The index page will show empty if the current organization id is not set. +After selecting the organization unit, the index page will show the books based on the selected organization unit. + +![1](1.png) + +![2](2.png) + +![3](3.png) + +## Summary + +In this blog post. We showd simple implementation of switching between organization units. You can extend this implementation to meet your requirements. + +After [ABP 8.3](https://github.com/abpframework/abp/pull/20065) we introduced [User-defined function mapping](https://learn.microsoft.com/en-us/ef/core/querying/user-defined-function-mapping) feature for global filters which will gain performance improvements. + +## References + +- [Data Filtering](https://abp.io/docs/latest/framework/infrastructure/data-filtering) +- [Claims Principal Factory](https://abp.io/docs/latest/framework/fundamentals/authorization#claims-principal-factory) diff --git a/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/cover.jpg b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/cover.jpg new file mode 100644 index 0000000000..9e0e080c56 Binary files /dev/null and b/docs/en/Blog-Posts/2024-08-01-switching-between-organization-units/cover.jpg differ