From 11a8dfa572543d457f96c7351770d3356ace684a Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 27 Sep 2024 12:48:30 -0700 Subject: [PATCH] feat: Add multi-tenancy to the template (#461) --- .github/workflows/release.yml | 7 +- CHANGELOG.md | 6 +- .../src/components/admin/c-admin-method.vue | 9 +- templates/Coalesce.Vue.Template/TestLocal.ps1 | 8 +- .../content/.template.config/template.json | 88 ++++- .../Utilities/SqliteDatabaseFixture.cs | 18 +- .../Utilities/TestBase.cs | 7 +- .../Coalesce.Starter.Vue.Data/AppDbContext.cs | 325 ++++++++++++++---- .../Auth/AppClaimTypes.cs | 21 +- .../Auth/ClaimsPrincipalExtensions.cs | 7 + .../Auth/ClaimsPrincipalFactory.cs | 58 +++- .../Auth/DbContextFactoryExtensions.cs | 18 + .../Auth/InvitationService.cs | 100 ++++++ .../Auth/Permission.cs | 2 +- .../Auth/SecurityService.cs | 11 +- .../Auth/UserInfo.cs | 4 + .../Auth/UserInvitation.cs | 12 + .../Coalesce/AuditOperationContext.cs | 20 +- .../DatabaseSeeder.cs | 41 ++- .../Coalesce.Starter.Vue.Data/GlobalUsings.cs | 3 + .../Models/AuditLog.cs | 30 ++ .../Coalesce.Starter.Vue.Data/Models/Role.cs | 78 ++++- .../Models/Tenancy/ITenanted.cs | 10 + .../Models/Tenancy/Tenant.cs | 68 ++++ .../Models/Tenancy/TenantMembership.cs | 12 + .../Models/Tenancy/TenantedBase.cs | 14 + .../Coalesce.Starter.Vue.Data/Models/User.cs | 178 +++++++--- .../Models/UserRole.cs | 31 +- .../Models/Widget.cs | 20 +- .../Generated/SecurityServiceController.g.cs | 6 +- .../Api/Generated/TenantController.g.cs | 111 ++++++ .../Api/Generated/UserController.g.cs | 61 +++- .../Auth/AuthenticationConfiguration.cs | 99 ++++++ .../Auth/SignInService.cs | 282 +++++++++++++++ .../Controllers/HomeController.cs | 27 +- .../Models/Generated/TenantDto.g.cs | 82 +++++ .../Models/Generated/UserDto.g.cs | 44 +-- .../Models/Generated/UserInfoDto.g.cs | 20 ++ .../Pages/CreateTenant.cshtml | 21 ++ .../Pages/CreateTenant.cshtml.cs | 44 +++ .../Pages/Invitation.cshtml | 34 ++ .../Pages/Invitation.cshtml.cs | 76 ++++ .../Pages/SelectTenant.cshtml | 47 +++ .../Pages/SelectTenant.cshtml.cs | 57 +++ .../Pages/SignIn.cshtml | 121 ++----- .../Pages/SignIn.cshtml.cs | 18 +- .../Pages/SignOut.cshtml | 7 + .../Pages/SignOut.cshtml.cs | 22 ++ .../Pages/_Layout.cshtml | 57 +++ .../Pages/_ViewImports.cshtml | 2 + .../Pages/_ViewStart.cshtml | 3 + .../Coalesce.Starter.Vue.Web/Program.cs | 164 +++++---- .../Coalesce.Starter.Vue.Web/ProgramAuth.cs | 236 ------------- .../package-lock.json | 152 ++++---- .../Coalesce.Starter.Vue.Web/package.json | 4 +- .../Coalesce.Starter.Vue.Web/src/App.vue | 55 ++- .../src/api-clients.g.ts | 31 ++ .../src/components/HelloWorld.vue | 140 +++++--- .../Coalesce.Starter.Vue.Web/src/main.ts | 17 +- .../src/metadata.g.ts | 236 +++++++++++-- .../Coalesce.Starter.Vue.Web/src/models.g.ts | 73 +++- .../Coalesce.Starter.Vue.Web/src/router.ts | 16 +- .../Coalesce.Starter.Vue.Web/src/site.scss | 14 + .../src/user-service.ts | 19 +- .../src/viewmodels.g.ts | 82 ++++- .../src/views/Admin.vue | 169 +++++---- .../src/views/UserProfile.vue | 160 +++++++++ .../src/views/WidgetEdit.vue | 2 +- .../src/views/errors/Forbidden.vue | 2 +- .../content/Coalesce.Starter.Vue.sln | 1 + .../content/Directory.Build.props | 7 +- 71 files changed, 3107 insertions(+), 920 deletions(-) create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/DbContextFactoryExtensions.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/InvitationService.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserInvitation.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/ITenanted.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/Tenant.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/TenantMembership.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/TenantedBase.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/TenantController.g.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/AuthenticationConfiguration.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/SignInService.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/TenantDto.g.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/CreateTenant.cshtml create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/CreateTenant.cshtml.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Invitation.cshtml create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Invitation.cshtml.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SelectTenant.cshtml create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SelectTenant.cshtml.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignOut.cshtml create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignOut.cshtml.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_Layout.cshtml create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_ViewImports.cshtml create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_ViewStart.cshtml delete mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/ProgramAuth.cs create mode 100644 templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/views/UserProfile.vue diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 422969e54..708909daf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,11 +37,16 @@ jobs: testCase: # Nothing: - "" - # Everything: + # Everything (except tenancy): - "--Identity --MicrosoftAuth --GoogleAuth --UserPictures --AuditLogs --ExampleModel --DarkMode --TrackingBase --AppInsights --OpenAPI" # Assorted partial variants: - "--Identity --UserPictures --TrackingBase" - "--Identity --MicrosoftAuth --AuditLogs" + # Tenancy variants: + - "--Identity --Tenancy --TenantCreateExternal --GoogleAuth" + - "--Identity --Tenancy --TenantCreateSelf --TenantMemberInvites --AuditLogs" # todo: add local accounts to this case when we add it + - "--Identity --Tenancy --TenantCreateAdmin --TenantMemberInvites --MicrosoftAuth" # todo: add local accounts to this case when we add it + defaults: run: diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9c36aa8..3a7031ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # 5.0.2 -- feat: better "not found" messages from data sources when the ID is null or empty string. (#447) +- feat: Make "not found" messages from data sources clearer when the ID is null or empty string. (#447) +- feat(template): Added multi-tenancy options to the template. (#441, #461) - fix(template): adjust manual chunking configuration to avoid circular deps. (#455) -- fix(audit): key props now respect configured property exclusions +- fix(audit): key props now respect configured property exclusions. +- fix: c-admin-method now preserves newlines when displaying success messages. # 5.0.1 diff --git a/src/coalesce-vue-vuetify3/src/components/admin/c-admin-method.vue b/src/coalesce-vue-vuetify3/src/components/admin/c-admin-method.vue index bfe622be6..c06f5f35b 100644 --- a/src/coalesce-vue-vuetify3/src/components/admin/c-admin-method.vue +++ b/src/coalesce-vue-vuetify3/src/components/admin/c-admin-method.vue @@ -88,9 +88,12 @@ " class="c-method--result-success" > - {{ - caller.message || "Success" - }} + + +
diff --git a/templates/Coalesce.Vue.Template/TestLocal.ps1 b/templates/Coalesce.Vue.Template/TestLocal.ps1 index 2dcab9f6f..9347f407d 100644 --- a/templates/Coalesce.Vue.Template/TestLocal.ps1 +++ b/templates/Coalesce.Vue.Template/TestLocal.ps1 @@ -1,5 +1,5 @@ param ( - [Parameter(Position=0)] + [Parameter(Position = 0)] [string[]]$testCases ) @@ -22,14 +22,15 @@ foreach ($testCase in $testCases) { Write-Output "-------TEST CASE------" if (-not $testCase) { Write-Output "" - } else { + } + else { Write-Output $testCase } Write-Output "----------------------" Write-Output "" Remove-Item $dir/Test.Template.Instance/* -Recurse -Force -ErrorAction SilentlyContinue - Invoke-Expression "dotnet new coalescevue -o $dir/Test.Template.Instance $testcase" + Invoke-Expression "dotnet new coalescevue -o $dir/Test.Template.Instance --force $testcase" Push-Location $dir/Test.Template.Instance/*.Web try { @@ -37,6 +38,7 @@ foreach ($testCase in $testCases) { dotnet coalesce npm ci npm run build + npm run lint:fix # ensure all lint issues are auto-fixable dotnet build } finally { diff --git a/templates/Coalesce.Vue.Template/content/.template.config/template.json b/templates/Coalesce.Vue.Template/content/.template.config/template.json index 8b4446e22..0625ee1a5 100644 --- a/templates/Coalesce.Vue.Template/content/.template.config/template.json +++ b/templates/Coalesce.Vue.Template/content/.template.config/template.json @@ -41,7 +41,7 @@ "datatype": "bool", "displayName": "Sign-in with Microsoft", "description": "Adds Microsoft as an external authentication and account provider for Identity.", - "$coalesceRequires": [ "and", "Identity" ], + "$coalesceRequires": ["and", "Identity"], "$coalesceLink": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/microsoft-logins" }, "GoogleAuth": { @@ -49,7 +49,7 @@ "datatype": "bool", "displayName": "Sign-in with Google", "description": "Adds Google as an external authentication and account provider for Identity.", - "$coalesceRequires": [ "and", "Identity" ], + "$coalesceRequires": ["and", "Identity"], "$coalesceLink": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/google-logins" }, "UserPictures": { @@ -57,7 +57,7 @@ "datatype": "bool", "displayName": "User Profile Pictures", "description": "Adds infrastructure for acquiring, saving, and displaying user profile pictures.", - "$coalesceRequires": [ "and", "Identity" ] + "$coalesceRequires": ["and", "Identity"] }, "TrackingBase": { "type": "parameter", @@ -90,6 +90,45 @@ "displayName": "OpenAPI/Swagger", "description": "Include configuration to expose an OpenAPI document and SwaggerUI using Swashbuckle." }, + "Tenancy": { + "type": "parameter", + "datatype": "bool", + "displayName": "Tenancy: Core", + "description": "Includes core infrastructure for supporting multiple tenants within one shared database, with shared user accounts.", + "$coalesceRequires": ["and", "Identity"] + }, + "TenantCreateSelf": { + "type": "parameter", + "datatype": "bool", + "displayName": "Tenancy: Creation by Self-service", + "description": "Allows any signed in user to create additional tenants.", + "$coalesceRequires": ["and", "Tenancy"] + }, + "TenantCreateAdmin": { + "type": "parameter", + "datatype": "bool", + "displayName": "Tenancy: Creation by Global Admin", + "description": "Allows global admins to create new tenants.", + "$coalesceRequires": ["and", "Tenancy"] + }, + "TenantCreateExternal": { + "type": "parameter", + "datatype": "bool", + "displayName": "Tenancy: Creation/Membership by OIDC", + "description": "Automatically creates a new tenant for external organizations (Microsoft Entra, Google GSuite), and grants automatic tenant membership to other users within those organizations.", + "$coalesceRequires": [ + "and", + "Tenancy", + ["or", "MicrosoftAuth", "GoogleAuth"] + ] + }, + "TenantMemberInvites": { + "type": "parameter", + "datatype": "bool", + "displayName": "Tenancy: Membership by Invitation", + "description": "Allows administrators within a tenant to create invitation links to grant membership to their tenant.", + "$coalesceRequires": ["and", "Tenancy"] + }, "AppInsights": { "type": "parameter", "datatype": "bool", @@ -131,11 +170,11 @@ { "condition": "!Identity", "exclude": [ - "**/ProgramAuth.cs", + "**/AuthenticationConfiguration.cs", "**/Forbidden.vue", "**/UserAvatar.vue", - "**/SignIn.cshtml", - "**/SignIn.cshtml.cs", + "**/SignIn.*", + "**/SignOut.*", "**/AppClaimTypes.cs", "**/ClaimsPrincipalFactory.cs", "**/Permission.cs", @@ -144,6 +183,33 @@ "**/Role.cs" ] }, + { + "condition": "!MicrosoftAuth && !GoogleAuth", + "exclude": ["**/SignInService.cs"] + }, + { + "condition": "!Tenancy", + "exclude": [ + "**/Tenant.cs", + "**/DbContextFactoryExtensions.cs", + "**/TenantMembership.cs", + "**/ITenanted.cs", + "**/TenantedBase.cs", + "**/SelectTenant.*" + ] + }, + { + "condition": "!TenantCreateSelf", + "exclude": ["**/CreateTenant.*"] + }, + { + "condition": "!(TenantMemberInvites || TenantCreateAdmin)", + "exclude": [ + "**/Invitation.*", + "**/InvitationService.*", + "**/UserInvitation.*" + ] + }, { "condition": "!(UserPictures && Identity)", "exclude": ["Coalesce.Starter.Vue.Data/Models/UserPhoto.cs"] @@ -170,15 +236,19 @@ }, { "condition": "!ExampleModel", - "exclude": ["**/WidgetEdit.vue", "**/WidgetEdit.spec.ts"] + "exclude": [ + "**/WidgetEdit.vue", + "**/WidgetEdit.spec.ts", + "**/Widget.cs" + ] }, { "condition": "!AzurePipelines", - "exclude": [ "**/azure-pipelines.yml" ] + "exclude": ["**/azure-pipelines.yml"] }, { "condition": "!GithubActions", - "exclude": [ "**/.github" ] + "exclude": ["**/.github"] } ] } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data.Test/Utilities/SqliteDatabaseFixture.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data.Test/Utilities/SqliteDatabaseFixture.cs index 4a661fe53..0acb48fc4 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data.Test/Utilities/SqliteDatabaseFixture.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data.Test/Utilities/SqliteDatabaseFixture.cs @@ -37,12 +37,24 @@ public SqliteDatabaseFixture() using var db = new AppDbContextForSqlite(Options); db.Database.EnsureCreated(); - Seed(); + Seed(db); } - public void Seed() + public void Seed(AppDbContext db) { - // Seed baseline test data, if desired. + var seeder = new DatabaseSeeder(db); + seeder.Seed(); + +#if Tenancy + if (!db.Tenants.Any()) + { + var tenant = new Tenant { Name = "Test Tenant" }; + db.Add(tenant); + db.SaveChanges(); + db.TenantId = tenant.TenantId; + seeder.SeedNewTenant(tenant); + } +#endif } public void Dispose() diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data.Test/Utilities/TestBase.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data.Test/Utilities/TestBase.cs index 4f19995f6..311792b46 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data.Test/Utilities/TestBase.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data.Test/Utilities/TestBase.cs @@ -1,4 +1,5 @@ -using IntelliTect.Coalesce; +using Coalesce.Starter.Vue.Data.Models; +using IntelliTect.Coalesce; using IntelliTect.Coalesce.TypeDefinition; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -82,6 +83,10 @@ private MockerScope BeginMockScope(bool standalone = false) { var mocker = new MockerScope(standalone ? null : this); var db = new AppDbContextForSqlite(DbFixture.Options); +#if Tenancy + db.TenantId = db.Tenants.OrderBy(t => t.TenantId).First().TenantId; +#endif + mocker.Use(DbFixture.Options); mocker.Use(db); diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/AppDbContext.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/AppDbContext.cs index 0c50ceb25..19acaeabd 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/AppDbContext.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/AppDbContext.cs @@ -1,11 +1,14 @@ using Coalesce.Starter.Vue.Data.Coalesce; -using Coalesce.Starter.Vue.Data.Models; #if AuditLogs using IntelliTect.Coalesce.AuditLogging; #endif using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.ValueGeneration; +using System.Linq.Expressions; using System.Security.Cryptography; namespace Coalesce.Starter.Vue.Data; @@ -13,105 +16,287 @@ namespace Coalesce.Starter.Vue.Data; [Coalesce] public class AppDbContext #if Identity - : IdentityDbContext< - User, - Role, - string, - IdentityUserClaim, - UserRole, - IdentityUserLogin, - IdentityRoleClaim, - IdentityUserToken - > + : IdentityDbContext< + User, + Role, + string, + IdentityUserClaim, + UserRole, + IdentityUserLogin, + RoleClaim, + IdentityUserToken + > #else - : DbContext + : DbContext #endif - , IDataProtectionKeyContext + , IDataProtectionKeyContext #if AuditLogs - , IAuditLogDbContext + , IAuditLogDbContext #endif { - public bool SuppressAudit { get; set; } = false; - - public AppDbContext() { } + public bool SuppressAudit { get; set; } = false; - public AppDbContext(DbContextOptions options) : base(options) { } +#if Tenancy + private string? _TenantId; -#if UserPictures - public DbSet UserPhotos { get; set; } + /// + /// The tenant ID used to filter results and assign new objects to a tenant. + /// + public string? TenantId + { + get => _TenantId; + set + { + if (_TenantId != null && value != _TenantId && ChangeTracker.Entries().Any()) + { + throw new InvalidOperationException("Cannot change the TenantId of an active DbContext. Make a new one through IDbContextFactory to perform operations on different tenants, or call ForceSetTenant()."); + } + _TenantId = value; + } + } + + public string TenantIdOrThrow => TenantId ?? throw new InvalidOperationException("TenantId not set on AppDbContext"); + + /// + /// Resets the 's change tracker and switches the current tenant to . + /// + public void ForceSetTenant(string tenantId) + { + if (TenantId != tenantId) + { + ChangeTracker.Clear(); + TenantId = tenantId; + } + } #endif + public AppDbContext() { } + + public AppDbContext(DbContextOptions options) : base(options) { } + #if AuditLogs - public DbSet AuditLogs { get; set; } - public DbSet AuditLogProperties { get; set; } + public DbSet AuditLogs => Set(); + public DbSet AuditLogProperties => Set(); #endif - public DbSet Widgets { get; set; } +#if Tenancy + public DbSet Tenants => Set(); + public DbSet TenantMemberships => Set(); +#endif + +#if UserPictures + public DbSet UserPhotos => Set(); +#endif + +#if ExampleModel + public DbSet Widgets => Set(); +#endif - [InternalUse] - public DbSet DataProtectionKeys { get; set; } + [InternalUse] + public DbSet DataProtectionKeys => Set(); -#if (TrackingBase || AuditLogs) - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder +#if (TrackingBase || AuditLogs || Tenancy) + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder #if TrackingBase - .UseStamping((entity, user) => entity.SetTracking(user)) + .UseStamping((entity, user) => entity.SetTracking(user)) +#endif +#if Tenancy + .AddInterceptors(new TenantInterceptor()) #endif #if AuditLogs - .UseCoalesceAuditLogging(x => x - .WithAugmentation() - .ConfigureAudit(config => - { - static string ShaString(byte[]? bytes) => bytes is null ? "" : Convert.ToBase64String(SHA1.HashData(bytes)); - - config - .FormatType(ShaString) - .Exclude() + .UseCoalesceAuditLogging(x => x + .WithAugmentation() + .ConfigureAudit(config => + { + static string ShaString(byte[]? bytes) => bytes is null ? "" : "SHA1:" + Convert.ToBase64String(SHA1.HashData(bytes)); + + config + .FormatType(ShaString) + .Exclude() #if TrackingBase - .ExcludeProperty(x => new { x.CreatedBy, x.CreatedById, x.CreatedOn, x.ModifiedBy, x.ModifiedById, x.ModifiedOn }); -#else - ; + .ExcludeProperty(x => new { x.CreatedBy, x.CreatedById, x.CreatedOn, x.ModifiedBy, x.ModifiedById, x.ModifiedOn }) #endif - }) - ) +#if Identity + .Format(x => x.PasswordHash, x => "") #endif - ; - } +#if Tenancy + .ExcludeProperty(x => new { x.TenantId }) +#endif + ; + }) + ) +#endif + ; + } #endif protected override void OnModelCreating(ModelBuilder builder) - { - base.OnModelCreating(builder); + { + base.OnModelCreating(builder); - // Remove cascading deletes. - foreach (var relationship in builder.Model.GetEntityTypes().SelectMany(e => e.GetForeignKeys())) - { - relationship.DeleteBehavior = DeleteBehavior.Restrict; - } + // Remove cascading deletes. + foreach (var relationship in builder.Model.GetEntityTypes().SelectMany(e => e.GetForeignKeys())) + { + relationship.DeleteBehavior = DeleteBehavior.Restrict; + } #if Identity - builder.Entity(userRole => - { - userRole.HasKey(ur => new { ur.UserId, ur.RoleId }); - - userRole.HasOne(ur => ur.Role) - .WithMany() - .HasForeignKey(ur => ur.RoleId) - .IsRequired() - .OnDelete(DeleteBehavior.Cascade); - - userRole.HasOne(ur => ur.User) - .WithMany(r => r.UserRoles) - .HasForeignKey(ur => ur.UserId) - .IsRequired() - .OnDelete(DeleteBehavior.Cascade); - }); + builder.Entity(userRole => + { + userRole.HasKey(ur => new { ur.UserId, ur.RoleId }); + + userRole.HasOne(ur => ur.Role) + .WithMany() + .HasForeignKey(ur => ur.RoleId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + userRole.HasOne(ur => ur.User) + .WithMany(r => r.UserRoles) + .HasForeignKey(ur => ur.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + }); builder.Entity(e => { e.PrimitiveCollection(e => e.Permissions).ElementType().HasConversion(); + +#if Tenancy + // Fix index that doesn't account for tenanted roles + e.Metadata.RemoveIndex(e.Metadata.GetIndexes().Where(i => i.Properties[0].Name == nameof(Role.NormalizedName)).Single()); + e.HasIndex(r => new { r.TenantId, r.NormalizedName }).IsUnique(); +#endif + + e.HasMany() + .WithOne(rc => rc.Role) + .HasPrincipalKey(r => r.Id) + .HasForeignKey(rc => rc.RoleId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); }); #endif - } + +#if Tenancy + // Setup tenancy model configuration. This should be after all other model configuration. + foreach (var model in builder.Model + .GetEntityTypes() + .Where(e => e.ClrType.GetInterface(nameof(ITenanted)) != null) + .ToList()) + { + // Create the global query filter for the model that will restrict data to the current tenant. + var param = Expression.Parameter(model.ClrType); + model.SetQueryFilter(Expression.Lambda( + Expression.Equal( + Expression.MakeMemberAccess(param, model.ClrType.GetProperty("TenantId")!), + Expression.MakeMemberAccess(Expression.Constant(this), this.GetType().GetProperty("TenantIdOrThrow")!) + ), + param + )); + + // Put the tenantID as the first part of each tenanted entity's PK. + + // This is done in a way that is transparent to Coalesce since Coalesce + // and APIs are essentially unconcerned with tenancy - the tenant is always derived + // from the logged in user. Also because Coalesce doesn't support composite keys. + + // Doing this lets us include tenantIDs as part of foreign keys, + // and also affords us slightly more performance when doing joins + // since data from each tenant will be clustered together. + + var key = model.FindPrimaryKey(); + var tenantIdProp = model.FindProperty(nameof(ITenanted.TenantId)); + if (key is { Properties.Count: 1 } && tenantIdProp is not null && key.Properties.Single() != tenantIdProp) + { + // A value generator is added so that entities can be .Add()ed to the DbContext + // while their TenantID is still null (if EF can't figure out the TenantId through any other navigation prop). + tenantIdProp.SetValueGeneratorFactory((p, t) => new TenantIdValueGenerator()); + + var pkProp = key.Properties.Single(); + var oldPkGenerated = pkProp.ValueGenerated; + + if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") + { + // Unfortunately for Sqlite and unit testing, we can't make the tenant part of the PK. + // See https://stackoverflow.com/questions/49592274/how-to-create-autoincrement-column-in-sqlite-using-ef-core + // So, do the next best thing and add a second FK to all relationships that includes the tenantID. + + var tenantedAk = model.AddKey(new[] { tenantIdProp, pkProp })!; + + foreach (var fk in model.GetReferencingForeignKeys().ToList()) + { + var dependentTenantId = fk.DeclaringEntityType.FindProperty(nameof(ITenanted.TenantId)); + if (dependentTenantId is null) continue; + + var newFk = fk.DeclaringEntityType.AddForeignKey(new[] { + dependentTenantId, + fk.Properties.Single() + }, tenantedAk, model); + + newFk.DeleteBehavior = DeleteBehavior.NoAction; + } + } + else + { + // SQL Server: + + // TenantID goes first, for clustering. + var newPk = model.SetPrimaryKey(new[] { tenantIdProp, pkProp })!; + + foreach (var fk in model.GetReferencingForeignKeys().ToList()) + { + fk.SetProperties(new[] { + fk.DeclaringEntityType.FindProperty(nameof(ITenanted.TenantId)) + ?? throw new InvalidOperationException($"Foreign key from untenanted entity {fk.DeclaringEntityType} cannot reference tenanted principal {model}"), + fk.Properties.Single() + }, newPk); + } + + // Keep the old PK prop as an identity column if it previously was before we changed the PK. + pkProp.ValueGenerated = oldPkGenerated; + } + } + } +#endif + } + +#if Tenancy + class TenantIdValueGenerator : ValueGenerator + { + public override bool GeneratesTemporaryValues => false; + + public override string Next(EntityEntry entry) => ((AppDbContext)entry.Context).TenantIdOrThrow; + } + + class TenantInterceptor : SaveChangesInterceptor + { + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + var db = (AppDbContext)eventData.Context!; + foreach (var entry in db.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Property(nameof(ITenanted.TenantId)).CurrentValue = db.TenantId; + } + else if (entry.State == EntityState.Modified && entry.Property(nameof(ITenanted.TenantId)).IsModified) + { + throw new InvalidOperationException("Cannot change the TenantId of an existing entity."); + } + } + + return result; + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + return new ValueTask>(SavingChanges(eventData, result)); + } + } +#endif } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/AppClaimTypes.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/AppClaimTypes.cs index ddf38a881..9f3126f84 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/AppClaimTypes.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/AppClaimTypes.cs @@ -4,8 +4,19 @@ public static class AppClaimTypes { public const string Role = "role"; public const string Permission = "perm"; - public const string UserId = ClaimTypes.NameIdentifier; - public const string UserName = ClaimTypes.Name; - public const string Email = ClaimTypes.Email; - public const string FullName = nameof(FullName); -} \ No newline at end of file + public const string UserId = "sub"; + public const string UserName = "username"; + public const string Email = "email"; + public const string FullName = "name"; +#if Tenancy + public const string TenantId = "tid"; +#endif +} + +#if Tenancy +public static class AppClaimValues +{ + public const string GlobalAdminRole = "GlobalAdmin"; + public const string NullTenantId = "00000000-0000-0000-0000-000000000000"; +} +#endif \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/ClaimsPrincipalExtensions.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/ClaimsPrincipalExtensions.cs index c586a1656..b95858ded 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/ClaimsPrincipalExtensions.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/ClaimsPrincipalExtensions.cs @@ -23,4 +23,11 @@ public static bool Can(this ClaimsPrincipal user, params Permission[] permission public static string? GetUserName(this ClaimsPrincipal user) => user.FindFirstValue(ClaimTypes.Name); #endif +#if Tenancy + public static string? GetTenantId(this ClaimsPrincipal user) + => user.FindFirstValue(AppClaimTypes.TenantId); + + public static bool HasTenant(this ClaimsPrincipal user) + => user.GetTenantId() is string tid && !string.IsNullOrWhiteSpace(tid) && tid != AppClaimValues.NullTenantId; +#endif } \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/ClaimsPrincipalFactory.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/ClaimsPrincipalFactory.cs index e20568723..03058e183 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/ClaimsPrincipalFactory.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/ClaimsPrincipalFactory.cs @@ -1,6 +1,6 @@ -using Coalesce.Starter.Vue.Data.Models; -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; +using System.Security; namespace Coalesce.Starter.Vue.Data.Auth; @@ -13,11 +13,53 @@ IOptions options { public override async Task CreateAsync(User user) { +#if Tenancy + var tenantId = db.TenantId; + if (string.IsNullOrWhiteSpace(tenantId)) + { + // User doesn't have a selected tenant. Pick one for them. + var membership = await db.TenantMemberships + .IgnoreQueryFilters() +#if TrackingBase + .OrderBy(m => m.CreatedOn) // Prefer oldest membership +#endif + .FirstOrDefaultAsync(tm => tm.UserId == user.Id); + + // Default to the "null" tenant if the user belongs to no tenants. + // This allows the rest of the sign-in process to function, + // but will never match a real tenant or produce real roles/permissions. + // This allows new users to accept invitations or create their own tenant. + tenantId = membership?.TenantId ?? AppClaimValues.NullTenantId; + db.ForceSetTenant(tenantId); + } + else + { + var isTenantMember = await db.TenantMemberships + .AnyAsync(t => t.UserId == user.Id && t.TenantId == tenantId); + if (!isTenantMember) + { + // This is a last-chance sanity check and should be impossible as long as the user's + // SecurityStamp is rerolled when they're evicted from a tenant. If the stamp isn't rerolled, + // a user could continually refresh their session within a tenant they were removed from. + db.ForceSetTenant(tenantId = AppClaimValues.NullTenantId); + } + } + +#endif var identity = await GenerateClaimsAsync(user); // Attach additional custom claims identity.AddClaim(new Claim(AppClaimTypes.FullName, user.FullName ?? "")); +#if Tenancy + if (user.IsGlobalAdmin) + { + identity.AddClaim(new Claim(identity.RoleClaimType, AppClaimValues.GlobalAdminRole)); + } + + identity.AddClaim(new Claim(AppClaimTypes.TenantId, tenantId)); +#endif + // Store all the permissions in a dedicated identity // whose RoleClaimType is Permission so that they can still be treated like roles // (and so they work with IsInRole and coalesce attribute-based security). @@ -35,6 +77,16 @@ public override async Task CreateAsync(User user) Options.ClaimsIdentity.UserNameClaimType, AppClaimTypes.Permission); - return new ClaimsPrincipal([identity, permissionIdentity]); + ClaimsPrincipal result = new([identity, permissionIdentity]); + +#if Tenancy + if (!user.IsGlobalAdmin && result.IsInRole(AppClaimValues.GlobalAdminRole)) + { + // Safety/sanity check that the user hasn't been able to elevate to global admin + // by some unexpected claim or permission fulfilling the global admin role check: + throw new SecurityException($"User ${user.Id} unexpectly appears to be a global admin."); + } +#endif + return result; } } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/DbContextFactoryExtensions.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/DbContextFactoryExtensions.cs new file mode 100644 index 000000000..f1cf1c14d --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/DbContextFactoryExtensions.cs @@ -0,0 +1,18 @@ +namespace Coalesce.Starter.Vue.Data.Auth; + +public static class DbContextFactoryExtensions +{ + public static IEnumerable GetTenantIds(this IDbContextFactory factory) + { + using var db = factory.CreateDbContext(); + + return db.Tenants.Select(t => t.TenantId).ToList(); + } + + public static AppDbContext CreateDbContext(this IDbContextFactory factory, string tenantId) + { + var db = factory.CreateDbContext(); + db.TenantId = tenantId; + return db; + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/InvitationService.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/InvitationService.cs new file mode 100644 index 000000000..822bf00dd --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/InvitationService.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Coalesce.Starter.Vue.Data.Auth; + +public class InvitationService( + AppDbContext db, + IDataProtectionProvider dataProtector, + IUrlHelper urlHelper +) +{ + private IDataProtector GetProtector() => dataProtector.CreateProtector("invitations"); + + public async Task CreateAndSendInvitation( + string tenantId, + string email, + Role[] roles + ) + { + if (roles.Any(r => r.TenantId != tenantId)) return "Role/tenant mismatch"; + + var invitation = new UserInvitation + { + Email = email, + Issued = DateTimeOffset.Now, + Roles = roles.Select(r => r.Id).ToArray(), + TenantId = db.TenantIdOrThrow + }; + + var user = await db.Users + .Where(u => u.Email == email && u.EmailConfirmed) + .FirstOrDefaultAsync(); + + if (user is not null) + { + return await AcceptInvitation(invitation, user); + } + + var link = CreateInvitationLink(invitation); + + // TODO: Implement email sending and send the invitation link directly to `email`. + // Returning it directly in the result message is a temporary measure. + + return new(true, message: $"Give the following invitation link to {email}:\n\n{link}"); + } + + public async Task AcceptInvitation( + UserInvitation invitation, + User? acceptingUser + ) + { + var tenant = await db.Tenants.FindAsync(invitation.TenantId); + + if (acceptingUser is null) return "User not found"; + if (tenant is null) return "Tenant not found"; + + // Note: `acceptingUser` will be untracked after ForceSetTenant. + db.ForceSetTenant(invitation.TenantId); + + if (await db.TenantMemberships.AnyAsync(m => m.User == acceptingUser)) + { + return $"{acceptingUser.UserName ?? acceptingUser.Email} is already a member of {tenant.Name}."; + } + + db.TenantMemberships.Add(new() { UserId = acceptingUser.Id }); + db.UserRoles.AddRange(invitation.Roles.Select(rid => new UserRole { RoleId = rid, UserId = acceptingUser.Id })); + await db.SaveChangesAsync(); + + return new(true, $"{acceptingUser.UserName ?? acceptingUser.Email} has been added as a member of {tenant.Name}."); + } + + public string CreateInvitationLink(UserInvitation invitation) + { + var inviteJson = JsonSerializer.Serialize(invitation); + var inviteCode = GetProtector().Protect(inviteJson); + + return urlHelper.PageLink("/invitation", values: new { code = inviteCode })!; + } + + public ItemResult DecodeInvitation(string code) + { + try + { + var unprotected = GetProtector().Unprotect(code); + var invite = JsonSerializer.Deserialize(unprotected); + + if (invite is null || invite.Issued.AddHours(24) < DateTimeOffset.Now) + { + return "The invitation is no longer valid."; + } + + return invite; + } + catch + { + return "The invitation is no longer valid."; + } + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/Permission.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/Permission.cs index 62dd1e74b..08e4435be 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/Permission.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/Permission.cs @@ -7,7 +7,7 @@ /// public enum Permission { - // Note: Enum values/numbers are not used. Only the names are used for persistence and API representation. + // Note: Enum values/numbers are not used for persistance - roles are stored as strings in the database. [Display(Name = "Admin - General", Description = "Modify application configuration and other administrative functions excluding user/role management.")] Admin = 1, diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/SecurityService.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/SecurityService.cs index 5e1113047..7fa935da5 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/SecurityService.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/SecurityService.cs @@ -5,8 +5,12 @@ namespace Coalesce.Starter.Vue.Data.Auth; public class SecurityService() { [Coalesce, Execute(HttpMethod = HttpMethod.Get)] - public UserInfo WhoAmI(ClaimsPrincipal user) + public UserInfo WhoAmI(ClaimsPrincipal user, AppDbContext db) { +#if Tenancy + var tenant = db.Tenants.Find(user.GetTenantId()); +#endif + return new UserInfo { #if Identity @@ -29,6 +33,11 @@ public UserInfo WhoAmI(ClaimsPrincipal user) Id = user.FindFirstValue(ClaimTypes.NameIdentifier), UserName = user.Identity?.Name, #endif + +#if Tenancy + TenantId = user.GetTenantId(), + TenantName = tenant?.Name, +#endif }; } } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserInfo.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserInfo.cs index 769ef22f3..21717894b 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserInfo.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserInfo.cs @@ -13,4 +13,8 @@ public class UserInfo public required ICollection Roles { get; set; } public required ICollection Permissions { get; set; } #endif +#if Tenancy + public string? TenantId { get; set; } + public string? TenantName { get; set; } +#endif } \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserInvitation.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserInvitation.cs new file mode 100644 index 000000000..187cb3898 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserInvitation.cs @@ -0,0 +1,12 @@ +namespace Coalesce.Starter.Vue.Data.Auth; + +public class UserInvitation +{ + public required string TenantId { get; set; } + + public required string Email { get; set; } + + public required DateTimeOffset Issued { get; set; } + + public required string[] Roles { get; set; } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Coalesce/AuditOperationContext.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Coalesce/AuditOperationContext.cs index 3cd9bf0e3..b5d614289 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Coalesce/AuditOperationContext.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Coalesce/AuditOperationContext.cs @@ -1,6 +1,4 @@ -using Coalesce.Starter.Vue.Data.Auth; -using Coalesce.Starter.Vue.Data.Models; -using IntelliTect.Coalesce.AuditLogging; +using IntelliTect.Coalesce.AuditLogging; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore.ChangeTracking; @@ -15,5 +13,21 @@ public override void Populate(AuditLog auditEntry, EntityEntry changedEntity) base.Populate(auditEntry, changedEntity); auditEntry.UserId = User?.GetUserId(); + +#if Tenancy + auditEntry.TenantId = changedEntity.Entity switch + { + ITenanted tenanted => tenanted.TenantId, + Tenant tenant => tenant.TenantId, + _ => auditEntry.TenantId + }; + + if (auditEntry.TenantId is not null) + { + // Strip the TenantId out of the primary key because we stored it in its own column. + var tenantKeyPrefix = $"{auditEntry.TenantId};"; + auditEntry.KeyValue = auditEntry.KeyValue?.Replace(tenantKeyPrefix, ""); + } +#endif } } \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/DatabaseSeeder.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/DatabaseSeeder.cs index e6bb911e9..30743be1a 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/DatabaseSeeder.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/DatabaseSeeder.cs @@ -1,13 +1,47 @@ -namespace Coalesce.Starter.Vue.Data; +using Coalesce.Starter.Vue.Data.Models; + +namespace Coalesce.Starter.Vue.Data; public class DatabaseSeeder(AppDbContext db) { public void Seed() { +#if Tenancy +#if (!TenantCreateExternal && !TenantCreateSelf) + if (!db.Tenants.Any()) + { + var tenant = new Tenant { Name = "Demo Tenant" }; + db.Add(tenant); + db.SaveChanges(); + + SeedNewTenant(tenant); + } +#endif +#elif Identity + SeedRoles(); +#endif + } + +#if Tenancy + public void SeedNewTenant(Tenant tenant, string? userId = null) + { + var tenantId = tenant.TenantId; + db.TenantId = tenantId; + #if Identity SeedRoles(); + + if (userId is not null) + { + // Give the first user in the tenant all roles so there is an initial admin. + db.AddRange(db.Roles.Select(r => new UserRole { Role = r, UserId = userId })); + db.Add(new TenantMembership { UserId = userId }); + } + + db.SaveChanges(); #endif } +#endif #if Identity private void SeedRoles() @@ -21,8 +55,9 @@ private void SeedRoles() NormalizedName = "ADMIN", }); - // NOTE: In a permissions-based authorization system, - // roles can freely be created by administrators in the admin pages. + // NOTE: In this application's permissions-based authorization system, + // additional roles can freely be created by administrators. + // You don't have to seed every possible role. db.SaveChanges(); } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/GlobalUsings.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/GlobalUsings.cs index 9cd9e54f1..99404ecac 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/GlobalUsings.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/GlobalUsings.cs @@ -7,6 +7,9 @@ global using System.ComponentModel.DataAnnotations.Schema; global using static IntelliTect.Coalesce.DataAnnotations.SecurityPermissionLevels; global using System.Security.Claims; +global using Coalesce.Starter.Vue.Data.Auth; +global using Coalesce.Starter.Vue.Data.Coalesce; +global using Coalesce.Starter.Vue.Data.Models; global using OrderByDirections = IntelliTect.Coalesce.DataAnnotations.DefaultOrderByAttribute.OrderByDirections; global using SearchMethods = IntelliTect.Coalesce.DataAnnotations.SearchAttribute.SearchMethods; diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/AuditLog.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/AuditLog.cs index 1c2f4f834..43b2bd8ef 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/AuditLog.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/AuditLog.cs @@ -5,7 +5,9 @@ namespace Coalesce.Starter.Vue.Data.Models; [Edit(DenyAll)] [Delete(DenyAll)] [Create(DenyAll)] +#if Identity [Read(nameof(Permission.ViewAuditLogs))] +#endif public class AuditLog : DefaultAuditLog { #if Identity @@ -14,4 +16,32 @@ public class AuditLog : DefaultAuditLog [Display(Name = "Changed By")] public User? User { get; set; } #endif + +#if Tenancy + // NOTE: Audit logs are *optionally* tenanted because they can log changes + // to non-tenanted entities as well. Read security is implemented in the below datasource. + + [InternalUse] + public string? TenantId { get; set; } + [InternalUse] + public Tenant? Tenant { get; set; } + + [DefaultDataSource] + public class TenantedDataSource : AppDataSource + { + public TenantedDataSource(CrudContext context) : base(context) { } + + public override IQueryable GetQuery(IDataSourceParameters parameters) + { + return base.GetQuery(parameters) + .AsNoTracking() + .Where(al => + // All ViewAuditLogs users can see logs in the current tenant + al.TenantId == User.GetTenantId() || + // Global admins can see logs that happened outside a tenant. + (User.IsInRole(AppClaimValues.GlobalAdminRole) && al.TenantId == null) + ); + } + } +#endif } \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Role.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Role.cs index c07e609fe..63b9a85ed 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Role.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Role.cs @@ -8,26 +8,70 @@ namespace Coalesce.Starter.Vue.Data.Models; [Edit(nameof(Permission.UserAdmin))] [Delete(nameof(Permission.UserAdmin))] [Description("Roles are groups of permissions, analagous to job titles or functions.")] -public class Role : IdentityRole +public class Role +#if Tenancy + : IdentityRole, ITenanted +#else + : IdentityRole +#endif { - [Required, Search(SearchMethod = SearchMethods.Contains)] - public override string? Name { get; set; } +#if Tenancy + [InternalUse] + [DefaultOrderBy(FieldOrder = 0)] + public string TenantId { get; set; } = null!; + [InternalUse] + [ForeignKey(nameof(TenantId))] + public Tenant? Tenant { get; set; } +#endif - [InternalUse] - public override string? ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); + [Required, Search(SearchMethod = SearchMethods.Contains)] + public override string? Name { get; set; } - [InternalUse] - public override string? NormalizedName { get; set; } + [InternalUse] + public override string? ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); - public List? Permissions { get; set; } + [InternalUse] + public override string? NormalizedName { get; set; } - public class Behaviors(RoleManager roleManager, CrudContext context) : AppBehaviors(context) - { - public override ItemResult BeforeSave(SaveKind kind, Role? oldItem, Role item) - { - item.NormalizedName = roleManager.NormalizeKey(item.Name); + public List? Permissions { get; set; } - return base.BeforeSave(kind, oldItem, item); - } - } -} \ No newline at end of file + public class Behaviors(RoleManager roleManager, CrudContext context) : AppBehaviors(context) + { + public override ItemResult BeforeSave(SaveKind kind, Role? oldItem, Role item) + { +#if Tenancy + if (AppClaimValues.GlobalAdminRole.Equals(item.Name, StringComparison.OrdinalIgnoreCase)) + { + return $"{item.Name} is a reserved role name and cannot be used."; + } +#endif + + item.NormalizedName = roleManager.NormalizeKey(item.Name); + + return base.BeforeSave(kind, oldItem, item); + } + } +} + +#if Tenancy +[InternalUse] +public class RoleClaim : IdentityRoleClaim, ITenanted +{ + [ForeignKey(nameof(RoleId))] + public Role? Role { get; set; } + + [InternalUse] + [DefaultOrderBy(FieldOrder = 0)] + public required string TenantId { get; set; } + [InternalUse] + [ForeignKey(nameof(TenantId))] + public Tenant? Tenant { get; set; } +} +#else +[InternalUse] +public class RoleClaim : IdentityRoleClaim +{ + [ForeignKey(nameof(RoleId))] + public Role? Role { get; set; } +} +#endif \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/ITenanted.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/ITenanted.cs new file mode 100644 index 000000000..ac35c47c4 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/ITenanted.cs @@ -0,0 +1,10 @@ +namespace Coalesce.Starter.Vue.Data.Models; + +/// +/// The model belongs to a tenant and should be filtered to the tenant of the current user/HTTP request. +/// +public interface ITenanted +{ + string TenantId { get; set; } + Tenant? Tenant { get; set; } +} \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/Tenant.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/Tenant.cs new file mode 100644 index 000000000..6f8019f95 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/Tenant.cs @@ -0,0 +1,68 @@ +using System.ComponentModel; + +namespace Coalesce.Starter.Vue.Data.Models; + +[Read(AllowAuthenticated)] +[Edit(Roles = nameof(Permission.Admin))] +[Create(DenyAll)] +[Delete(DenyAll)] +#if TenantCreateExternal +[Index(nameof(ExternalId), IsUnique = true)] +#endif +[Display(Name = "Organization")] +public class Tenant +{ + public string TenantId { get; set; } = Guid.NewGuid().ToString(); + + public required string Name { get; set; } + +#if TenantCreateExternal + [Read] + [Description("The external origin of this tenant. Other users who sign in with accounts from this external source will automatically join this organization.")] + public string? ExternalId { get; set; } +#endif + + [DefaultDataSource] + public class DefaultSource(CrudContext context) : AppDataSource(context) + { + public override IQueryable GetQuery(IDataSourceParameters parameters) + { + // Only allow the current tenant to be read and modified. + return base.GetQuery(parameters) + .Where(t => t.TenantId == User.GetTenantId()); + } + } + + public class GlobalAdminSource(CrudContext context) : AppDataSource(context) + { + public override IQueryable GetQuery(IDataSourceParameters parameters) + { + if (!User.IsInRole(AppClaimValues.GlobalAdminRole)) + { + return Enumerable.Empty().AsQueryable(); + } + + return base.GetQuery(parameters); + } + } + +#if TenantCreateAdmin + [Coalesce, Execute(AppClaimValues.GlobalAdminRole)] + public static async Task Create( + AppDbContext db, + [Inject] InvitationService invitationService, + [Display(Name = "Org Name")] string name, + [DataType(DataType.EmailAddress)] string adminEmail + ) + { + Tenant tenant = new() { Name = name }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + db.ForceSetTenant(tenant.TenantId); + new DatabaseSeeder(db).SeedNewTenant(tenant); + + return await invitationService.CreateAndSendInvitation(tenant.TenantId, adminEmail, db.Roles.ToArray()); + } +#endif +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/TenantMembership.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/TenantMembership.cs new file mode 100644 index 000000000..3b9e0f6ff --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/TenantMembership.cs @@ -0,0 +1,12 @@ +namespace Coalesce.Starter.Vue.Data.Models; + +[Index(nameof(UserId), nameof(TenantId), IsUnique = true)] +[InternalUse] +public class TenantMembership : TenantedBase +{ + public string TenantMembershipId { get; set; } = Guid.NewGuid().ToString(); + + [Required] + public string UserId { get; set; } = default!; + public User? User { get; set; } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/TenantedBase.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/TenantedBase.cs new file mode 100644 index 000000000..6d547cb18 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Tenancy/TenantedBase.cs @@ -0,0 +1,14 @@ +namespace Coalesce.Starter.Vue.Data.Models; + +public abstract class TenantedBase +#if TrackingBase + : TrackingBase, ITenanted +#else + : ITenanted +#endif +{ + [InternalUse, Required] + public string TenantId { get; set; } = null!; + [InternalUse] + public Tenant? Tenant { get; set; } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/User.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/User.cs index ab69ea890..38c5f6da0 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/User.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/User.cs @@ -1,58 +1,59 @@ -using Coalesce.Starter.Vue.Data.Auth; -using Coalesce.Starter.Vue.Data.Coalesce; -using IntelliTect.Coalesce.Helpers; -using IntelliTect.Coalesce.Utilities; using Microsoft.AspNetCore.Identity; using System.ComponentModel; namespace Coalesce.Starter.Vue.Data.Models; -[Edit(nameof(Permission.UserAdmin))] +[Edit(AllowAuthenticated)] [Create(DenyAll)] [Delete(DenyAll)] [Description("A user profile within the application.")] public class User : IdentityUser { [Search(SearchMethod = SearchMethods.Contains)] + [ListText] public string? FullName { get; set; } + [Search] + public override string? UserName { get; set; } + + [Read] // Email readonly - sourced from external identity providers + public override string? Email { get; set; } + + [Read] + public override bool EmailConfirmed { get; set; } + #if UserPictures [Read, Hidden] - public byte[]? PhotoMD5 { get; set; } + public byte[]? PhotoHash { get; set; } [InverseProperty(nameof(UserPhoto.User))] public UserPhoto? Photo { get; set; } #endif - [Search] - public override string? UserName { get; set; } + [InternalUse] - public override string? NormalizedUserName { get; set; } + public override string? NormalizedUserName { get; set; } - [InternalUse] - public override string? PasswordHash { get; set; } + [InternalUse] + public override string? PasswordHash { get; set; } - [InternalUse] - public override string? SecurityStamp { get; set; } + [InternalUse] + public override string? SecurityStamp { get; set; } - [InternalUse] - public override string? ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); + [InternalUse] + public override string? ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); - [InternalUse] - public override string? PhoneNumber { get; set; } + [InternalUse] + public override string? PhoneNumber { get; set; } [InternalUse] public override bool PhoneNumberConfirmed { get; set; } - [Read(nameof(Permission.UserAdmin)), Edit(nameof(Permission.UserAdmin))] - public override string? Email { get; set; } [InternalUse] public override string? NormalizedEmail { get; set; } - [InternalUse] - public override bool EmailConfirmed { get; set; } - [InternalUse] - public override bool TwoFactorEnabled { get; set; } + [InternalUse] + public override bool TwoFactorEnabled { get; set; } #if PasswordAuth [Read(nameof(Permission.UserAdmin))] @@ -63,7 +64,11 @@ public class User : IdentityUser public override int AccessFailedCount { get; set; } [Description("If set, the user will be blocked from signing in until this date.")] +#if Tenancy + [InternalUse] +#else [Read(nameof(Permission.UserAdmin)), Edit(nameof(Permission.UserAdmin))] +#endif public override DateTimeOffset? LockoutEnd { get; set; } #if PasswordAuth @@ -71,41 +76,40 @@ public class User : IdentityUser #else [Description("If enabled, the user can be locked out.")] #endif +#if Tenancy + [InternalUse] +#else [Read(nameof(Permission.UserAdmin)), Edit(nameof(Permission.UserAdmin))] +#endif public override bool LockoutEnabled { get; set; } - [Read(nameof(Permission.UserAdmin))] + [Read(nameof(Permission.UserAdmin), NoAutoInclude = true)] [InverseProperty(nameof(UserRole.User))] [ManyToMany("Roles")] + [Hidden] public ICollection? UserRoles { get; set; } + [Display(Name = "Roles")] [Read(nameof(Permission.UserAdmin))] - [NotMapped, DataType(DataType.MultilineText)] - [Hidden(HiddenAttribute.Areas.List)] - [Display(Description = "A summary of the effective permissions of the user, derived from their current roles.")] - public string? EffectivePermissions - { - get - { - var currentPermissions = UserRoles - ?.SelectMany(u => u.Role?.Permissions!.Select(p => (p, u.Role)) ?? []) - .ToLookup(p => p.p, p => p.Role); - - if (currentPermissions is null) return null; - - return string.Join("\n", Enum.GetValues().Select(p => currentPermissions.Contains(p) - ? $"✅ {p.GetDisplayName()} (via {string.Join(", ", currentPermissions[p].Select(r => r.Name))})" - : $"❌ {p.GetDisplayName()}" - )); - } - } + public IEnumerable? RoleNames => UserRoles?.Where(ur => ur.Role != null).Select(r => r.Role!.Name!); + +#if Tenancy + [Read(AppClaimValues.GlobalAdminRole)] + [Edit(AppClaimValues.GlobalAdminRole)] + [Hidden] + [Description("Global admins can perform some administrative actions against ALL tenants.")] + public bool IsGlobalAdmin { get; set; } +#endif #if UserPictures - [Coalesce, Execute(HttpMethod = HttpMethod.Get, VaryByProperty = nameof(PhotoMD5))] - public ItemResult GetPhoto(AppDbContext db) + [Coalesce, Execute(HttpMethod = HttpMethod.Get, VaryByProperty = nameof(PhotoHash))] + public ItemResult GetPhoto(ClaimsPrincipal user, AppDbContext db) { return new IntelliTect.Coalesce.Models.File(db.UserPhotos .Where(p => p.UserId == this.Id) +#if Tenancy + .Where(p => db.TenantMemberships.Any(tm => tm.UserId == this.Id && tm.TenantId == user.GetTenantId())) +#endif .Select(p => p.Content)) { ContentType = "image/*" @@ -113,28 +117,91 @@ public ItemResult GetPhoto(AppDbContext db) } #endif +#if Tenancy + [Coalesce, Execute(Roles = nameof(Permission.UserAdmin))] + public ItemResult Evict(ClaimsPrincipal callingUser, AppDbContext db) + { + if ( + Id == callingUser.GetUserId() && + db.Users.Count(u => u.UserRoles!.Any(r => r.Role!.Permissions!.Contains(Permission.UserAdmin))) == 1 + ) + { + return "You cannot remove the last remaining user admin."; + } + + this.SecurityStamp = Guid.NewGuid().ToString(); + db.RemoveRange(db.UserRoles.Where(u => u.UserId == this.Id)); + db.RemoveRange(db.TenantMemberships.Where(u => u.UserId == this.Id)); + db.SaveChanges(); + + return true; + } + +#if TenantMemberInvites + [Coalesce, Execute(Roles = nameof(Permission.UserAdmin))] + public static async Task InviteUser( + AppDbContext db, + [Inject] InvitationService invitationService, + [DataType(DataType.EmailAddress)] string email, + Role? role + ) + { + Role[] roles = role is null ? [] : [role]; + return await invitationService.CreateAndSendInvitation(db.TenantIdOrThrow, email, roles); + } +#endif +#endif + + [DefaultDataSource] + public class DefaultSource(CrudContext context) : AppDataSource(context) + { + public override IQueryable GetQuery(IDataSourceParameters parameters) + { + var query = base.GetQuery(parameters); + if (User.Can(Permission.UserAdmin)) + { + query = query.Include(u => u.UserRoles!).ThenInclude(ur => ur.Role); + } + +#if Tenancy + return query.Where(u => Db.TenantMemberships.Any(tm => tm.UserId == u.Id && tm.TenantId == User.GetTenantId())); +#else + return query; +#endif + } + } + public class UserBehaviors( - CrudContext context, + CrudContext context, UserManager userManager, SignInManager signInManager ) : AppBehaviors(context) { public override ItemResult BeforeSave(SaveKind kind, User? oldItem, User item) { - if (oldItem != null) +#if Tenancy + // Since users exist across tenants, a user may only edit their own profile. + // Admins within a particular tenant cannot edit the properties of a user + // that will affect other tenants. + if (item.Id != User.GetUserId()) return "Forbidden."; +#else + // Users who aren't user admins can only edit their own profile. + if (item.Id != User.GetUserId() && !User.Can(Permission.UserAdmin)) return "Forbidden."; +#endif + + if (item.UserName != oldItem?.UserName) { - if (item.Email != oldItem.Email) + if (Db.Users.Any(u => u.UserName == item.UserName && u.Id != item.Id)) { - item.NormalizedEmail = userManager.NormalizeEmail(item.Email); - item.SecurityStamp = Guid.NewGuid().ToString(); + return "Username is already taken."; } - if (item.UserName != oldItem.UserName) - { - item.NormalizedUserName = userManager.NormalizeEmail(item.UserName); - item.SecurityStamp = Guid.NewGuid().ToString(); - } + item.NormalizedUserName = userManager.NormalizeName(item.UserName); + } + if (oldItem != null) + { +#if (!Tenancy) if (item.LockoutEnd != oldItem.LockoutEnd) { // Auto-enable lockout when setting a lockout date. @@ -149,6 +216,7 @@ public override ItemResult BeforeSave(SaveKind kind, User? oldItem, User item) // Make it clear to the administrator that lockout is only respected when LockoutEnabled. item.LockoutEnd = null; } +#endif } return base.BeforeSave(kind, oldItem, item); diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/UserRole.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/UserRole.cs index 489840f9d..1bc1518c9 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/UserRole.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/UserRole.cs @@ -7,6 +7,9 @@ namespace Coalesce.Starter.Vue.Data.Models; [Create(nameof(Permission.UserAdmin))] [Delete(nameof(Permission.UserAdmin))] public class UserRole : IdentityUserRole +#if Tenancy + , ITenanted +#endif { // Fake PK for Coalesce since IdentityUserRole uses a composite PK. [NotMapped] @@ -21,6 +24,14 @@ public string Id } } +#if Tenancy + [InternalUse] + [DefaultOrderBy(FieldOrder = 0)] + public string TenantId { get; set; } = null!; + [InternalUse] + public Tenant? Tenant { get; set; } +#endif + [DefaultOrderBy(FieldOrder = 0)] public User? User { get; set; } @@ -28,7 +39,7 @@ public string Id public Role? Role { get; set; } [DefaultDataSource] - public class DefaultSource(CrudContext context) : StandardDataSource(context) + public class DefaultSource(CrudContext context) : AppDataSource(context) { // Teach Coalesce how to properly query for our compound key: protected override Task EvaluateItemQueryAsync( @@ -40,4 +51,22 @@ public class DefaultSource(CrudContext context) : StandardDataSour return query.FirstOrDefaultAsync(r => r.UserId == idParts[0] && r.RoleId == idParts[1], cancellationToken); } } + + public class Behaviors( + CrudContext context, + SignInManager signInManager + ) : AppBehaviors(context) + { + public override async Task> AfterSaveAsync(SaveKind kind, UserRole? oldItem, UserRole item) + { + if (User.GetUserId() == item.Id) + { + // If the user was editing their own roles, refresh their current sign-in immediately + // so that it doesn't feel like nothing happened. + await signInManager.RefreshSignInAsync(item.User!); + } + + return true; + } + } } \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Widget.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Widget.cs index ab5e0fe52..fdede2b09 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Widget.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Models/Widget.cs @@ -4,22 +4,24 @@ namespace Coalesce.Starter.Vue.Data.Models; [Description("A sample model provided by the Coalesce template. Remove this when you start building your real data model.")] public class Widget -#if TrackingBase +#if Tenancy + : TenantedBase +#elif TrackingBase : TrackingBase #endif { public int WidgetId { get; set; } - public required string Name { get; set; } - - public required WidgetCategory Category { get; set; } - - public DateTimeOffset? InventedOn { get; set; } + public required string Name { get; set; } + + public required WidgetCategory Category { get; set; } + + public DateTimeOffset? InventedOn { get; set; } } public enum WidgetCategory { - Whizbangs, - Sprecklesprockets, - Discombobulators, + Whizbangs, + Sprecklesprockets, + Discombobulators, } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/SecurityServiceController.g.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/SecurityServiceController.g.cs index 6a87c6a6c..33cea9c57 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/SecurityServiceController.g.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/SecurityServiceController.g.cs @@ -41,12 +41,14 @@ public SecurityServiceController(CrudContext context, Coalesce.Starter.Vue.Data. /// [HttpGet("WhoAmI")] [Authorize] - public virtual ItemResult WhoAmI() + public virtual ItemResult WhoAmI( + [FromServices] Coalesce.Starter.Vue.Data.AppDbContext db) { IncludeTree includeTree = null; var _mappingContext = new MappingContext(Context); var _methodResult = Service.WhoAmI( - User + User, + db ); var _result = new ItemResult(); _result.Object = Mapper.MapToDto(_methodResult, _mappingContext, includeTree); diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/TenantController.g.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/TenantController.g.cs new file mode 100644 index 000000000..f072b6d8a --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/TenantController.g.cs @@ -0,0 +1,111 @@ + +using Coalesce.Starter.Vue.Web.Models; +using IntelliTect.Coalesce; +using IntelliTect.Coalesce.Api; +using IntelliTect.Coalesce.Api.Behaviors; +using IntelliTect.Coalesce.Api.Controllers; +using IntelliTect.Coalesce.Api.DataSources; +using IntelliTect.Coalesce.Mapping; +using IntelliTect.Coalesce.Mapping.IncludeTrees; +using IntelliTect.Coalesce.Models; +using IntelliTect.Coalesce.TypeDefinition; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Net; +using System.Threading.Tasks; + +namespace Coalesce.Starter.Vue.Web.Api +{ + [Route("api/Tenant")] + [Authorize] + [ServiceFilter(typeof(IApiActionFilter))] + public partial class TenantController + : BaseApiController + { + public TenantController(CrudContext context) : base(context) + { + GeneratedForClassViewModel = context.ReflectionRepository.GetClassViewModel(); + } + + [HttpGet("get/{id}")] + [Authorize] + public virtual Task> Get( + string id, + DataSourceParameters parameters, + IDataSource dataSource) + => GetImplementation(id, parameters, dataSource); + + [HttpGet("list")] + [Authorize] + public virtual Task> List( + ListParameters parameters, + IDataSource dataSource) + => ListImplementation(parameters, dataSource); + + [HttpGet("count")] + [Authorize] + public virtual Task> Count( + FilterParameters parameters, + IDataSource dataSource) + => CountImplementation(parameters, dataSource); + + [HttpPost("save")] + [Authorize] + public virtual Task> Save( + [FromForm] TenantParameter dto, + [FromQuery] DataSourceParameters parameters, + IDataSource dataSource, + IBehaviors behaviors) + => SaveImplementation(dto, parameters, dataSource, behaviors); + + [HttpPost("bulkSave")] + [Authorize] + public virtual Task> BulkSave( + [FromBody] BulkSaveRequest dto, + [FromQuery] DataSourceParameters parameters, + IDataSource dataSource, + [FromServices] IDataSourceFactory dataSourceFactory, + [FromServices] IBehaviorsFactory behaviorsFactory) + => BulkSaveImplementation(dto, parameters, dataSource, dataSourceFactory, behaviorsFactory); + + // Methods from data class exposed through API Controller. + + /// + /// Method: Create + /// + [HttpPost("Create")] + [Authorize(Roles = "GlobalAdmin")] + public virtual async Task Create( + [FromServices] Coalesce.Starter.Vue.Data.Auth.InvitationService invitationService, + [FromForm(Name = "name")] string name, + [FromForm(Name = "adminEmail")] string adminEmail) + { + var _params = new + { + name = name, + adminEmail = adminEmail + }; + + if (Context.Options.ValidateAttributesForMethods) + { + var _validationResult = ItemResult.FromParameterValidation( + GeneratedForClassViewModel!.MethodByName("Create"), _params, HttpContext.RequestServices); + if (!_validationResult.WasSuccessful) return _validationResult; + } + + var _methodResult = await Coalesce.Starter.Vue.Data.Models.Tenant.Create( + Db, + invitationService, + _params.name, + _params.adminEmail + ); + var _result = new ItemResult(_methodResult); + return _result; + } + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/UserController.g.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/UserController.g.cs index 0d51ced8d..8d7a923e5 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/UserController.g.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Api/Generated/UserController.g.cs @@ -93,7 +93,7 @@ public virtual Task> BulkSave( } var item = itemResult.Object; - var _currentVaryValue = item.PhotoMD5; + var _currentVaryValue = item.PhotoHash; if (_currentVaryValue != default) { var _expectedEtagHeader = new Microsoft.Net.Http.Headers.EntityTagHeaderValue('"' + Microsoft.AspNetCore.WebUtilities.Base64UrlTextEncoder.Encode(_currentVaryValue) + '"'); @@ -111,6 +111,7 @@ public virtual Task> BulkSave( } var _methodResult = item.GetPhoto( + User, Db ); if (_methodResult.Object != null) @@ -121,5 +122,63 @@ public virtual Task> BulkSave( _result.Object = _methodResult.Object; return _result; } + + /// + /// Method: Evict + /// + [HttpPost("Evict")] + [Authorize(Roles = "UserAdmin")] + public virtual async Task Evict( + [FromServices] IDataSourceFactory dataSourceFactory, + [FromForm(Name = "id")] string id) + { + var dataSource = dataSourceFactory.GetDataSource("Default"); + var itemResult = await dataSource.GetItemAsync(id, new DataSourceParameters()); + if (!itemResult.WasSuccessful) + { + return new ItemResult(itemResult); + } + var item = itemResult.Object; + var _methodResult = item.Evict( + User, + Db + ); + var _result = new ItemResult(_methodResult); + return _result; + } + + /// + /// Method: InviteUser + /// + [HttpPost("InviteUser")] + [Authorize(Roles = "UserAdmin")] + public virtual async Task InviteUser( + [FromServices] Coalesce.Starter.Vue.Data.Auth.InvitationService invitationService, + [FromForm(Name = "email")] string email, + [FromForm(Name = "role")] RoleParameter role) + { + var _params = new + { + email = email, + role = role + }; + + if (Context.Options.ValidateAttributesForMethods) + { + var _validationResult = ItemResult.FromParameterValidation( + GeneratedForClassViewModel!.MethodByName("InviteUser"), _params, HttpContext.RequestServices); + if (!_validationResult.WasSuccessful) return _validationResult; + } + + var _mappingContext = new MappingContext(Context); + var _methodResult = await Coalesce.Starter.Vue.Data.Models.User.InviteUser( + Db, + invitationService, + _params.email, + _params.role.MapToNew(_mappingContext) + ); + var _result = new ItemResult(_methodResult); + return _result; + } } } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/AuthenticationConfiguration.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/AuthenticationConfiguration.cs new file mode 100644 index 000000000..cbf6fed30 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/AuthenticationConfiguration.cs @@ -0,0 +1,99 @@ +using Coalesce.Starter.Vue.Data; +using Coalesce.Starter.Vue.Data.Auth; +using Coalesce.Starter.Vue.Data.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using System.Security.Claims; + +namespace Coalesce.Starter.Vue.Web.Auth; + +public static class AuthenticationConfiguration +{ + public static void ConfigureAuthentication(this WebApplicationBuilder builder) + { + var config = builder.Configuration; + + builder.Services + .AddIdentity(c => + { + c.ClaimsIdentity.RoleClaimType = AppClaimTypes.Role; + c.ClaimsIdentity.EmailClaimType = AppClaimTypes.Email; + c.ClaimsIdentity.UserIdClaimType = AppClaimTypes.UserId; + c.ClaimsIdentity.UserNameClaimType = AppClaimTypes.UserName; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders() + .AddClaimsPrincipalFactory(); + +#if (GoogleAuth || MicrosoftAuth) + builder.Services.AddScoped(); +#endif + + builder.Services + .AddAuthentication() +#if GoogleAuth + .AddGoogle(options => + { + options.ClientId = config["Authentication:Google:ClientId"]!; + options.ClientSecret = config["Authentication:Google:ClientSecret"]!; +#if TenantCreateExternal + options.ClaimActions.MapJsonKey("hd", "hd"); // Hosted domain (i.e. GSuite domain). +#endif +#if UserPictures + options.ClaimActions.MapJsonKey("pictureUrl", "picture"); +#endif + options.Events.OnTicketReceived = async ctx => + { + await ctx.HttpContext.RequestServices + .GetRequiredService() + .OnGoogleTicketReceived(ctx); + }; + }) +#endif +#if MicrosoftAuth + .AddMicrosoftAccount(options => + { + options.ClientId = config["Authentication:Microsoft:ClientId"]!; + options.ClientSecret = config["Authentication:Microsoft:ClientSecret"]!; +#if (UserPictures || TenantCreateExternal) + options.SaveTokens = true; +#endif + + options.Events.OnTicketReceived = async ctx => + { + await ctx.HttpContext.RequestServices + .GetRequiredService() + .OnMicrosoftTicketReceived(ctx); + }; + }) +#endif + ; + + builder.Services.Configure(o => + { + // Configure how often to refresh user claims and validate + // that the user is still allowed to sign in. + o.ValidationInterval = TimeSpan.FromMinutes(5); + }); + + builder.Services.ConfigureApplicationCookie(c => + { + c.LoginPath = "/sign-in"; // Razor page "Pages/SignIn.cshtml" + +#if Tenancy + var oldOnValidate = c.Events.OnValidatePrincipal; + c.Events.OnValidatePrincipal = async ctx => + { + // Make the current tenantID of the user available to the rest of the request. + // This is the earliest possible point to do so after the auth cookie has been read. + ctx.HttpContext.RequestServices + .GetRequiredService() + .TenantId = ctx.Principal?.GetTenantId(); + + await oldOnValidate(ctx); + }; +#endif + }); + } + +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/SignInService.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/SignInService.cs new file mode 100644 index 000000000..19ea78982 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/SignInService.cs @@ -0,0 +1,282 @@ +using Coalesce.Starter.Vue.Data.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; + +namespace Coalesce.Starter.Vue.Data.Auth; + +public class SignInService( + AppDbContext db, + SignInManager signInManager, + ILogger logger +) +{ +#if GoogleAuth + public async Task OnGoogleTicketReceived(TicketReceivedContext ctx) + { + var (user, remoteLoginInfo) = await GetOrCreateUser(ctx); + if (user is null) + { + await Forbid(ctx); + return; + } + +#if TenantCreateExternal + // Note: domain will be null for personal gmail accounts. + string? gSuiteDomain = ctx.Principal!.FindFirstValue("hd"); + if (!string.IsNullOrWhiteSpace(gSuiteDomain)) + { + await GetAndAssignUserExternalTenant(user, remoteLoginInfo, gSuiteDomain); + } +#endif + +#if UserPictures + // Populate or update user photo from Google + await UpdateUserPhoto(user, ctx.Options.Backchannel, + () => new HttpRequestMessage(HttpMethod.Get, ctx.Principal!.FindFirstValue("pictureUrl"))); + +#endif + // OPTIONAL: Populate additional fields on `user` specific to Google, if any. + + await signInManager.UserManager.UpdateAsync(user); + + await SignInExternalUser(ctx, remoteLoginInfo); + } +#endif + +#if MicrosoftAuth + public async Task OnMicrosoftTicketReceived(TicketReceivedContext ctx) + { + var (user, remoteLoginInfo) = await GetOrCreateUser(ctx); + if (user is null) + { + await Forbid(ctx); + return; + } + +#if TenantCreateExternal + try + { + var accessJwt = new JwtSecurityTokenHandler() + .ReadJwtToken(ctx.Properties!.GetTokenValue(OpenIdConnectParameterNames.AccessToken)); + string? entraTenantId = accessJwt.Claims.FirstOrDefault(c => c.Type == "tid")?.Value; + + if (entraTenantId is not null) + { + await GetAndAssignUserExternalTenant(user, remoteLoginInfo, entraTenantId); + } + } + catch (SecurityTokenMalformedException) + { + // Expected for personal MSFT accounts, which cannot automatically create an external tenant. + // Personal accounts use opaque access tokens, not JWTs. + } +#endif + +#if UserPictures + // Populate or update user photo from Microsoft Graph + await UpdateUserPhoto(user, ctx.Options.Backchannel, () => + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me/photos/96x96/$value"); + request.Headers.Authorization = new("Bearer", ctx.Properties!.GetTokenValue(OpenIdConnectParameterNames.AccessToken)); + return request; + }); + +#endif + // OPTIONAL: Populate additional fields on `user` specific to Microsoft, if any. + + await signInManager.UserManager.UpdateAsync(user); + + await SignInExternalUser(ctx, remoteLoginInfo); + } +#endif + + private static async Task Forbid(TicketReceivedContext ctx, string message = "Forbidden") + { + await Results.Text(message, statusCode: StatusCodes.Status403Forbidden).ExecuteAsync(ctx.HttpContext); + ctx.HandleResponse(); + } + + private async Task<(User? user, UserLoginInfo remoteLoginInfo)> GetOrCreateUser(TicketReceivedContext ctx) + { + var remoteProvider = ctx.Scheme.Name; + var remoteUser = ctx.Principal!; + var remoteUserId = remoteUser.FindFirstValue(ClaimTypes.NameIdentifier)!; + var remoteUserEmail = remoteUser.FindFirstValue(ClaimTypes.Email); + + var remoteLoginInfo = new UserLoginInfo(remoteProvider, remoteUserId, ctx.Scheme.DisplayName); + + // Find by the external user ID + bool foundByLogin = false; + User? user = await signInManager.UserManager.FindByLoginAsync(remoteProvider, remoteUserId); + + // If not found, look for an existing user by email address + if (user is not null) + { + foundByLogin = true; + } + else if (remoteUserEmail is not null) + { + user = await signInManager.UserManager.FindByEmailAsync(remoteUserEmail); + // Don't match existing users by email if the email isn't confirmed. + if (user?.EmailConfirmed == false) user = null; + } + + if (user is null) + { + if (!await CanUserSignUpAsync(ctx, db, remoteUser)) + { + return (null, remoteLoginInfo); + } + + user = new User { UserName = remoteUserEmail }; + +#if Tenancy + // If this user is the first user, make them the global admin + if (!db.Users.Any()) + { + user.IsGlobalAdmin = true; + +#if (!TenantCreateSelf && !TenantCreateExternal) + // Ensure that the very first user belongs to a tenant so they can create more tenants. + var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Name == "Demo Tenant"); + if (tenant is not null) + { + db.TenantId = tenant.TenantId; + db.TenantMemberships.Add(new() { TenantId = tenant.TenantId, User = user }); + user.UserRoles = db.Roles.Select(r => new UserRole { Role = r, User = user }).ToList(); + logger.LogInformation($"Granting demo tenant membership for initial user {user.Id}"); + } +#endif + } +#else + // If this user is the first user, give them all roles so there is an initial admin. + if (!db.Users.Any()) + { + user.UserRoles = db.Roles.Select(r => new UserRole { Role = r, User = user }).ToList(); + } +#endif + + await signInManager.UserManager.CreateAsync(user); + } + + if (!foundByLogin) + { + await signInManager.UserManager.AddLoginAsync(user, remoteLoginInfo); + } + + user.FullName = remoteUser.FindFirstValue(ClaimTypes.Name) ?? user.FullName; + if (!string.IsNullOrWhiteSpace(remoteUserEmail)) + { + user.Email = remoteUserEmail; + user.EmailConfirmed = true; + } + // OPTIONAL: Update any other properties on the user as desired. + + return (user, remoteLoginInfo); + } + + +#if TenantCreateExternal + private async Task GetAndAssignUserExternalTenant( + User user, + UserLoginInfo userLoginInfo, + string externalTenantId + ) + { + var externalId = $"{userLoginInfo.LoginProvider}:{externalTenantId}"; + + var tenant = await db.Tenants.SingleOrDefaultAsync(t => t.ExternalId == externalId); + if (tenant is null) + { + // Automatically create a tenant in our application based on the external tenant. + db.Tenants.Add(tenant = new() { ExternalId = externalId, Name = user.Email?.Split('@').Last() ?? externalId }); + await db.SaveChangesAsync(); + + new DatabaseSeeder(db).SeedNewTenant(tenant, user.Id); + } + db.TenantId = tenant.TenantId; + + var membership = await db.TenantMemberships.SingleOrDefaultAsync(tm => tm.TenantId == tenant.TenantId && tm.UserId == user.Id); + if (membership is null) + { + membership = new() { TenantId = tenant.TenantId, UserId = user.Id }; + db.Add(membership); + + logger.LogInformation($"Automatically granting membership for user {user.Id} into tenant {tenant.TenantId} based on external tenant {externalId}"); + + await db.SaveChangesAsync(); + } + + return tenant; + } +#endif + +#if UserPictures + private async Task UpdateUserPhoto(User user, HttpClient client, Func requestFactory) + { + UserPhoto? photo = user.Photo = db.UserPhotos.Where(p => p.UserId == user.Id).FirstOrDefault(); + if (photo is not null && photo.ModifiedOn >= DateTimeOffset.Now.AddDays(-7)) + { + // User photo already populated and reasonably recent. + return; + } + + var request = requestFactory(); + + if (request.RequestUri is null) return; + + try + { + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) return; + + byte[] content = await response.Content.ReadAsByteArrayAsync(); + + if (content is not { Length: > 0 }) return; + + if (photo is null) + { + user.Photo = photo = new UserPhoto { UserId = user.Id, Content = content }; + } + else + { + photo.Content = content; + photo.SetTracking(user.Id); + } + user.PhotoHash = MD5.HashData(content); + } + catch { /* Failure is non-critical */ } + } +#endif + + private Task CanUserSignUpAsync(TicketReceivedContext ctx, AppDbContext db, ClaimsPrincipal remoteUser) + { + // OPTIONAL: Examine the properties of `remoteUser` and determine if they're permitted to sign up. + return Task.FromResult(true); + } + + private async Task SignInExternalUser(TicketReceivedContext ctx, UserLoginInfo remoteLoginInfo) + { + // ExternalLoginSignInAsync checks that the user isn't locked out. + var result = await signInManager.ExternalLoginSignInAsync( + remoteLoginInfo.LoginProvider, + remoteLoginInfo.ProviderKey, + isPersistent: true, + bypassTwoFactor: true); + + if (!result.Succeeded) + { + await Forbid(ctx); + return; + } + + ctx.HttpContext.Response.Redirect(ctx.ReturnUri ?? "/"); + ctx.HandleResponse(); + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Controllers/HomeController.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Controllers/HomeController.cs index dc48338ac..cd6bb268f 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Controllers/HomeController.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Controllers/HomeController.cs @@ -1,19 +1,16 @@ -using Coalesce.Starter.Vue.Data.Models; +using Coalesce.Starter.Vue.Data.Auth; #if AppInsights using Microsoft.ApplicationInsights.AspNetCore; #endif using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; using System.Web; namespace Coalesce.Starter.Vue.Web.Controllers; -public class HomeController( -#if Identity - SignInManager signInManager -#endif -) : Controller +public class HomeController() : Controller { /// /// Spa route for vue-based parts of the app @@ -31,6 +28,13 @@ public async Task Index( [FromServices] IWebHostEnvironment hostingEnvironment ) { +#if Tenancy + if (!User.HasTenant()) + { + return RedirectToPage("/SelectTenant", new { ReturnUrl = Request.GetEncodedPathAndQuery() }); + } +#endif + var fileInfo = hostingEnvironment.WebRootFileProvider.GetFileInfo("index.html"); if (!fileInfo.Exists) return NotFound($"{hostingEnvironment.WebRootPath}/index.html was not found"); @@ -62,13 +66,4 @@ [FromServices] IWebHostEnvironment hostingEnvironment static string JsEncode(string s) => HttpUtility.JavaScriptStringEncode(s); } - -#if Identity - [HttpGet] - public async new Task SignOut() - { - await signInManager.SignOutAsync(); - return Redirect("/"); - } -#endif } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/TenantDto.g.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/TenantDto.g.cs new file mode 100644 index 000000000..bb2d7f325 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/TenantDto.g.cs @@ -0,0 +1,82 @@ +using IntelliTect.Coalesce; +using IntelliTect.Coalesce.Mapping; +using IntelliTect.Coalesce.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; + +namespace Coalesce.Starter.Vue.Web.Models +{ + public partial class TenantParameter : GeneratedParameterDto + { + public TenantParameter() { } + + private string _TenantId; + private string _Name; + + public string TenantId + { + get => _TenantId; + set { _TenantId = value; Changed(nameof(TenantId)); } + } + public string Name + { + get => _Name; + set { _Name = value; Changed(nameof(Name)); } + } + + /// + /// Map from the current DTO instance to the domain object. + /// + public override void MapTo(Coalesce.Starter.Vue.Data.Models.Tenant entity, IMappingContext context) + { + var includes = context.Includes; + + if (OnUpdate(entity, context)) return; + + if (ShouldMapTo(nameof(TenantId))) entity.TenantId = TenantId; + if (ShouldMapTo(nameof(Name))) entity.Name = Name; + } + + /// + /// Map from the current DTO instance to a new instance of the domain object. + /// + public override Coalesce.Starter.Vue.Data.Models.Tenant MapToNew(IMappingContext context) + { + var includes = context.Includes; + + var entity = new Coalesce.Starter.Vue.Data.Models.Tenant() + { + Name = Name, + }; + + if (OnUpdate(entity, context)) return entity; + if (ShouldMapTo(nameof(TenantId))) entity.TenantId = TenantId; + + return entity; + } + } + + public partial class TenantResponse : GeneratedResponseDto + { + public TenantResponse() { } + + public string TenantId { get; set; } + public string Name { get; set; } + public string ExternalId { get; set; } + + /// + /// Map from the domain object to the properties of the current DTO instance. + /// + public override void MapFrom(Coalesce.Starter.Vue.Data.Models.Tenant obj, IMappingContext context, IncludeTree tree = null) + { + if (obj == null) return; + var includes = context.Includes; + + this.TenantId = obj.TenantId; + this.Name = obj.Name; + this.ExternalId = obj.ExternalId; + } + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/UserDto.g.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/UserDto.g.cs index d993dac35..5ac3b0b66 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/UserDto.g.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/UserDto.g.cs @@ -15,9 +15,7 @@ public UserParameter() { } private string _Id; private string _FullName; private string _UserName; - private string _Email; - private System.DateTimeOffset? _LockoutEnd; - private bool? _LockoutEnabled; + private bool? _IsGlobalAdmin; public string Id { @@ -34,20 +32,10 @@ public string UserName get => _UserName; set { _UserName = value; Changed(nameof(UserName)); } } - public string Email + public bool? IsGlobalAdmin { - get => _Email; - set { _Email = value; Changed(nameof(Email)); } - } - public System.DateTimeOffset? LockoutEnd - { - get => _LockoutEnd; - set { _LockoutEnd = value; Changed(nameof(LockoutEnd)); } - } - public bool? LockoutEnabled - { - get => _LockoutEnabled; - set { _LockoutEnabled = value; Changed(nameof(LockoutEnabled)); } + get => _IsGlobalAdmin; + set { _IsGlobalAdmin = value; Changed(nameof(IsGlobalAdmin)); } } /// @@ -62,9 +50,7 @@ public override void MapTo(Coalesce.Starter.Vue.Data.Models.User entity, IMappin if (ShouldMapTo(nameof(Id))) entity.Id = Id; if (ShouldMapTo(nameof(FullName))) entity.FullName = FullName; if (ShouldMapTo(nameof(UserName))) entity.UserName = UserName; - if (ShouldMapTo(nameof(Email)) && (context.IsInRoleCached("UserAdmin"))) entity.Email = Email; - if (ShouldMapTo(nameof(LockoutEnd)) && (context.IsInRoleCached("UserAdmin"))) entity.LockoutEnd = LockoutEnd; - if (ShouldMapTo(nameof(LockoutEnabled)) && (context.IsInRoleCached("UserAdmin"))) entity.LockoutEnabled = (LockoutEnabled ?? entity.LockoutEnabled); + if (ShouldMapTo(nameof(IsGlobalAdmin)) && (context.IsInRoleCached("GlobalAdmin"))) entity.IsGlobalAdmin = (IsGlobalAdmin ?? entity.IsGlobalAdmin); } /// @@ -84,12 +70,12 @@ public UserResponse() { } public string Id { get; set; } public string FullName { get; set; } - public byte[] PhotoMD5 { get; set; } public string UserName { get; set; } public string Email { get; set; } - public System.DateTimeOffset? LockoutEnd { get; set; } - public bool? LockoutEnabled { get; set; } - public string EffectivePermissions { get; set; } + public bool? EmailConfirmed { get; set; } + public byte[] PhotoHash { get; set; } + public System.Collections.Generic.ICollection RoleNames { get; set; } + public bool? IsGlobalAdmin { get; set; } public System.Collections.Generic.ICollection UserRoles { get; set; } /// @@ -102,19 +88,18 @@ public override void MapFrom(Coalesce.Starter.Vue.Data.Models.User obj, IMapping this.Id = obj.Id; this.FullName = obj.FullName; - this.PhotoMD5 = obj.PhotoMD5; this.UserName = obj.UserName; + this.Email = obj.Email; + this.EmailConfirmed = obj.EmailConfirmed; + this.PhotoHash = obj.PhotoHash; if ((context.IsInRoleCached("UserAdmin"))) { - this.Email = obj.Email; - this.LockoutEnd = obj.LockoutEnd; - this.LockoutEnabled = obj.LockoutEnabled; - this.EffectivePermissions = obj.EffectivePermissions; + this.RoleNames = obj.RoleNames?.ToList(); var propValUserRoles = obj.UserRoles; if (propValUserRoles != null && (tree == null || tree[nameof(this.UserRoles)] != null)) { this.UserRoles = propValUserRoles - .OrderBy(f => (f.User == null ? "" : f.User.Id)).ThenBy(f => (f.Role == null ? "" : f.Role.Name)) + .OrderBy(f => f.TenantId).ThenBy(f => (f.User == null ? "" : f.User.Id)).ThenBy(f => (f.Role == null ? "" : f.Role.TenantId)) .Select(f => f.MapToDto(context, tree?[nameof(this.UserRoles)])).ToList(); } else if (propValUserRoles == null && tree?[nameof(this.UserRoles)] != null) @@ -124,6 +109,7 @@ public override void MapFrom(Coalesce.Starter.Vue.Data.Models.User obj, IMapping } + if ((context.IsInRoleCached("GlobalAdmin"))) this.IsGlobalAdmin = obj.IsGlobalAdmin; } } } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/UserInfoDto.g.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/UserInfoDto.g.cs index 013587b28..9c8959c0b 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/UserInfoDto.g.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Models/Generated/UserInfoDto.g.cs @@ -18,6 +18,8 @@ public UserInfoParameter() { } private string _FullName; private System.Collections.Generic.ICollection _Roles; private System.Collections.Generic.ICollection _Permissions; + private string _TenantId; + private string _TenantName; public string Id { @@ -49,6 +51,16 @@ public System.Collections.Generic.ICollection Permissions get => _Permissions; set { _Permissions = value; Changed(nameof(Permissions)); } } + public string TenantId + { + get => _TenantId; + set { _TenantId = value; Changed(nameof(TenantId)); } + } + public string TenantName + { + get => _TenantName; + set { _TenantName = value; Changed(nameof(TenantName)); } + } /// /// Map from the current DTO instance to the domain object. @@ -65,6 +77,8 @@ public override void MapTo(Coalesce.Starter.Vue.Data.Auth.UserInfo entity, IMapp if (ShouldMapTo(nameof(FullName))) entity.FullName = FullName; if (ShouldMapTo(nameof(Roles))) entity.Roles = Roles; if (ShouldMapTo(nameof(Permissions))) entity.Permissions = Permissions; + if (ShouldMapTo(nameof(TenantId))) entity.TenantId = TenantId; + if (ShouldMapTo(nameof(TenantName))) entity.TenantName = TenantName; } /// @@ -85,6 +99,8 @@ public override Coalesce.Starter.Vue.Data.Auth.UserInfo MapToNew(IMappingContext if (ShouldMapTo(nameof(UserName))) entity.UserName = UserName; if (ShouldMapTo(nameof(Email))) entity.Email = Email; if (ShouldMapTo(nameof(FullName))) entity.FullName = FullName; + if (ShouldMapTo(nameof(TenantId))) entity.TenantId = TenantId; + if (ShouldMapTo(nameof(TenantName))) entity.TenantName = TenantName; return entity; } @@ -100,6 +116,8 @@ public UserInfoResponse() { } public string FullName { get; set; } public System.Collections.Generic.ICollection Roles { get; set; } public System.Collections.Generic.ICollection Permissions { get; set; } + public string TenantId { get; set; } + public string TenantName { get; set; } /// /// Map from the domain object to the properties of the current DTO instance. @@ -115,6 +133,8 @@ public override void MapFrom(Coalesce.Starter.Vue.Data.Auth.UserInfo obj, IMappi this.FullName = obj.FullName; this.Roles = obj.Roles; this.Permissions = obj.Permissions; + this.TenantId = obj.TenantId; + this.TenantName = obj.TenantName; } } } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/CreateTenant.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/CreateTenant.cshtml new file mode 100644 index 000000000..9ae9cdf1a --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/CreateTenant.cshtml @@ -0,0 +1,21 @@ +@page "/new-org" +@model Coalesce.Starter.Vue.Web.Pages.CreateTenantModel + +@{ + ViewData["Title"] = "New Organization"; +} + +
+ +
+
+ + +
+ +
+ +
+
\ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/CreateTenant.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/CreateTenant.cshtml.cs new file mode 100644 index 000000000..e3a7ac6b5 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/CreateTenant.cshtml.cs @@ -0,0 +1,44 @@ +using Coalesce.Starter.Vue.Data; +using Coalesce.Starter.Vue.Data.Auth; +using Coalesce.Starter.Vue.Data.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.ComponentModel.DataAnnotations; + +namespace Coalesce.Starter.Vue.Web.Pages +{ + [Authorize] + public class CreateTenantModel(AppDbContext db) : PageModel + { + [Required] + [BindProperty] + [Display(Name = "Organization Name")] + public string? Name { get; set; } + + public void OnGet() + { + } + + public async Task OnPostAsync( + [FromServices] SignInManager signInManager + ) + { + if (!ModelState.IsValid) return Page(); + + Tenant tenant = new() { Name = Name! }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + db.ForceSetTenant(tenant.TenantId); + new DatabaseSeeder(db).SeedNewTenant(tenant, User.GetUserId()); + + // Sign the user into the new tenant (uses `db.TenantId`). + var user = await db.Users.FindAsync(User.GetUserId()); + await signInManager.RefreshSignInAsync(user!); + + return Redirect("/"); + } + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Invitation.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Invitation.cshtml new file mode 100644 index 000000000..c9d283d73 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Invitation.cshtml @@ -0,0 +1,34 @@ +@page "/invitation" +@model Coalesce.Starter.Vue.Web.Pages.InvitationModel +@{ + ViewData["Title"] = "Join Organization"; +} + +
+ +@if (ModelState.IsValid) +{ +

+ You have been invited to join the @Model.Tenant.Name organization. You will join as @User.GetUserName(). +

+ +
+ + +
+ +
+ + +
+ +
+ +
+} \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Invitation.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Invitation.cshtml.cs new file mode 100644 index 000000000..7fc5455fa --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Invitation.cshtml.cs @@ -0,0 +1,76 @@ +using Coalesce.Starter.Vue.Data; +using Coalesce.Starter.Vue.Data.Auth; +using Coalesce.Starter.Vue.Data.Models; +using IntelliTect.Coalesce.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.ComponentModel.DataAnnotations; + +#nullable disable + +namespace Coalesce.Starter.Vue.Web.Pages +{ + [Authorize] + public class InvitationModel( + InvitationService invitationService, + SignInManager signInManager, + AppDbContext db + ) : PageModel + { + [BindProperty(SupportsGet = true), Required] + public string Code { get; set; } + + internal UserInvitation Invitation { get; private set; } + + internal Tenant Tenant { get; private set; } + + public void OnGet() + { + DecodeInvitation(); + } + + public async Task OnPost() + { + DecodeInvitation(); + if (!ModelState.IsValid) return Page(); + + db.ForceSetTenant(Invitation.TenantId); + + var user = await db.Users.FindAsync(User.GetUserId()); + var result = await invitationService.AcceptInvitation(Invitation, user); + if (!result.WasSuccessful) + { + ModelState.AddModelError(nameof(Code), result.Message); + return Page(); + } + + // Sign the user into the newly joined tenant (uses `db.TenantId`). + await signInManager.RefreshSignInAsync(user); + + return Redirect("/"); + } + + private void DecodeInvitation() + { + if (string.IsNullOrWhiteSpace(Code)) return; + + var decodeResult = invitationService.DecodeInvitation(Code); + if (!decodeResult.WasSuccessful) + { + ModelState.AddModelError(nameof(Code), decodeResult.Message); + return; + } + Invitation = decodeResult.Object; + + var tenant = db.Tenants.Find(Invitation.TenantId); + if (tenant is null) + { + ModelState.AddModelError(nameof(Code), "The invitation link is not valid."); + return; + } + Tenant = tenant; + } + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SelectTenant.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SelectTenant.cshtml new file mode 100644 index 000000000..03b20b3c7 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SelectTenant.cshtml @@ -0,0 +1,47 @@ +@page "/select-org" +@model Coalesce.Starter.Vue.Web.Pages.SelectTenantModel + +@{ + ViewData["Title"] = "Choose Organization"; +} + +
+ +
+ + @foreach (var tenant in Model.Tenants) + { + + } + @if (!Model.Tenants.Any()) + { +

+ @User.GetUserName() is a not a member of any organization. +

+ } +
+ +
+ +@*#if (TenantCreateSelf)*@ +
+ +
+@*#endif*@ + +
+ + + +
diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SelectTenant.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SelectTenant.cshtml.cs new file mode 100644 index 000000000..e61d41b7a --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SelectTenant.cshtml.cs @@ -0,0 +1,57 @@ +using Coalesce.Starter.Vue.Data; +using Coalesce.Starter.Vue.Data.Auth; +using Coalesce.Starter.Vue.Data.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace Coalesce.Starter.Vue.Web.Pages +{ + [Authorize] + public class SelectTenantModel(AppDbContext db) : PageModel + { + [BindProperty(SupportsGet = true)] + public string? ReturnUrl { get; set; } + + public List Tenants { get; private set; } = []; + + public async Task OnGet() + { + await LoadTenants(); + } + + public async Task OnPost( + [FromForm] string tenantId, + [FromServices] SignInManager signInManager + ) + { + await LoadTenants(); + if (!Tenants.Any(t => t.TenantId == tenantId)) + { + ModelState.AddModelError("tenantId", "Invalid Tenant"); + } + if (!ModelState.IsValid) return Page(); + + db.ForceSetTenant(tenantId); + + var user = await db.Users.FindAsync(User.GetUserId()); + await signInManager.RefreshSignInAsync(user!); + + return LocalRedirect(string.IsNullOrWhiteSpace(ReturnUrl) ? "/" : ReturnUrl); + } + + private async Task LoadTenants() + { + var userId = User.GetUserId(); + Tenants = await db.TenantMemberships + .IgnoreQueryFilters() + .Where(tm => tm.UserId == userId) + .Select(tm => tm.Tenant!) + .OrderBy(t => t.Name) + .ToListAsync(); + } + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignIn.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignIn.cshtml index c16ed05b4..6b257d6dc 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignIn.cshtml +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignIn.cshtml @@ -5,103 +5,30 @@ @{ var externalLogins = (await schemeProvider.GetAllSchemesAsync()).Where(s => s.DisplayName != null); -} - - - - - Sign In - - - - - - - - - - -
-
- @* *@ - Sign In -
+ ViewData["Title"] = "Sign In"; +} -
- @Html.AntiForgeryToken() - - @foreach (var provider in externalLogins) - { - - } -
+@*#if TenantMemberInvites *@ +@if (Model.ReturnUrl?.StartsWith("/invitation") == true) +{ + - - - +} +@*#endif *@ +
+ + +
+ @foreach (var provider in externalLogins) + { + + } +
+
diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignIn.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignIn.cshtml.cs index e55575e8d..dc235c5b8 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignIn.cshtml.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignIn.cshtml.cs @@ -10,17 +10,25 @@ public class SignInModel : PageModel [BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; } + [BindProperty] + public string? Provider { get; set; } + public void OnGet() { } - public IActionResult OnPost([FromForm] string provider) + public IActionResult OnPost() { - // Request a redirect to the external login provider. - return new ChallengeResult(provider, new() + if (!string.IsNullOrWhiteSpace(Provider)) { - RedirectUri = ReturnUrl - }); + // Request a redirect to the external login provider. + return new ChallengeResult(Provider, new() + { + RedirectUri = ReturnUrl + }); + } + + return Page(); } } } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignOut.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignOut.cshtml new file mode 100644 index 000000000..6cfacbebd --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignOut.cshtml @@ -0,0 +1,7 @@ +@page "/sign-out" +@using Microsoft.AspNetCore.Authentication +@model Coalesce.Starter.Vue.Web.Pages.SignOutModel + +@{ + ViewData["Title"] = "Sign Out"; +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignOut.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignOut.cshtml.cs new file mode 100644 index 000000000..09df66e17 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/SignOut.cshtml.cs @@ -0,0 +1,22 @@ +using Coalesce.Starter.Vue.Data.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Coalesce.Starter.Vue.Web.Pages +{ + public class SignOutModel(SignInManager signInManager) : PageModel + { + public async Task OnGet() + { + await signInManager.SignOutAsync(); + return Redirect("/"); + } + + public async Task OnPost() + { + await signInManager.SignOutAsync(); + return Redirect("/"); + } + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_Layout.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_Layout.cshtml new file mode 100644 index 000000000..eae1264ae --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_Layout.cshtml @@ -0,0 +1,57 @@ +@*#if (AppInsights) +@inject Microsoft.ApplicationInsights.AspNetCore.JavaScriptSnippet JavaScriptSnippet +#endif*@ + + + + + + + @ViewData["Title"] + + + + @*#if (AppInsights) + @Html.Raw(JavaScriptSnippet.FullScript) + #endif*@ + + + + +
+
+

+ @* *@ + + @ViewData["Title"] +

+ + @RenderBody() +
+
+ + + @RenderSection("Scripts", required: false) + + \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_ViewImports.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_ViewImports.cshtml new file mode 100644 index 000000000..9397f9d75 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_ViewImports.cshtml @@ -0,0 +1,2 @@ +@using Coalesce.Starter.Vue.Data.Auth +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_ViewStart.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_ViewStart.cshtml new file mode 100644 index 000000000..596003a68 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout.cshtml"; +} \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Program.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Program.cs index c29df0a28..d0528e8bf 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Program.cs +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Program.cs @@ -18,23 +18,27 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Coalesce.Starter.Vue.Web.Auth; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(new WebApplicationOptions { - Args = args, - // Explicit declaration prevents ASP.NET Core from erroring if wwwroot doesn't exist at startup: - WebRootPath = "wwwroot" + Args = args, + // Explicit declaration prevents ASP.NET Core from erroring if wwwroot doesn't exist at startup: + WebRootPath = "wwwroot" }); builder.Logging - .AddConsole() - // Filter out Request Starting/Request Finished noise: - .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); + .AddConsole() + // Filter out Request Starting/Request Finished noise: + .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); builder.Configuration - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .AddJsonFile("appsettings.localhost.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables(); + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile("appsettings.localhost.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables(); #region Configure Services @@ -46,36 +50,37 @@ b.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"]; }); services.AddSingleton(); -services.ConfigureTelemetryModule((module, o) => { - module.EnableSqlCommandTextInstrumentation = true; +services.ConfigureTelemetryModule((module, o) => +{ + module.EnableSqlCommandTextInstrumentation = true; }); -// App insights filters all logs to Warning by default. +// App insights filters all logs to Warning by default. We want to include our own logging. builder.Logging.AddFilter("Coalesce.Starter.Vue", LogLevel.Information); #endif services.AddDbContext(options => options - .UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"), opt => opt - .EnableRetryOnFailure() - .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) - ) - // Ignored because it interferes with the construction of Coalesce IncludeTrees via .Include() - .ConfigureWarnings(warnings => warnings.Ignore(CoreEventId.NavigationBaseIncludeIgnored)) + .UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"), opt => opt + .EnableRetryOnFailure() + .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) + ) + // Ignored because it interferes with the construction of Coalesce IncludeTrees via .Include() + .ConfigureWarnings(warnings => warnings.Ignore(CoreEventId.NavigationBaseIncludeIgnored)) ); services.AddCoalesce(); services.AddDataProtection() - .PersistKeysToDbContext(); + .PersistKeysToDbContext(); services - .AddMvc() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; - options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; - }); + .AddMvc() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); #if Identity builder.ConfigureAuthentication(); @@ -84,14 +89,27 @@ #if OpenAPI services.AddSwaggerGen(c => { - c.AddCoalesce(); - c.SwaggerDoc("current", new OpenApiInfo { Title = "Current API", Version = "current" }); + c.AddCoalesce(); + c.SwaggerDoc("current", new OpenApiInfo { Title = "Current API", Version = "current" }); }); #endif services.AddScoped(); +#if TenantMemberInvites +// Register IUrlHelper to allow for invite link generation. +services.AddSingleton(); +services.AddScoped(x => +{ + var actionContext = x.GetRequiredService().ActionContext; + var factory = x.GetRequiredService(); + return factory.GetUrlHelper(actionContext!); +}); + +services.AddScoped(); +#endif + #endregion @@ -101,32 +119,32 @@ if (app.Environment.IsDevelopment()) { - app.UseDeveloperExceptionPage(); - - app.UseViteDevelopmentServer(c => - { - c.DevServerPort = 5002; - }); - - app.MapCoalesceSecurityOverview("coalesce-security"); - - #if (!Identity) - // TODO: Dummy authentication for initial development. - // Replace this with a proper authentication scheme like - // Windows Authentication, or an OIDC provider, or something else. - // If you wanted to use ASP.NET Core Identity, you're recommended - // to keep the "--Identity" parameter to the Coalesce template enabled. - app.Use(async (context, next) => - { - Claim[] claims = [new Claim(ClaimTypes.Name, "developmentuser")]; - - var identity = new ClaimsIdentity(claims, "dummy-auth"); - context.User = new ClaimsPrincipal(identity); - - await next.Invoke(); - }); - // End Dummy Authentication. - #endif + app.UseDeveloperExceptionPage(); + + app.UseViteDevelopmentServer(c => + { + c.DevServerPort = 5002; + }); + + app.MapCoalesceSecurityOverview("coalesce-security"); + +#if (!Identity) + // TODO: Dummy authentication for initial development. + // Replace this with a proper authentication scheme like + // Windows Authentication, or an OIDC provider, or something else. + // If you wanted to use ASP.NET Core Identity, you're recommended + // to keep the "--Identity" parameter to the Coalesce template enabled. + app.Use(async (context, next) => + { + Claim[] claims = [new Claim(ClaimTypes.Name, "developmentuser")]; + + var identity = new ClaimsIdentity(claims, "dummy-auth"); + context.User = new ClaimsPrincipal(identity); + + await next.Invoke(); + }); + // End Dummy Authentication. +#endif } app.UseAuthentication(); @@ -135,30 +153,30 @@ var containsFileHashRegex = new Regex(@"[.-][0-9a-zA-Z-_]{8}\.[^\.]*$", RegexOptions.Compiled); app.UseStaticFiles(new StaticFileOptions { - OnPrepareResponse = ctx => - { - // vite puts 8-char hashes before the file extension. - // Use this to determine if we can send a long-term cache duration. - if (containsFileHashRegex.IsMatch(ctx.File.Name)) - { - ctx.Context.Response.GetTypedHeaders().CacheControl = new() { Public = true, MaxAge = TimeSpan.FromDays(30) }; - } - } + OnPrepareResponse = ctx => + { + // vite puts 8-char hashes before the file extension. + // Use this to determine if we can send a long-term cache duration. + if (containsFileHashRegex.IsMatch(ctx.File.Name)) + { + ctx.Context.Response.GetTypedHeaders().CacheControl = new() { Public = true, MaxAge = TimeSpan.FromDays(30) }; + } + } }); // For all requests that aren't to static files, disallow caching by default. // Individual endpoints may override this. app.Use(async (context, next) => { - context.Response.GetTypedHeaders().CacheControl = new() { NoCache = true, NoStore = true }; - await next(); + context.Response.GetTypedHeaders().CacheControl = new() { NoCache = true, NoStore = true }; + await next(); }); #if OpenAPI app.MapSwagger(); app.UseSwaggerUI(c => { - c.SwaggerEndpoint("/swagger/current/swagger.json", "Current API"); + c.SwaggerEndpoint("/swagger/current/swagger.json", "Current API"); }); #endif @@ -177,16 +195,16 @@ // Initialize/migrate database. using (var scope = app.Services.CreateScope()) { - var serviceScope = scope.ServiceProvider; + var serviceScope = scope.ServiceProvider; - // Run database migrations. - using var db = serviceScope.GetRequiredService(); - db.Database.SetCommandTimeout(TimeSpan.FromMinutes(10)); + // Run database migrations. + using var db = serviceScope.GetRequiredService(); + db.Database.SetCommandTimeout(TimeSpan.FromMinutes(10)); #if KeepTemplateOnly - db.Database.EnsureDeleted(); - db.Database.EnsureCreated(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); #else - db.Database.Migrate(); + db.Database.Migrate(); #endif new DatabaseSeeder(db).Seed(); } diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/ProgramAuth.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/ProgramAuth.cs deleted file mode 100644 index e1c49021b..000000000 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/ProgramAuth.cs +++ /dev/null @@ -1,236 +0,0 @@ -using Coalesce.Starter.Vue.Data.Models; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Identity; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using System.Data; -using System.Security.Claims; -using System.Security.Cryptography; - -namespace Coalesce.Starter.Vue.Data.Auth; - -public static class ProgramAuth -{ - public static void ConfigureAuthentication(this WebApplicationBuilder builder) - { - var config = builder.Configuration; - - builder.Services - .AddIdentity() - .AddEntityFrameworkStores() - .AddDefaultTokenProviders() - .AddClaimsPrincipalFactory(); - - builder.Services - .AddAuthentication() -#if GoogleAuth - .AddGoogle(options => - { - options.ClientId = config["Authentication:Google:ClientId"]!; - options.ClientSecret = config["Authentication:Google:ClientSecret"]!; - options.ClaimActions.MapJsonKey("hd", "hd"); // Hosted domain (i.e. GSuite domain). -#if UserPictures - options.ClaimActions.MapJsonKey("pictureUrl", "picture"); -#endif - - options.Events.OnTicketReceived = OnTicketReceived(async (user, db, ctx) => - { - // OPTIONAL: Populate fields on `user` specific to Google, if any. - - // NOTE: If needed, the GSuite domain (if any) of the user can be acquired as follows: - //var domain = ctx.Principal!.FindFirstValue("hd") -#if UserPictures - // Populate or update user photo from Google - await UpdateUserPhoto(user, db, ctx.Options.Backchannel, - () => new HttpRequestMessage(HttpMethod.Get, ctx.Principal!.FindFirstValue("pictureUrl"))); -#endif - }); - }) -#endif -#if MicrosoftAuth - .AddMicrosoftAccount(options => - { - options.ClientId = config["Authentication:Microsoft:ClientId"]!; - options.ClientSecret = config["Authentication:Microsoft:ClientSecret"]!; -#if UserPictures - options.SaveTokens = true; -#endif - - options.Events.OnTicketReceived = OnTicketReceived(async (user, db, ctx) => - { - // OPTIONAL: Populate additional fields on `user` specific to Microsoft, if any. - - // NOTE: If needed, the Entra/AAD TenantID of the user can be acquired as follows: - //var tenantId = new JwtSecurityTokenHandler() - // .ReadJwtToken(ctx.Properties!.GetTokenValue(OpenIdConnectParameterNames.AccessToken)) - // .Claims.First(c => c.Type == "tid").Value; - -#if UserPictures - // Populate or update user photo from Microsoft Graph - await UpdateUserPhoto(user, db, ctx.Options.Backchannel, () => { - var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me/photos/96x96/$value"); - request.Headers.Authorization = new("Bearer", ctx.Properties!.GetTokenValue(OpenIdConnectParameterNames.AccessToken)); - return request; - }); -#endif - }); - }) -#endif - ; - - builder.Services.Configure(o => - { - // Configure how often to refresh user claims and validate - // that the user is still allowed to sign in. - o.ValidationInterval = TimeSpan.FromMinutes(5); - }); - - builder.Services.ConfigureApplicationCookie(c => - { - c.LoginPath = "/sign-in"; // Razor page "Pages/SignIn.cshtml" - }); - } - -#if UserPictures - private static async Task UpdateUserPhoto(User user, AppDbContext db, HttpClient client, Func requestFactory) - { - UserPhoto? photo = user.Photo = db.UserPhotos.Where(p => p.UserId == user.Id).FirstOrDefault(); - if (photo is not null && photo.ModifiedOn >= DateTimeOffset.Now.AddDays(-7)) - { - // User photo already populated and reasonably recent. - return; - } - - var request = requestFactory(); - - if (request.RequestUri is null) return; - - try - { - var response = await client.SendAsync(request); - if (!response.IsSuccessStatusCode) return; - - byte[] content = await response.Content.ReadAsByteArrayAsync(); - - if (content is not { Length: > 0 }) return; - - if (photo is null) - { - user.Photo = photo = new UserPhoto { UserId = user.Id, Content = content }; - } - else - { - photo.Content = content; - photo.SetTracking(user.Id); - } - user.PhotoMD5 = MD5.HashData(content); - } - catch { /* Failure is non-critical */ } - } -#endif - -#if (GoogleAuth || MicrosoftAuth) - private static Func OnTicketReceived( - Func? updateUser = null - ) => async (TicketReceivedContext ctx) => - { - var db = ctx.HttpContext.RequestServices.GetRequiredService(); - var signInManager = ctx.HttpContext.RequestServices.GetRequiredService>(); - - var (user, remoteLoginInfo) = await GetOrCreateUser(ctx, db, signInManager); - if (user is null) - { - await Results.Text("Forbidden", statusCode: StatusCodes.Status403Forbidden).ExecuteAsync(ctx.HttpContext); - ctx.HandleResponse(); - return; - } - - if (updateUser is not null) await updateUser(user, db, ctx); - - await signInManager.UserManager.UpdateAsync(user); - - // ExternalLoginSignInAsync checks that the user isn't locked out. - var result = await signInManager.ExternalLoginSignInAsync( - remoteLoginInfo.LoginProvider, - remoteLoginInfo.ProviderKey, - isPersistent: true, - bypassTwoFactor: true); - - if (!result.Succeeded) - { - await Results.Text("Forbidden", statusCode: StatusCodes.Status403Forbidden).ExecuteAsync(ctx.HttpContext); - ctx.HandleResponse(); - return; - } - - ctx.HttpContext.Response.Redirect(ctx.ReturnUri ?? "/"); - ctx.HandleResponse(); - }; - - private static async Task<(User? user, UserLoginInfo remoteLoginInfo)> GetOrCreateUser( - TicketReceivedContext ctx, - AppDbContext db, - SignInManager signInManager - ) - { - var remoteProvider = ctx.Scheme.Name; - var remoteUser = ctx.Principal!; - var remoteUserId = remoteUser.FindFirstValue(ClaimTypes.NameIdentifier)!; - var remoteUserEmail = remoteUser.FindFirstValue(ClaimTypes.Email); - - var remoteLoginInfo = new UserLoginInfo(remoteProvider, remoteUserId, ctx.Scheme.DisplayName); - - // Find by the external user ID - bool foundByLogin = false; - User? user = await signInManager.UserManager.FindByLoginAsync(remoteProvider, remoteUserId); - - // If not found, look for an existing user by email address - if (user is not null) - { - foundByLogin = true; - } - else if (remoteUserEmail is not null) - { - user = await signInManager.UserManager.FindByEmailAsync(remoteUserEmail); - // Don't match existing users by email if the email isn't confirmed. - if (user?.EmailConfirmed == false) user = null; - } - - if (user is null) - { - if (!await CanUserSignUpAsync(ctx, db, remoteUser)) - { - return (null, remoteLoginInfo); - } - - user = new User { UserName = remoteUserEmail }; - - // If this user is the first user, give them all roles so there is an initial admin. - if (!db.Users.Any()) - { - user.UserRoles = db.Roles.Select(r => new UserRole { Role = r, User = user }).ToList(); - } - - await signInManager.UserManager.CreateAsync(user); - } - - if (!foundByLogin) - { - await signInManager.UserManager.AddLoginAsync(user, remoteLoginInfo); - } - - user.FullName = remoteUser.FindFirstValue(ClaimTypes.Name) ?? user.FullName; - user.Email = remoteUserEmail ?? user.Email; - user.EmailConfirmed = true; - // OPTIONAL: Update any other properties on the user as desired. - - return (user, remoteLoginInfo); - } - - private static Task CanUserSignUpAsync(TicketReceivedContext ctx, AppDbContext db, ClaimsPrincipal remoteUser) - { - // OPTIONAL: Examine the properties of `remoteUser` and determine if they're permitted to sign up. - return Task.FromResult(true); - } - -#endif -} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/package-lock.json b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/package-lock.json index 7288379cf..a2f1af210 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/package-lock.json +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.6.0", "@vueuse/core": "^11.0.3", - "coalesce-vue": "5.0.0", - "coalesce-vue-vuetify3": "5.0.0", + "coalesce-vue": "5.0.1", + "coalesce-vue-vuetify3": "5.0.1", "typeface-roboto": "1.1.13", "vue": "^3.5.0", "vue-router": "^4.4.3", @@ -821,9 +821,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz", - "integrity": "sha512-fSuPrt0ZO8uXeS+xP3b+yYTCBUd05MoSp2N/MFOgjhhUhMmchXlpTQrTpI8T+YAwAQuK7MafsCOxW7VrPMrJcg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -834,9 +834,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.2.tgz", - "integrity": "sha512-xGU5ZQmPlsjQS6tzTTGwMsnKUtu0WVbl0hYpTPauvbRAnmIvpInhJtgjj3mcuJpEiuUw4v1s4BimkdfDWlh7gA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -847,9 +847,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.2.tgz", - "integrity": "sha512-99AhQ3/ZMxU7jw34Sq8brzXqWH/bMnf7ZVhvLk9QU2cOepbQSVTns6qoErJmSiAvU3InRqC2RRZ5ovh1KN0d0Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -860,9 +860,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.2.tgz", - "integrity": "sha512-ZbRaUvw2iN/y37x6dY50D8m2BnDbBjlnMPotDi/qITMJ4sIxNY33HArjikDyakhSv0+ybdUxhWxE6kTI4oX26w==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -873,9 +873,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.2.tgz", - "integrity": "sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -886,9 +886,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.2.tgz", - "integrity": "sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -899,9 +899,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.2.tgz", - "integrity": "sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -912,9 +912,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.2.tgz", - "integrity": "sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -925,9 +925,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.2.tgz", - "integrity": "sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -938,9 +938,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.2.tgz", - "integrity": "sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -951,9 +951,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.2.tgz", - "integrity": "sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -964,9 +964,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.2.tgz", - "integrity": "sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -977,9 +977,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.2.tgz", - "integrity": "sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -990,9 +990,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.2.tgz", - "integrity": "sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -1003,9 +1003,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.2.tgz", - "integrity": "sha512-5rA4vjlqgrpbFVVHX3qkrCo/fZTj1q0Xxpg+Z7yIo3J2AilW7t2+n6Q8Jrx+4MrYpAnjttTYF8rr7bP46BPzRw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -1016,9 +1016,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.2.tgz", - "integrity": "sha512-6UUxd0+SKomjdzuAcp+HAmxw1FlGBnl1v2yEPSabtx4lBfdXHDVsW7+lQkgz9cNFJGY3AWR7+V8P5BqkD9L9nA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -1949,9 +1949,9 @@ } }, "node_modules/coalesce-vue": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/coalesce-vue/-/coalesce-vue-5.0.0.tgz", - "integrity": "sha512-9PgBfetmP5XJNwehjk4noU0p8oWdJ8Jl7lnXRezaNQAfhB0Co757+INQlDVipCw8VmYg40IW1zDTKH9E/R5DIA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/coalesce-vue/-/coalesce-vue-5.0.1.tgz", + "integrity": "sha512-CAIifw14NiqSicuWART/t4U/yDG8LrRzmNbA+RLrmaF289CiQ5gyJuNxZC3tLuyWgexB0EysbRli3pq7LVp/Cw==", "dependencies": { "@types/lodash-es": "^4.17.3", "axios": "^1.3.4", @@ -1969,9 +1969,9 @@ } }, "node_modules/coalesce-vue-vuetify3": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/coalesce-vue-vuetify3/-/coalesce-vue-vuetify3-5.0.0.tgz", - "integrity": "sha512-RFngKY8/lVgsSoPN1+Y1jdkEKI00sUBrGTArtPX69BpGpvBQxDN+Ctx/kmN90HhlLNwqpF2Sr5gTTSGV4Yc90Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/coalesce-vue-vuetify3/-/coalesce-vue-vuetify3-5.0.1.tgz", + "integrity": "sha512-OkXGQmiEsFxdawkKaDwQqIQN8B4Km73JpGH+kFecrwb7vz3hyEQ0OfQRUon6sKBPHjaIFhU3U1cMmp9HdBbz8A==", "dependencies": { "date-fns": "^3.0.0", "date-fns-tz": "^3.0.0", @@ -1981,7 +1981,7 @@ "vue-router": "^4.4.1" }, "peerDependencies": { - "coalesce-vue": "5.0.0", + "coalesce-vue": "5.0.1", "vue": "^3.4.0", "vuetify": "^3.7.1" } @@ -4072,9 +4072,9 @@ } }, "node_modules/rollup": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.2.tgz", - "integrity": "sha512-e3TapAgYf9xjdLvKQCkQTnbTKd4a6jwlpQSJJFokHGaX2IVjoEqkIIhiQfqsi0cdwlOD+tQGuOd5AJkc5RngBw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -4087,22 +4087,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.21.2", - "@rollup/rollup-android-arm64": "4.21.2", - "@rollup/rollup-darwin-arm64": "4.21.2", - "@rollup/rollup-darwin-x64": "4.21.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.21.2", - "@rollup/rollup-linux-arm-musleabihf": "4.21.2", - "@rollup/rollup-linux-arm64-gnu": "4.21.2", - "@rollup/rollup-linux-arm64-musl": "4.21.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.21.2", - "@rollup/rollup-linux-riscv64-gnu": "4.21.2", - "@rollup/rollup-linux-s390x-gnu": "4.21.2", - "@rollup/rollup-linux-x64-gnu": "4.21.2", - "@rollup/rollup-linux-x64-musl": "4.21.2", - "@rollup/rollup-win32-arm64-msvc": "4.21.2", - "@rollup/rollup-win32-ia32-msvc": "4.21.2", - "@rollup/rollup-win32-x64-msvc": "4.21.2", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/package.json b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/package.json index 4650a0830..5427bcca6 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/package.json +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/package.json @@ -15,8 +15,8 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.6.0", "@vueuse/core": "^11.0.3", - "coalesce-vue": "5.0.0", - "coalesce-vue-vuetify3": "5.0.0", + "coalesce-vue": "5.0.1", + "coalesce-vue-vuetify3": "5.0.1", "typeface-roboto": "1.1.13", "vue": "^3.5.0", "vue-router": "^4.4.3", diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/App.vue b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/App.vue index 8d977b636..a8f279483 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/App.vue +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/App.vue @@ -4,7 +4,10 @@ - Coalesce Vue Template + Coalesce.Starter.Vue + + — {{ $userInfo.tenantName }} + @@ -25,6 +28,7 @@ diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/main.ts b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/main.ts index 7156cba45..7a0d4f324 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/main.ts +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/main.ts @@ -7,7 +7,10 @@ import { isAxiosError } from "axios"; import App from "./App.vue"; import router from "./router"; -import { globalProperties as userServiceProps } from "./user-service"; +import { + refreshUserInfo, + globalProperties as userServiceProps, +} from "./user-service"; // Import global CSS and Fonts: import "typeface-roboto"; @@ -68,6 +71,7 @@ CoalesceAxiosClient.interceptors.response.use(undefined, (error) => { /* Never resolving promise so failure doesn't momentarily propagate to UI before reload completes */ }); } + return Promise.reject(error); }); // SETUP: coalesce-vue-vuetify @@ -79,7 +83,6 @@ const coalesceVuetify = createCoalesceVuetify({ // SETUP: app insights //@ts-expect-error AppInsights imported from backend JavaScriptSnippet; no types available. window.appInsights?.addTelemetryInitializer(function (envelope) { - debugger; if ( envelope.baseType === "ExceptionData" && // Filter out unactionable, junk errors: @@ -102,4 +105,12 @@ app.use(router); app.use(vuetify); app.use(coalesceVuetify); -app.mount("#app"); +refreshUserInfo() + .then(() => { + app.mount("#app"); + }) + .catch((e) => { + alert( + "Unable to contact the application server. Please refresh the page to try again.", + ); + }); diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/metadata.g.ts b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/metadata.g.ts index 4e00a4d7b..c3d0316a1 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/metadata.g.ts +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/metadata.g.ts @@ -184,6 +184,14 @@ export const AuditLog = domain.types.AuditLog = { methods: { }, dataSources: { + tenantedDataSource: { + type: "dataSource", + name: "TenantedDataSource" as const, + displayName: "Tenanted Data Source", + isDefault: true, + props: { + }, + }, }, } export const AuditLogProperty = domain.types.AuditLogProperty = { @@ -298,11 +306,99 @@ export const Role = domain.types.Role = { dataSources: { }, } +export const Tenant = domain.types.Tenant = { + name: "Tenant" as const, + displayName: "Organization", + get displayProp() { return this.props.name }, + type: "model", + controllerRoute: "Tenant", + get keyProp() { return this.props.tenantId }, + behaviorFlags: 2 as BehaviorFlags, + props: { + tenantId: { + name: "tenantId", + displayName: "Tenant Id", + type: "string", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + name: { + name: "name", + displayName: "Name", + type: "string", + role: "value", + rules: { + required: val => (val != null && val !== '') || "Name is required.", + } + }, + externalId: { + name: "externalId", + displayName: "External Id", + description: "The external origin of this tenant. Other users who sign in with accounts from this external source will automatically join this organization.", + type: "string", + role: "value", + dontSerialize: true, + }, + }, + methods: { + create: { + name: "create", + displayName: "Create", + transportType: "item", + httpMethod: "POST", + isStatic: true, + params: { + name: { + name: "name", + displayName: "Org Name", + type: "string", + role: "value", + rules: { + required: val => (val != null && val !== '') || "Org Name is required.", + } + }, + adminEmail: { + name: "adminEmail", + displayName: "Admin Email", + type: "string", + subtype: "email", + role: "value", + rules: { + required: val => (val != null && val !== '') || "Admin Email is required.", + } + }, + }, + return: { + name: "$return", + displayName: "Result", + type: "void", + role: "value", + }, + }, + }, + dataSources: { + defaultSource: { + type: "dataSource", + name: "DefaultSource" as const, + displayName: "Default Source", + isDefault: true, + props: { + }, + }, + globalAdminSource: { + type: "dataSource", + name: "GlobalAdminSource" as const, + displayName: "Global Admin Source", + props: { + }, + }, + }, +} export const User = domain.types.User = { name: "User" as const, displayName: "User", description: "A user profile within the application.", - get displayProp() { return this.props.id }, + get displayProp() { return this.props.fullName }, type: "model", controllerRoute: "User", get keyProp() { return this.props.id }, @@ -314,15 +410,6 @@ export const User = domain.types.User = { type: "string", role: "value", }, - photoMD5: { - name: "photoMD5", - displayName: "Photo MD5", - type: "binary", - base64: true, - role: "value", - hidden: 3 as HiddenAreas, - dontSerialize: true, - }, userName: { name: "userName", displayName: "User Name", @@ -334,21 +421,23 @@ export const User = domain.types.User = { displayName: "Email", type: "string", role: "value", + dontSerialize: true, }, - lockoutEnd: { - name: "lockoutEnd", - displayName: "Lockout End", - description: "If set, the user will be blocked from signing in until this date.", - type: "date", - dateKind: "datetime", + emailConfirmed: { + name: "emailConfirmed", + displayName: "Email Confirmed", + type: "boolean", role: "value", + dontSerialize: true, }, - lockoutEnabled: { - name: "lockoutEnabled", - displayName: "Lockout Enabled", - description: "If enabled, the user can be locked out.", - type: "boolean", + photoHash: { + name: "photoHash", + displayName: "Photo Hash", + type: "binary", + base64: true, role: "value", + hidden: 3 as HiddenAreas, + dontSerialize: true, }, userRoles: { name: "userRoles", @@ -373,18 +462,30 @@ export const User = domain.types.User = { get nearForeignKey() { return (domain.types.UserRole as ModelType & { name: "UserRole" }).props.userId as ForeignKeyProperty }, get nearNavigationProp() { return (domain.types.UserRole as ModelType & { name: "UserRole" }).props.user as ModelReferenceNavigationProperty }, }, + hidden: 3 as HiddenAreas, dontSerialize: true, }, - effectivePermissions: { - name: "effectivePermissions", - displayName: "Effective Permissions", - description: "A summary of the effective permissions of the user, derived from their current roles.", - type: "string", - subtype: "multiline", + roleNames: { + name: "roleNames", + displayName: "Roles", + type: "collection", + itemType: { + name: "$collectionItem", + displayName: "", + role: "value", + type: "string", + }, role: "value", - hidden: 1 as HiddenAreas, dontSerialize: true, }, + isGlobalAdmin: { + name: "isGlobalAdmin", + displayName: "Is Global Admin", + description: "Global admins can perform some administrative actions against ALL tenants.", + type: "boolean", + role: "value", + hidden: 3 as HiddenAreas, + }, id: { name: "id", displayName: "Id", @@ -415,7 +516,7 @@ export const User = domain.types.User = { displayName: "Etag", type: "binary", role: "value", - get source() { return (domain.types.User as ModelType & { name: "User" }).props.photoMD5 }, + get source() { return (domain.types.User as ModelType & { name: "User" }).props.photoHash }, }, }, return: { @@ -425,8 +526,72 @@ export const User = domain.types.User = { role: "value", }, }, + evict: { + name: "evict", + displayName: "Evict", + transportType: "item", + httpMethod: "POST", + params: { + id: { + name: "id", + displayName: "Primary Key", + type: "string", + role: "value", + get source() { return (domain.types.User as ModelType & { name: "User" }).props.id }, + rules: { + required: val => (val != null && val !== '') || "Primary Key is required.", + } + }, + }, + return: { + name: "$return", + displayName: "Result", + type: "void", + role: "value", + }, + }, + inviteUser: { + name: "inviteUser", + displayName: "Invite User", + transportType: "item", + httpMethod: "POST", + isStatic: true, + params: { + email: { + name: "email", + displayName: "Email", + type: "string", + subtype: "email", + role: "value", + rules: { + required: val => (val != null && val !== '') || "Email is required.", + } + }, + role: { + name: "role", + displayName: "Role", + type: "model", + get typeDef() { return (domain.types.Role as ModelType & { name: "Role" }) }, + role: "value", + }, + }, + return: { + name: "$return", + displayName: "Result", + type: "void", + role: "value", + }, + }, }, dataSources: { + defaultSource: { + type: "dataSource", + name: "DefaultSource" as const, + displayName: "Default Source", + isDefault: true, + props: { + }, + }, }, } export const UserRole = domain.types.UserRole = { @@ -666,6 +831,18 @@ export const UserInfo = domain.types.UserInfo = { }, role: "value", }, + tenantId: { + name: "tenantId", + displayName: "Tenant Id", + type: "string", + role: "value", + }, + tenantName: { + name: "tenantName", + displayName: "Tenant Name", + type: "string", + role: "value", + }, }, } export const SecurityService = domain.services.SecurityService = { @@ -702,6 +879,7 @@ interface AppDomain extends Domain { AuditLog: typeof AuditLog AuditLogProperty: typeof AuditLogProperty Role: typeof Role + Tenant: typeof Tenant User: typeof User UserInfo: typeof UserInfo UserRole: typeof UserRole diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/models.g.ts b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/models.g.ts index 3494b6b4b..bba5220e5 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/models.g.ts +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/models.g.ts @@ -59,6 +59,14 @@ export class AuditLog { Object.assign(this, AuditLog.map(data || {})); } } +export namespace AuditLog { + export namespace DataSources { + + export class TenantedDataSource implements DataSource { + readonly $metadata = metadata.AuditLog.dataSources.tenantedDataSource + } + } +} export interface AuditLogProperty extends Model { @@ -117,21 +125,57 @@ export class Role { } +export interface Tenant extends Model { + tenantId: string | null + name: string | null + + /** The external origin of this tenant. Other users who sign in with accounts from this external source will automatically join this organization. */ + externalId: string | null +} +export class Tenant { + + /** Mutates the input object and its descendents into a valid Tenant implementation. */ + static convert(data?: Partial): Tenant { + return convertToModel(data || {}, metadata.Tenant) + } + + /** Maps the input object and its descendents to a new, valid Tenant implementation. */ + static map(data?: Partial): Tenant { + return mapToModel(data || {}, metadata.Tenant) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.Tenant; } + + /** Instantiate a new Tenant, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, Tenant.map(data || {})); + } +} +export namespace Tenant { + export namespace DataSources { + + export class DefaultSource implements DataSource { + readonly $metadata = metadata.Tenant.dataSources.defaultSource + } + + export class GlobalAdminSource implements DataSource { + readonly $metadata = metadata.Tenant.dataSources.globalAdminSource + } + } +} + + export interface User extends Model { fullName: string | null - photoMD5: string | null userName: string | null email: string | null - - /** If set, the user will be blocked from signing in until this date. */ - lockoutEnd: Date | null - - /** If enabled, the user can be locked out. */ - lockoutEnabled: boolean | null + emailConfirmed: boolean | null + photoHash: string | null userRoles: UserRole[] | null + roleNames: string[] | null - /** A summary of the effective permissions of the user, derived from their current roles. */ - effectivePermissions: string | null + /** Global admins can perform some administrative actions against ALL tenants. */ + isGlobalAdmin: boolean | null id: string | null } export class User { @@ -153,6 +197,14 @@ export class User { Object.assign(this, User.map(data || {})); } } +export namespace User { + export namespace DataSources { + + export class DefaultSource implements DataSource { + readonly $metadata = metadata.User.dataSources.defaultSource + } + } +} export interface UserRole extends Model { @@ -231,6 +283,8 @@ export interface UserInfo extends Model { fullName: string | null roles: string[] | null permissions: string[] | null + tenantId: string | null + tenantName: string | null } export class UserInfo { @@ -263,6 +317,7 @@ declare module "coalesce-vue/lib/model" { AuditLog: AuditLog AuditLogProperty: AuditLogProperty Role: Role + Tenant: Tenant User: User UserInfo: UserInfo UserRole: UserRole diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/router.ts b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/router.ts index 4d04197da..155ccc05f 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/router.ts +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/router.ts @@ -6,7 +6,7 @@ import { CAdminAuditLogPage, //#endif } from "coalesce-vue-vuetify3"; -//#if Identity +//#if (Identity && AuditLogs) import { Permission } from "./models.g"; //#endif @@ -15,7 +15,6 @@ const router = createRouter({ routes: [ { path: "/", - name: "home", component: () => import("./views/Home.vue"), }, //#if ExampleModel @@ -28,9 +27,16 @@ const router = createRouter({ //#endif { path: "/admin", - name: "admin", component: () => import("./views/Admin.vue"), }, + //#if Identity + { + path: "/user/:id", + alias: "/admin/User/edit/:id", // Override coalesce admin page + props: true, + component: () => import("./views/UserProfile.vue"), + }, + // #endif // Coalesce admin routes { @@ -49,7 +55,9 @@ const router = createRouter({ { path: "/admin/audit", component: titledAdminPage(CAdminAuditLogPage), + //#if Identity meta: { permissions: [Permission.ViewAuditLogs] }, + //#endif props: { type: "AuditLog" }, }, //#endif @@ -70,7 +78,7 @@ function titledAdminPage< | typeof CAdminEditorPage //#if AuditLogs | typeof CAdminAuditLogPage, - //#endif + //#endif >(component: T) { return defineComponent({ setup() { diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/site.scss b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/site.scss index bcfa2ef58..3044096c9 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/site.scss +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/site.scss @@ -23,3 +23,17 @@ a { // Without setting this, icon appearance can vary with CSS load order --fa-display: inline-flex; } + +// Visually distinguish readonly inputs +.v-input--readonly { + .v-switch__track, + .v-switch__thumb { + border: 1px dashed rgba(var(--v-theme-on-surface), 0.7); + } + .v-field__outline__start, + .v-field__outline__end, + .v-field__outline__notch::before, + .v-field__outline__notch::after { + border-style: dashed; + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/user-service.ts b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/user-service.ts index 0488cd9ef..5463a6312 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/user-service.ts +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/user-service.ts @@ -8,6 +8,7 @@ import { const securityService = new SecurityServiceViewModel(); securityService.whoAmI.setConcurrency("debounce"); + //#if AppInsights securityService.whoAmI.onFulfilled(() => { //@ts-expect-error AppInsights imported from backend JavaScriptSnippet; no types available. @@ -15,6 +16,22 @@ securityService.whoAmI.onFulfilled(() => { }); //#endif +//#if Tenancy +let initialTenantId: string | null = null; +securityService.whoAmI.onFulfilled(() => { + const tid = userInfo.value.tenantId; + if (initialTenantId && initialTenantId != tid) { + console.warn("Tenant has changed. Forcing page reload."); + window.location.reload(); + return new Promise(() => { + /* Never resolving promise so the new tenant info doesn't have a chance to mix in the UI with the old tenant info. */ + }); + } else { + initialTenantId = tid; + } +}); +//#endif + /** Properties about the currently authenticated user */ export const userInfo = computed(() => { return securityService.whoAmI.result ?? new UserInfo(); @@ -50,8 +67,6 @@ document.addEventListener( false, ); -refreshUserInfo(); - // Make useful properties available in vue