diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4876efef8..708909daf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: - "--Identity --UserPictures --TrackingBase" - "--Identity --MicrosoftAuth --AuditLogs" # Tenancy variants: - - "--Identity --Tenancy --TenantCreateExternal --MicrosoftAuth" + - "--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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9c36aa8..9072c1695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - feat: better "not found" messages from data sources when the ID is null or empty string. (#447) - fix(template): adjust manual chunking configuration to avoid circular deps. (#455) - fix(audit): key props now respect configured property exclusions +- fix: c-admin-method now preserves newlines when display success messages # 5.0.1 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 f5ede0551..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 @@ -49,7 +49,7 @@ public string? TenantId { if (_TenantId != null && value != _TenantId && ChangeTracker.Entries().Any()) { - throw new InvalidOperationException("Cannot change the TenantId of an active DbContext. Make a new one through DbContextFactory to perform operations on different tenants, or call ForceSetTenant()."); + 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; } @@ -119,7 +119,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) .ExcludeProperty(x => new { x.CreatedBy, x.CreatedById, x.CreatedOn, x.ModifiedBy, x.ModifiedById, x.ModifiedOn }) #endif #if Identity - .ExcludeProperty(x => new { x.PasswordHash }) + .Format(x => x.PasswordHash, x => "") #endif #if Tenancy .ExcludeProperty(x => new { x.TenantId }) 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 fc236a895..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 @@ -26,6 +26,7 @@ public static bool Can(this ClaimsPrincipal user, params Permission[] permission #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 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 495213f07..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 @@ -8,17 +8,17 @@ public void Seed() { #if Tenancy #if (!TenantCreateExternal && !TenantCreateSelf) - if (!db.Tenants.Any()) - { - var tenant = new Tenant { Name = "Demo Tenant" }; - db.Add(tenant); - db.SaveChanges(); + if (!db.Tenants.Any()) + { + var tenant = new Tenant { Name = "Demo Tenant" }; + db.Add(tenant); + db.SaveChanges(); - SeedNewTenant(tenant); - } + SeedNewTenant(tenant); + } #endif #elif Identity - SeedRoles(); + SeedRoles(); #endif } 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 9d0125e78..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 @@ -16,8 +18,8 @@ public class AuditLog : DefaultAuditLog #endif #if Tenancy - // NOTE: Audit logs are not strictly tenanted because they can log changes - // to non-tenanted entities as well. + // 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; } @@ -33,9 +35,9 @@ public override IQueryable GetQuery(IDataSourceParameters parameters) { return base.GetQuery(parameters) .AsNoTracking() - .Where(al => - // All users can see logs in the current tenant - al.TenantId == User.GetTenantId() || + .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) ); 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 a9f29229e..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 @@ -9,9 +9,9 @@ namespace Coalesce.Starter.Vue.Data.Models; [Description("A user profile within the application.")] public class User : IdentityUser { - [Search(SearchMethod = SearchMethods.Contains)] - [ListText] - public string? FullName { get; set; } + [Search(SearchMethod = SearchMethods.Contains)] + [ListText] + public string? FullName { get; set; } [Search] public override string? UserName { get; set; } @@ -24,167 +24,169 @@ public class User : IdentityUser #if UserPictures [Read, Hidden] - public byte[]? PhotoHash { get; set; } + public byte[]? PhotoHash { get; set; } - [InverseProperty(nameof(UserPhoto.User))] - public UserPhoto? Photo { get; set; } + [InverseProperty(nameof(UserPhoto.User))] + public UserPhoto? Photo { get; set; } #endif [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 bool PhoneNumberConfirmed { get; set; }[InternalUse] - public override string? NormalizedEmail { get; set; } + [InternalUse] + public override string? PhoneNumber { get; set; } + [InternalUse] + public override bool PhoneNumberConfirmed { get; set; } - [InternalUse] - public override bool TwoFactorEnabled { get; set; } + [InternalUse] + public override string? NormalizedEmail { get; set; } + + [InternalUse] + public override bool TwoFactorEnabled { get; set; } #if PasswordAuth - [Read(nameof(Permission.UserAdmin))] - [Description("The number of failed login attempts for the user. Reset after a successful password sign-in.")] + [Read(nameof(Permission.UserAdmin))] + [Description("The number of failed login attempts for the user. Reset after a successful password sign-in.")] #else - [InternalUse] + [InternalUse] #endif - public override int AccessFailedCount { get; set; } + public override int AccessFailedCount { get; set; } - [Description("If set, the user will be blocked from signing in until this date.")] + [Description("If set, the user will be blocked from signing in until this date.")] #if Tenancy - [InternalUse] + [InternalUse] #else - [Read(nameof(Permission.UserAdmin)), Edit(nameof(Permission.UserAdmin))] + [Read(nameof(Permission.UserAdmin)), Edit(nameof(Permission.UserAdmin))] #endif - public override DateTimeOffset? LockoutEnd { get; set; } + public override DateTimeOffset? LockoutEnd { get; set; } #if PasswordAuth - [Description("If enabled, the user will be locked out after multiple failed sign-in attempts.")] + [Description("If enabled, the user will be locked out after multiple failed sign-in attempts.")] #else - [Description("If enabled, the user can be locked out.")] + [Description("If enabled, the user can be locked out.")] #endif #if Tenancy - [InternalUse] + [InternalUse] #else - [Read(nameof(Permission.UserAdmin)), Edit(nameof(Permission.UserAdmin))] + [Read(nameof(Permission.UserAdmin)), Edit(nameof(Permission.UserAdmin))] #endif - public override bool LockoutEnabled { get; set; } + public override bool LockoutEnabled { get; set; } - [Read(nameof(Permission.UserAdmin), NoAutoInclude = true)] - [InverseProperty(nameof(UserRole.User))] - [ManyToMany("Roles")] - [Hidden] - public ICollection? UserRoles { get; set; } + [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))] - public IEnumerable? RoleNames => UserRoles?.Where(ur => ur.Role != null).Select(r => r.Role!.Name!); + [Display(Name = "Roles")] + [Read(nameof(Permission.UserAdmin))] + 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; } + [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(PhotoHash))] - public ItemResult GetPhoto(ClaimsPrincipal user, AppDbContext db) - { - return new IntelliTect.Coalesce.Models.File(db.UserPhotos - .Where(p => p.UserId == this.Id) + [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())) + .Where(p => db.TenantMemberships.Any(tm => tm.UserId == this.Id && tm.TenantId == user.GetTenantId())) #endif - .Select(p => p.Content)) - { - ContentType = "image/*" - }; - } + .Select(p => p.Content)) + { + ContentType = "image/*" + }; + } #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; - } + [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); - } + [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); - } + [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())); + return query.Where(u => Db.TenantMemberships.Any(tm => tm.UserId == u.Id && tm.TenantId == User.GetTenantId())); #else - return query; + return query; #endif - } - } - - public class UserBehaviors( - CrudContext context, - UserManager userManager, - SignInManager signInManager - ) : AppBehaviors(context) - { - public override ItemResult BeforeSave(SaveKind kind, User? oldItem, User item) - { + } + } + + public class UserBehaviors( + CrudContext context, + UserManager userManager, + SignInManager signInManager + ) : AppBehaviors(context) + { + public override ItemResult BeforeSave(SaveKind kind, User? oldItem, User item) + { #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."; + // 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."; + if (item.Id != User.GetUserId() && !User.Can(Permission.UserAdmin)) return "Forbidden."; #endif if (item.UserName != oldItem?.UserName) @@ -198,39 +200,39 @@ public override ItemResult BeforeSave(SaveKind kind, User? oldItem, User item) } if (oldItem != null) - { + { #if (!Tenancy) if (item.LockoutEnd != oldItem.LockoutEnd) - { - // Auto-enable lockout when setting a lockout date. - if (item.LockoutEnd != null) item.LockoutEnabled = true; - - // Invalidate existing sessions when manually locking a user's account. - item.SecurityStamp = Guid.NewGuid().ToString(); - } - - if (!item.LockoutEnabled) - { - // Make it clear to the administrator that lockout is only respected when LockoutEnabled. - item.LockoutEnd = null; - } + { + // Auto-enable lockout when setting a lockout date. + if (item.LockoutEnd != null) item.LockoutEnabled = true; + + // Invalidate existing sessions when manually locking a user's account. + item.SecurityStamp = Guid.NewGuid().ToString(); + } + + if (!item.LockoutEnabled) + { + // Make it clear to the administrator that lockout is only respected when LockoutEnabled. + item.LockoutEnd = null; + } #endif - } - - return base.BeforeSave(kind, oldItem, item); - } - - public override async Task> AfterSaveAsync(SaveKind kind, User? oldItem, User item) - { - if (User.GetUserId() == item.Id) - { - // If the user was editing their own profile, - // refresh their current sign-in so they aren't kicked out if - // the change required a refresh to the user's security stamp. - await signInManager.RefreshSignInAsync(item); - } - - return await base.AfterSaveAsync(kind, oldItem, item); - } - } + } + + return base.BeforeSave(kind, oldItem, item); + } + + public override async Task> AfterSaveAsync(SaveKind kind, User? oldItem, User item) + { + if (User.GetUserId() == item.Id) + { + // If the user was editing their own profile, + // refresh their current sign-in so they aren't kicked out if + // the change required a refresh to the user's security stamp. + await signInManager.RefreshSignInAsync(item); + } + + return await base.AfterSaveAsync(kind, oldItem, item); + } + } } 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 c1d940e9f..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 @@ -12,16 +12,16 @@ public class Widget { 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/Auth/AuthenticationConfiguration.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/AuthenticationConfiguration.cs index f19c6c3df..cbf6fed30 100644 --- 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 @@ -9,91 +9,91 @@ namespace Coalesce.Starter.Vue.Web.Auth; public static class AuthenticationConfiguration { - public static void ConfigureAuthentication(this WebApplicationBuilder builder) - { - var config = builder.Configuration; + 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(); + 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(); + builder.Services.AddScoped(); #endif - builder.Services - .AddAuthentication() + builder.Services + .AddAuthentication() #if GoogleAuth - .AddGoogle(options => - { - options.ClientId = config["Authentication:Google:ClientId"]!; - options.ClientSecret = config["Authentication:Google:ClientSecret"]!; + .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). + options.ClaimActions.MapJsonKey("hd", "hd"); // Hosted domain (i.e. GSuite domain). #endif #if UserPictures - options.ClaimActions.MapJsonKey("pictureUrl", "picture"); + options.ClaimActions.MapJsonKey("pictureUrl", "picture"); #endif - options.Events.OnTicketReceived = async ctx => - { - await ctx.HttpContext.RequestServices - .GetRequiredService() - .OnGoogleTicketReceived(ctx); - }; - }) + 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"]!; + .AddMicrosoftAccount(options => + { + options.ClientId = config["Authentication:Microsoft:ClientId"]!; + options.ClientSecret = config["Authentication:Microsoft:ClientSecret"]!; #if (UserPictures || TenantCreateExternal) - options.SaveTokens = true; + options.SaveTokens = true; #endif - options.Events.OnTicketReceived = async ctx => - { - await ctx.HttpContext.RequestServices - .GetRequiredService() - .OnMicrosoftTicketReceived(ctx); - }; - }) + 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.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" + 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(); + 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); - }; + 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 index 909ffe5c0..19ea78982 100644 --- 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 @@ -11,275 +11,272 @@ namespace Coalesce.Starter.Vue.Data.Auth; public class SignInService( - AppDbContext db, - SignInManager signInManager, - ILogger logger + 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; - } + 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); - } + // 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"))); + // 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. + // OPTIONAL: Populate additional fields on `user` specific to Google, if any. - await signInManager.UserManager.UpdateAsync(user); + await signInManager.UserManager.UpdateAsync(user); - await SignInExternalUser(ctx, remoteLoginInfo); - } + 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; - } + 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. - } + 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; - }); + // 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. + // OPTIONAL: Populate additional fields on `user` specific to Microsoft, if any. - await signInManager.UserManager.UpdateAsync(user); + await signInManager.UserManager.UpdateAsync(user); - await SignInExternalUser(ctx, remoteLoginInfo); - } - - private static async Task Forbid(TicketReceivedContext ctx, string message = "Forbidden") - { - await Results.Text(message, statusCode: StatusCodes.Status403Forbidden).ExecuteAsync(ctx.HttpContext); - ctx.HandleResponse(); - } + await SignInExternalUser(ctx, remoteLoginInfo); + } #endif -#if (GoogleAuth || MicrosoftAuth) - 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 }; + 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 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}"); - } + // 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(); - } + // 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); - } + await signInManager.UserManager.CreateAsync(user); + } - if (!foundByLogin) - { - await signInManager.UserManager.AddLoginAsync(user, remoteLoginInfo); - } + 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. + 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); - } + 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; - } + 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 */ } - } + 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(); - } - -#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/Pages/Invitation.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Invitation.cshtml.cs index 8fe1c2066..7fc5455fa 100644 --- 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 @@ -12,65 +12,65 @@ 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; } + [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 UserInvitation Invitation { get; private set; } - internal Tenant Tenant { get; private set; } + internal Tenant Tenant { get; private set; } - public void OnGet() - { - DecodeInvitation(); - } + public void OnGet() + { + DecodeInvitation(); + } - public async Task OnPost() - { - DecodeInvitation(); - if (!ModelState.IsValid) return Page(); + public async Task OnPost() + { + DecodeInvitation(); + if (!ModelState.IsValid) return Page(); - db.ForceSetTenant(Invitation.TenantId); + 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(); - } + 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); + // Sign the user into the newly joined tenant (uses `db.TenantId`). + await signInManager.RefreshSignInAsync(user); - return Redirect("/"); - } + return Redirect("/"); + } - private void DecodeInvitation() - { - if (string.IsNullOrWhiteSpace(Code)) return; + 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 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; - } - } + 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/Program.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Program.cs index 9ae106f0d..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 @@ -25,20 +25,20 @@ 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 @@ -50,8 +50,9 @@ 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. We want to include our own logging. builder.Logging.AddFilter("Coalesce.Starter.Vue", LogLevel.Information); @@ -59,27 +60,27 @@ 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(); @@ -88,8 +89,8 @@ #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 @@ -99,7 +100,8 @@ #if TenantMemberInvites // Register IUrlHelper to allow for invite link generation. services.AddSingleton(); -services.AddScoped(x => { +services.AddScoped(x => +{ var actionContext = x.GetRequiredService().ActionContext; var factory = x.GetRequiredService(); return factory.GetUrlHelper(actionContext!); @@ -117,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(); @@ -151,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 @@ -193,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/src/user-service.ts b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/user-service.ts index 17ffd3224..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,12 +8,14 @@ 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. window.appInsights?.setAuthenticatedUserContext(userInfo.value.userName); }); //#endif + //#if Tenancy let initialTenantId: string | null = null; securityService.whoAmI.onFulfilled(() => { @@ -22,7 +24,7 @@ securityService.whoAmI.onFulfilled(() => { 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 change to mix in the UI with the old tenant info. */ + /* 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;