Skip to content

Commit

Permalink
Add a feature to allow the user to manage tenants (#12360)
Browse files Browse the repository at this point in the history
  • Loading branch information
MikeAlhayek authored Oct 20, 2022
1 parent b45c84c commit be4fe36
Show file tree
Hide file tree
Showing 26 changed files with 512 additions and 235 deletions.
13 changes: 13 additions & 0 deletions OrchardCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Media.AmazonS3"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.ArchiveLater", "src\OrchardCore.Modules\OrchardCore.ArchiveLater\OrchardCore.ArchiveLater.csproj", "{190C4BEB-C506-4F7F-BDCA-93F3C1C221BC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Features.Core", "src\OrchardCore\OrchardCore.Features.Core\OrchardCore.Features.Core.csproj", "{122EC0DA-A593-4038-BD21-2D4A7061F348}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Search", "src\OrchardCore.Modules\OrchardCore.Search\OrchardCore.Search.csproj", "{7BDF280B-70B7-4AFC-A6F7-B5759DCA2A2C}"
EndProject
Global
Expand Down Expand Up @@ -1270,14 +1272,23 @@ Global
{FF1C550C-6D30-499A-AF11-68DE7C8B6869}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF1C550C-6D30-499A-AF11-68DE7C8B6869}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF1C550C-6D30-499A-AF11-68DE7C8B6869}.Release|Any CPU.Build.0 = Release|Any CPU
{A6563050-EE6D-4E7C-81AA-C383DB3ED124}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A6563050-EE6D-4E7C-81AA-C383DB3ED124}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A6563050-EE6D-4E7C-81AA-C383DB3ED124}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A6563050-EE6D-4E7C-81AA-C383DB3ED124}.Release|Any CPU.Build.0 = Release|Any CPU
{190C4BEB-C506-4F7F-BDCA-93F3C1C221BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{190C4BEB-C506-4F7F-BDCA-93F3C1C221BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{190C4BEB-C506-4F7F-BDCA-93F3C1C221BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{190C4BEB-C506-4F7F-BDCA-93F3C1C221BC}.Release|Any CPU.Build.0 = Release|Any CPU
{122EC0DA-A593-4038-BD21-2D4A7061F348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{122EC0DA-A593-4038-BD21-2D4A7061F348}.Debug|Any CPU.Build.0 = Debug|Any CPU
{122EC0DA-A593-4038-BD21-2D4A7061F348}.Release|Any CPU.ActiveCfg = Release|Any CPU
{122EC0DA-A593-4038-BD21-2D4A7061F348}.Release|Any CPU.Build.0 = Release|Any CPU
{7BDF280B-70B7-4AFC-A6F7-B5759DCA2A2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7BDF280B-70B7-4AFC-A6F7-B5759DCA2A2C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7BDF280B-70B7-4AFC-A6F7-B5759DCA2A2C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7BDF280B-70B7-4AFC-A6F7-B5759DCA2A2C}.Release|Any CPU.Build.0 = Release|Any CPU

EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1500,7 +1511,9 @@ Global
{38F43FA0-5BA8-4D6B-8F66-C708D590EF76} = {F23AC6C2-DE44-4699-999D-3C478EF3D691}
{FF1C550C-6D30-499A-AF11-68DE7C8B6869} = {90030E85-0C4F-456F-B879-443E8A3F220D}
{190C4BEB-C506-4F7F-BDCA-93F3C1C221BC} = {90030E85-0C4F-456F-B879-443E8A3F220D}
{122EC0DA-A593-4038-BD21-2D4A7061F348} = {F23AC6C2-DE44-4699-999D-3C478EF3D691}
{7BDF280B-70B7-4AFC-A6F7-B5759DCA2A2C} = {90030E85-0C4F-456F-B879-443E8A3F220D}

EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {46A1D25A-78D1-4476-9CBF-25B75E296341}
Expand Down
3 changes: 2 additions & 1 deletion src/OrchardCore.Modules/OrchardCore.Features/AdminMenu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public Task BuildNavigationAsync(string name, NavigationBuilder builder)
.Add(S["Configuration"], NavigationConstants.AdminMenuConfigurationPosition, configuration => configuration
.AddClass("menu-configuration").Id("configuration")
.Add(S["Features"], S["Features"].PrefixPosition(), deployment => deployment
.Action("Features", "Admin", new { area = "OrchardCore.Features" })
// Since features admin accepts tenant, always pass empty string to create valid link for current tenant.
.Action("Features", "Admin", new { area = "OrchardCore.Features", tenant = String.Empty })
.Permission(Permissions.ManageFeatures)
.LocalNav()
), priority: 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,27 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using OrchardCore.DisplayManagement.Extensions;
using OrchardCore.DisplayManagement.Notify;
using OrchardCore.Environment.Extensions;
using OrchardCore.Environment.Extensions.Features;
using OrchardCore.Environment.Shell;
using OrchardCore.Features.Models;
using OrchardCore.Environment.Shell.Models;
using OrchardCore.Features.Services;
using OrchardCore.Features.ViewModels;
using OrchardCore.Routing;

namespace OrchardCore.Features.Controllers
namespace OrchardCore.Features.Controllers
{
public class AdminController : Controller
{
private readonly IExtensionManager _extensionManager;
private readonly IShellFeaturesManager _shellFeaturesManager;
private readonly IAuthorizationService _authorizationService;
private readonly IShellHost _shellHost;
private readonly ShellSettings _shellSettings;
private readonly INotifier _notifier;
private readonly IExtensionManager _extensionManager;
private readonly IShellFeaturesManager _shellFeaturesManager;
private readonly IStringLocalizer S;
private readonly IHtmlLocalizer H;

Expand All @@ -32,62 +34,44 @@ public AdminController(
IHtmlLocalizer<AdminController> localizer,
IShellFeaturesManager shellFeaturesManager,
IAuthorizationService authorizationService,
IShellHost shellHost,
ShellSettings shellSettings,
INotifier notifier,
IStringLocalizer<AdminController> stringLocalizer)
{
_extensionManager = extensionManager;
_shellFeaturesManager = shellFeaturesManager;
_authorizationService = authorizationService;
_shellHost = shellHost;
_shellSettings = shellSettings;
_notifier = notifier;
_extensionManager = extensionManager;
_shellFeaturesManager = shellFeaturesManager;
H = localizer;
S = stringLocalizer;
}

public async Task<ActionResult> Features()
public async Task<ActionResult> Features(string tenant)
{
if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageFeatures))
{
return Forbid();
}

var enabledFeatures = await _shellFeaturesManager.GetEnabledFeaturesAsync();
var alwaysEnabledFeatures = await _shellFeaturesManager.GetAlwaysEnabledFeaturesAsync();

var moduleFeatures = new List<ModuleFeature>();

var features = (await _shellFeaturesManager.GetAvailableFeaturesAsync()).Where(f => !f.IsTheme());

foreach (var moduleFeatureInfo in features)
{
var dependentFeatures = _extensionManager.GetDependentFeatures(moduleFeatureInfo.Id);
var featureDependencies = _extensionManager.GetFeatureDependencies(moduleFeatureInfo.Id);

var moduleFeature = new ModuleFeature
{
Descriptor = moduleFeatureInfo,
IsEnabled = enabledFeatures.Contains(moduleFeatureInfo),
IsAlwaysEnabled = alwaysEnabledFeatures.Contains(moduleFeatureInfo),
EnabledByDependencyOnly = moduleFeatureInfo.EnabledByDependencyOnly,
//IsRecentlyInstalled = _moduleService.IsRecentlyInstalled(f.Extension),
//NeedsUpdate = featuresThatNeedUpdate.Contains(f.Id),
EnabledDependentFeatures = dependentFeatures.Where(x => x.Id != moduleFeatureInfo.Id && enabledFeatures.Contains(x)).ToList(),
FeatureDependencies = featureDependencies.Where(d => d.Id != moduleFeatureInfo.Id).ToList()
};

moduleFeatures.Add(moduleFeature);
}
var viewModel = new FeaturesViewModel();

return View(new FeaturesViewModel
await ExecuteAsync(tenant, async (featureService, settings) =>
{
Features = moduleFeatures
// if the user provide an invalid tenant value, we'll set it to null so it's not available on the next request
tenant = settings?.Name;
viewModel.Name = settings?.Name;
viewModel.Features = await featureService.GetModuleFeaturesAsync();
});

return View(viewModel);
}

[HttpPost]
[FormValueRequired("submit.BulkAction")]
public async Task<ActionResult> Features(BulkActionViewModel model, bool? force)
public async Task<ActionResult> Features(BulkActionViewModel model, bool? force, string tenant)
{
if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageFeatures))
{
Expand All @@ -101,97 +85,118 @@ public async Task<ActionResult> Features(BulkActionViewModel model, bool? force)

if (ModelState.IsValid)
{
var features = (await _shellFeaturesManager.GetAvailableFeaturesAsync())
.Where(f => !f.EnabledByDependencyOnly && !f.IsTheme() && model.FeatureIds.Contains(f.Id));
await ExecuteAsync(tenant, async (featureService, settings) =>
{
var availableFeatures = await featureService.GetAvailableFeatures(model.FeatureIds);
await EnableOrDisableFeaturesAsync(features, model.BulkAction, force);
await featureService.EnableOrDisableFeaturesAsync(availableFeatures, model.BulkAction, force, async (features, isEnabled) => await NotifyAsync(features, isEnabled));
});
}

return RedirectToAction(nameof(Features));
}

[HttpPost]
public async Task<IActionResult> Disable(string id)
public async Task<IActionResult> Disable(string id, string tenant)
{
if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageFeatures))
{
return Forbid();
}

var feature = (await _shellFeaturesManager.GetAvailableFeaturesAsync())
.FirstOrDefault(f => f.Id == id && !f.EnabledByDependencyOnly && !f.IsTheme());
var found = false;

if (feature == null)
await ExecuteAsync(tenant, async (featureService, settings) =>
{
return NotFound();
}
var feature = await featureService.GetAvailableFeature(id);
// Generating routes can fail while the tenant is recycled as routes can use services.
// It could be fixed by waiting for the next request or the end of the current one
// to actually release the tenant. Right now we render the url before recycling the tenant.
if (feature != null)
{
found = true;
}
var nextUrl = Url.Action(nameof(Features));
await featureService.EnableOrDisableFeaturesAsync(new[] { feature }, FeaturesBulkAction.Disable, true, async (features, isEnabled) => await NotifyAsync(features, isEnabled));
});

await EnableOrDisableFeaturesAsync(new[] { feature }, FeaturesBulkAction.Disable, force: true);
if (!found)
{
return NotFound();
}

return Redirect(nextUrl);
return Redirect(GetNextUrl(tenant));
}

[HttpPost]
public async Task<IActionResult> Enable(string id)
public async Task<IActionResult> Enable(string id, string tenant)
{
if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageFeatures))
{
return Forbid();
}

var feature = (await _shellFeaturesManager.GetAvailableFeaturesAsync())
.FirstOrDefault(f => f.Id == id && !f.EnabledByDependencyOnly && !f.IsTheme());
var found = false;

await ExecuteAsync(tenant, async (featureService, settings) =>
{
var feature = await featureService.GetAvailableFeature(id);
if (feature != null)
{
found = true;
}
await featureService.EnableOrDisableFeaturesAsync(new[] { feature }, FeaturesBulkAction.Enable, true, async (features, isEnabled) => await NotifyAsync(features, isEnabled));
});

if (feature == null)
if (!found)
{
return NotFound();
}

// Generating routes can fail while the tenant is recycled as routes can use services.
// It could be fixed by waiting for the next request or the end of the current one
// to actually release the tenant. Right now we render the url before recycling the tenant.
return Redirect(GetNextUrl(tenant));
}

private async Task ExecuteAsync(string tenant, Func<FeatureService, ShellSettings, Task> action)
{
if (_shellSettings.IsDefaultShell()
&& !String.IsNullOrWhiteSpace(tenant)
&& _shellHost.TryGetSettings(tenant, out var settings)
&& !settings.IsDefaultShell()
&& settings.State == TenantState.Running)
{
// At this point we know that this request is being executed from the host.
// Also, we were able to find a matching running tenant that isn't a default shell
// we are safe to create a scope for the given tenant
var shellScope = await _shellHost.GetScopeAsync(settings);

await shellScope.UsingAsync(async scope =>
{
var shellFeatureManager = scope.ServiceProvider.GetRequiredService<IShellFeaturesManager>();
var extensionManager = scope.ServiceProvider.GetRequiredService<IExtensionManager>();
var nextUrl = Url.Action(nameof(Features));
// at this point we apply the action on the given tenant
await action(new FeatureService(shellFeatureManager, extensionManager), settings);
});

await EnableOrDisableFeaturesAsync(new[] { feature }, FeaturesBulkAction.Enable, force: true);
return;
}

return Redirect(nextUrl);
// at this point we apply the action on the current tenant
await action(new FeatureService(_shellFeaturesManager, _extensionManager), null);
}

private async Task EnableOrDisableFeaturesAsync(IEnumerable<IFeatureInfo> features, FeaturesBulkAction action, bool? force)
private string GetNextUrl(string tenant)
{
switch (action)
// Generating routes can fail while the tenant is recycled as routes can use services.
// It could be fixed by waiting for the next request or the end of the current one
// to actually release the tenant. Right now we render the url before recycling the tenant.

if (!String.IsNullOrWhiteSpace(tenant))
{
case FeaturesBulkAction.None:
break;
case FeaturesBulkAction.Enable:
await _shellFeaturesManager.EnableFeaturesAsync(features, force == true);
await NotifyAsync(features);
break;
case FeaturesBulkAction.Disable:
await _shellFeaturesManager.DisableFeaturesAsync(features, force == true);
await NotifyAsync(features, enabled: false);
break;
case FeaturesBulkAction.Toggle:
// The features array has already been checked for validity.
var enabledFeatures = await _shellFeaturesManager.GetEnabledFeaturesAsync();
var disabledFeatures = await _shellFeaturesManager.GetDisabledFeaturesAsync();
var featuresToEnable = disabledFeatures.Intersect(features);
var featuresToDisable = enabledFeatures.Intersect(features);

await _shellFeaturesManager.UpdateFeaturesAsync(featuresToDisable, featuresToEnable, force == true);
await NotifyAsync(featuresToEnable);
await NotifyAsync(featuresToDisable, enabled: false);
return;
default:
break;
return Url.Action(nameof(Features), new { tenant });
}

return Url.Action(nameof(Features));
}

private async ValueTask NotifyAsync(IEnumerable<IFeatureInfo> features, bool enabled = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<ProjectReference Include="..\..\OrchardCore\OrchardCore.ContentManagement.Abstractions\OrchardCore.ContentManagement.Abstractions.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.Deployment.Abstractions\OrchardCore.Deployment.Abstractions.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.DisplayManagement\OrchardCore.DisplayManagement.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.Features.Core\OrchardCore.Features.Core.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.Module.Targets\OrchardCore.Module.Targets.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.Navigation.Core\OrchardCore.Navigation.Core.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.Recipes.Abstractions\OrchardCore.Recipes.Abstractions.csproj" />
Expand Down
6 changes: 3 additions & 3 deletions src/OrchardCore.Modules/OrchardCore.Features/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,19 @@ public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilde
routes.MapAreaControllerRoute(
name: "Features",
areaName: "OrchardCore.Features",
pattern: _adminOptions.AdminUrlPrefix + "/Features",
pattern: _adminOptions.AdminUrlPrefix + "/Features/{tenant?}",
defaults: new { controller = adminControllerName, action = nameof(AdminController.Features) }
);
routes.MapAreaControllerRoute(
name: "FeaturesDisable",
areaName: "OrchardCore.Features",
pattern: _adminOptions.AdminUrlPrefix + "/Features/{id}/Disable",
pattern: _adminOptions.AdminUrlPrefix + "/Features/{id}/Disable/{tenant?}",
defaults: new { controller = adminControllerName, action = nameof(AdminController.Disable) }
);
routes.MapAreaControllerRoute(
name: "FeaturesEnable",
areaName: "OrchardCore.Features",
pattern: _adminOptions.AdminUrlPrefix + "/Features/{id}/Enable",
pattern: _adminOptions.AdminUrlPrefix + "/Features/{id}/Enable/{tenant?}",
defaults: new { controller = adminControllerName, action = nameof(AdminController.Enable) }
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,8 @@ namespace OrchardCore.Features.ViewModels
{
public class FeaturesViewModel
{
public IEnumerable<ModuleFeature> Features { get; set; }
}
public string Name { get; set; }

public class BulkActionViewModel
{
public FeaturesBulkAction BulkAction { get; set; }
public string[] FeatureIds { get; set; }
}

public enum FeaturesBulkAction
{
None,
Enable,
Disable,
Toggle
public IEnumerable<ModuleFeature> Features { get; set; }
}
}
Loading

0 comments on commit be4fe36

Please sign in to comment.