diff --git a/zero.Core/Api/AppIdService.cs b/zero.Core/Api/AppId.cs
similarity index 100%
rename from zero.Core/Api/AppIdService.cs
rename to zero.Core/Api/AppId.cs
diff --git a/zero.Core/Api/ApplicationContext.cs b/zero.Core/Api/ApplicationContext.cs
new file mode 100644
index 00000000..d3126f0c
--- /dev/null
+++ b/zero.Core/Api/ApplicationContext.cs
@@ -0,0 +1,283 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.AspNetCore.Identity;
+using Raven.Client.Documents;
+using Raven.Client.Documents.Session;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using zero.Core.Entities;
+using zero.Core.Extensions;
+using zero.Core.Identity;
+using zero.Core.Options;
+
+namespace zero.Core.Api
+{
+ public class ApplicationContext : IApplicationContext
+ {
+ ///
+ public IApplication App { get; protected set; }
+
+ ///
+ public string AppId { get; protected set; }
+
+ protected IDocumentStore Raven { get; private set; }
+
+ protected IZeroOptions Options { get; private set; }
+
+ protected UserManager UserManager { get; private set; }
+
+
+
+ public ApplicationContext(IDocumentStore raven, IZeroOptions options, UserManager userManager)
+ {
+ Raven = raven;
+ Options = options;
+ UserManager = userManager;
+ }
+
+
+ ///
+ public async Task Resolve(HttpContext context)
+ {
+ if (context?.Request == null)
+ {
+ return null;
+ }
+
+ IApplication app;
+
+ if (IsBackofficeRequest(context))
+ {
+ app = await ResolveFromUser(context.User);
+ }
+ else
+ {
+ app = await ResolveFromRequest(context);
+ }
+
+ App = app;
+ AppId = app?.Id;
+
+ return app;
+ }
+
+
+ ///
+ public async Task TrySwitchForUser(User user, string appId)
+ {
+ if (user == null || appId.IsNullOrEmpty())
+ {
+ return false;
+ }
+
+ string[] allowedAppIds = GetAllowedAppIdsForUser(user);
+
+ bool isMainId = appId.Equals(user.AppId, StringComparison.InvariantCultureIgnoreCase);
+ bool isAllowedId = allowedAppIds.Contains(appId, StringComparer.InvariantCultureIgnoreCase);
+
+ if (user.IsSuper || isMainId || isAllowedId)
+ {
+ user.CurrentAppId = appId;
+
+ IdentityResult updateResult = await UserManager.UpdateAsync(user);
+ return updateResult.Succeeded;
+ }
+
+ return false;
+ }
+
+
+ ///
+ public async Task ResolveFromUser(ClaimsPrincipal user)
+ {
+ User userEntity = await UserManager.GetUserAsync(user);
+ return await ResolveFromUser(userEntity);
+ }
+
+
+ ///
+ public async Task ResolveFromUser(User user)
+ {
+ if (user == null)
+ {
+ return null;
+ }
+
+ string appId = null;
+ string[] allowedAppIds = GetAllowedAppIdsForUser(user);
+
+ if (!user.CurrentAppId.IsNullOrEmpty())
+ {
+ if (user.IsSuper || allowedAppIds.Contains(user.CurrentAppId, StringComparer.InvariantCultureIgnoreCase))
+ {
+ appId = user.CurrentAppId;
+ }
+ else
+ {
+ appId = user.AppId;
+ }
+ }
+ else
+ {
+ appId = user.AppId;
+ }
+
+ if (appId.IsNullOrEmpty())
+ {
+ throw new Exception($"User entity ${user.Id} needs a valid AppId");
+ }
+
+ using (IAsyncDocumentSession session = Raven.OpenAsyncSession())
+ {
+ return await session.LoadAsync(appId);
+ }
+ }
+
+
+ ///
+ public async Task ResolveFromRequest(HttpContext context)
+ {
+ return await ResolveFromUri(context.Request.GetEncodedUrl());
+ }
+
+
+ ///
+ public async Task ResolveFromUri(string uriString)
+ {
+ return ResolveFromUriInternal(new Uri(uriString, UriKind.Absolute), await GetApplications());
+ }
+
+
+ ///
+ public async Task ResolveFromUri(Uri uri)
+ {
+ return ResolveFromUriInternal(uri, await GetApplications());
+ }
+
+
+ ///
+ /// Get matching application from an URI
+ ///
+ IApplication ResolveFromUriInternal(Uri uri, IList apps)
+ {
+ IApplication currentApp = null;
+
+ foreach (Application app in apps)
+ {
+ if (app.Domains?.Length < 1)
+ {
+ continue;
+ }
+
+ foreach (string domain in app.Domains)
+ {
+ Uri domainUri = new Uri(domain, UriKind.Absolute);
+
+ int compareResult = Uri.Compare(uri, domainUri, UriComponents.HostAndPort, UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase);
+
+ if (compareResult == 0)
+ {
+ currentApp = app;
+ break;
+ }
+ }
+ }
+
+ return currentApp;
+ }
+
+
+ ///
+ /// Get all applications to choose from
+ ///
+ async Task> GetApplications()
+ {
+ using (IAsyncDocumentSession session = Raven.OpenAsyncSession())
+ {
+ return await session.Query().ToListAsync();
+ }
+ }
+
+
+ ///
+ /// Get applications the user has access to
+ ///
+ string[] GetAllowedAppIdsForUser(User user)
+ {
+ IEnumerable permissions = user.Claims
+ .Where(claim => claim.Type == Constants.Auth.Claims.Permission && claim.Value.StartsWith(Permissions.Applications))
+ .Select(x => Permission.FromClaim(x.ToClaim(), Permissions.Applications));
+
+ string[] appIds = permissions.Where(x => x.IsTrue).Select(x => x.NormalizedKey).ToArray();
+
+ return appIds;
+ }
+
+
+ ///
+ /// Whether the current request is a backoffice request
+ ///
+ bool IsBackofficeRequest(HttpContext context)
+ {
+ string path = Options.BackofficePath.EnsureStartsWith('/').TrimEnd('/');
+ return context.Request.Path.ToString().StartsWith(path);
+ }
+ }
+
+
+
+ public interface IApplicationContext
+ {
+ ///
+ /// Currently loaded application
+ ///
+ IApplication App { get; }
+
+ ///
+ /// Current loaded application Id
+ ///
+ string AppId { get; }
+
+ ///
+ /// Resolves the current application from either the backoffice user (in case it is backoffice request)
+ /// or the domain (in case it is frontend request).
+ /// The resolved data is stored in the App + AppId properties.
+ ///
+ Task Resolve(HttpContext context);
+
+ ///
+ /// Try to switch the current application for a user
+ ///
+ Task TrySwitchForUser(User user, string appId);
+
+ ///
+ /// Resolves the current application from the request path
+ ///
+ Task ResolveFromRequest(HttpContext context);
+
+ ///
+ /// Get matching application from an URI string
+ ///
+ Task ResolveFromUri(string uriString);
+
+ ///
+ /// Get matching application from an URI
+ ///
+ Task ResolveFromUri(Uri uri);
+
+ ///
+ /// Resolves the current application from the logged-in backoffice user.
+ /// This method won't return apps the user has no access to.
+ ///
+ Task ResolveFromUser(ClaimsPrincipal user);
+
+ ///
+ /// Resolves the current application from a user.
+ /// This method won't return apps the user has no access to.
+ ///
+ Task ResolveFromUser(User user);
+ }
+}
diff --git a/zero.Core/Api/BackofficeStore.cs b/zero.Core/Api/BackofficeStore.cs
index a480ba2e..bb235e78 100644
--- a/zero.Core/Api/BackofficeStore.cs
+++ b/zero.Core/Api/BackofficeStore.cs
@@ -1,13 +1,11 @@
using FluentValidation;
using FluentValidation.Results;
-using Microsoft.AspNetCore.Http;
using Raven.Client.Documents;
using Raven.Client.Documents.Linq;
using Raven.Client.Documents.Session;
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Security.Claims;
using System.Threading.Tasks;
using zero.Core.Attributes;
using zero.Core.Entities;
@@ -239,10 +237,9 @@ namespace zero.Core.Api
public class AppAwareBackofficeStore : BackofficeStore, IAppAwareBackofficeStore
{
- public AppAwareBackofficeStore(IDocumentStore raven, IMediaUpload media, IHttpContextAccessor httpContextAccessor) : base(raven, media)
+ public AppAwareBackofficeStore(IDocumentStore raven, IMediaUpload media, IApplicationContext context) : base(raven, media)
{
- Claim appIdClaim = httpContextAccessor.HttpContext?.User.Claims.FirstOrDefault(x => x.Type == Constants.Auth.Claims.CurrentAppId);
- AppId = appIdClaim?.Value;
+ AppId = context.AppId;
}
}
}
diff --git a/zero.Core/Entities/Applications/Application.cs b/zero.Core/Entities/Applications/Application.cs
index e3d433e3..552fe397 100644
--- a/zero.Core/Entities/Applications/Application.cs
+++ b/zero.Core/Entities/Applications/Application.cs
@@ -5,37 +5,59 @@ namespace zero.Core.Entities
///
/// An application is a website. zero can host multiple websites at once which share common assets
///
- public class Application : ZeroEntity, IZeroDbConventions
+ public class Application : ZeroEntity, IApplication
+ {
+ ///
+ public string FullName { get; set; }
+
+ ///
+ public string Email { get; set; }
+
+ ///
+ public string ImageId { get; set; }
+
+ ///
+ public string IconId { get; set; }
+
+ ///
+ public string[] Domains { get; set; } = new string[] { };
+
+ ///
+ public List Features { get; set; } = new List();
+ }
+
+
+ public interface IApplication : IZeroEntity, IZeroDbConventions
{
///
/// Full company or product name
///
- public string FullName { get; set; }
+ string FullName { get; set; }
///
/// Generic contact email. Can be used in various locations
///
- public string Email { get; set; }
+ string Email { get; set; }
///
/// Image of the application
///
- public string ImageId { get; set; }
+ string ImageId { get; set; }
///
/// Simple image of the application (used as favicon)
///
- public string IconId { get; set; }
+ string IconId { get; set; }
///
/// All assigned domains for this application
///
- public string[] Domains { get; set; } = new string[] { };
+ string[] Domains { get; set; }
///
/// Features which are enabled for this application.
/// Can be user-defined and affect both backoffice and frontend
///
- public List Features { get; set; } = new List();
+ List Features { get; set; }
}
}
\ No newline at end of file
diff --git a/zero.Core/Middlewares/ApplicationContextMiddleware.cs b/zero.Core/Middlewares/ApplicationContextMiddleware.cs
new file mode 100644
index 00000000..5c5e4da9
--- /dev/null
+++ b/zero.Core/Middlewares/ApplicationContextMiddleware.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Http;
+using System.Threading.Tasks;
+using zero.Core.Api;
+
+namespace zero.Core.Middlewares
+{
+ public class ApplicationContextMiddleware
+ {
+ RequestDelegate Next;
+
+
+ public ApplicationContextMiddleware(RequestDelegate next)
+ {
+ Next = next;
+ }
+
+
+ public async Task Invoke(HttpContext httpContext, IApplicationContext appContext)
+ {
+ await appContext.Resolve(httpContext);
+ await Next(httpContext);
+ }
+ }
+}
diff --git a/zero.Web.UI/App/navigation.vue b/zero.Web.UI/App/navigation.vue
index d77948df..ab32f9e3 100644
--- a/zero.Web.UI/App/navigation.vue
+++ b/zero.Web.UI/App/navigation.vue
@@ -5,7 +5,7 @@
-
+
@@ -46,7 +46,7 @@