diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 708909daf..1c5c1cb94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,8 +44,8 @@ jobs: - "--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 + - "--Identity --Tenancy --TenantCreateSelf --TenantMemberInvites --AuditLogs --LocalAuth --EmailSendGrid" + - "--Identity --Tenancy --TenantCreateAdmin --TenantMemberInvites --MicrosoftAuth --LocalAuth --EmailAzure" defaults: diff --git a/CHANGELOG.md b/CHANGELOG.md index c214087d5..2c5e22b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 5.2.0 + +- feat: Added a `reset` method to all API caller objects. This method resets all stateful fields on the object to default values. + # 5.1.0 - feat: All Coalesce-generated endpoints that accept a formdata body now also accept a JSON body. Existing formdata endpoints remain unchanged. `coalesce-vue` does not yet use these new endpoints. diff --git a/docs/stacks/vue/TemplateBuilder.vue b/docs/stacks/vue/TemplateBuilder.vue index 1d83c3b65..c3f94bf23 100644 --- a/docs/stacks/vue/TemplateBuilder.vue +++ b/docs/stacks/vue/TemplateBuilder.vue @@ -139,7 +139,7 @@ const selections = ref([ "DarkMode", "AuditLogs", "UserPictures", - "ExampleModel", + // "ExampleModel", ]); watch( diff --git a/src/coalesce-vue/src/api-client.ts b/src/coalesce-vue/src/api-client.ts index ad4b5e9fb..021b6af7d 100644 --- a/src/coalesce-vue/src/api-client.ts +++ b/src/coalesce-vue/src/api-client.ts @@ -1278,6 +1278,20 @@ export abstract class ApiState< return this.__rawResponse.value; } + /** Reset all state fields of the instance. + * Does not reset configuration like response caching and concurrency mode. + */ + public reset() { + if (this.isLoading) + throw new Error("Cannot reset while a request is pending."); + this.hasResult = false; + this.result = null; + this.wasSuccessful = null; + this.message = null; + this.isLoading = false; + this.__rawResponse.value = undefined; + } + private __responseCacheConfig?: ResponseCachingConfiguration; /** * Enable response caching for the API caller, @@ -1768,6 +1782,18 @@ export class ItemApiState extends ApiState< super(apiClient, invoker); } + public override reset(): void { + super.reset(); + this.result = null; + this.validationIssues = null; + if (this._objectUrl?.url) { + URL.revokeObjectURL(this._objectUrl.url); + this._objectUrl.url = undefined; + this._objectUrl.target = undefined; + this._objectUrl = undefined; + } + } + private _objectUrl?: { url?: string; target?: TResult; @@ -1857,6 +1883,13 @@ export class ItemApiStateWithArgs< this.__args.value = v; } + public override reset(resetArgs = true) { + super.reset(); + if (resetArgs) { + this.resetArgs(); + } + } + /** Invokes a call to this API endpoint. * If `args` is not provided, the values in `this.args` will be used for the method's parameters. */ public invokeWithArgs(args: TArgsObj = this.args): Promise { @@ -1991,6 +2024,15 @@ export class ListApiState extends ApiState< super(apiClient, invoker); } + override reset() { + super.reset(); + this.result = null; + this.totalCount = null; + this.pageCount = null; + this.pageSize = null; + this.page = null; + } + protected setResponseProps(data: ListResult) { this.wasSuccessful = data.wasSuccessful; this.message = data.message || null; @@ -2023,6 +2065,13 @@ export class ListApiStateWithArgs< this.__args.value = v; } + public override reset(resetArgs = true) { + super.reset(); + if (resetArgs) { + this.resetArgs(); + } + } + /** Invokes a call to this API endpoint. * If `args` is not provided, the values in `this.args` will be used for the method's parameters. */ public invokeWithArgs(args: TArgsObj = this.args): Promise { diff --git a/templates/Coalesce.Vue.Template/content/.template.config/template.json b/templates/Coalesce.Vue.Template/content/.template.config/template.json index e44c8ae06..d12fd9c89 100644 --- a/templates/Coalesce.Vue.Template/content/.template.config/template.json +++ b/templates/Coalesce.Vue.Template/content/.template.config/template.json @@ -52,6 +52,14 @@ "$coalesceRequires": ["and", "Identity"], "$coalesceLink": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/google-logins" }, + "LocalAuth": { + "type": "parameter", + "datatype": "bool", + "displayName": "Sign-in with Username/Password", + "description": "Adds infrastructure for supporting individual user accounts with first-party usernames and passwords.", + "$coalesceRequires": ["and", "Identity"], + "$coalesceLink": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/individual" + }, "UserPictures": { "type": "parameter", "datatype": "bool", @@ -136,6 +144,20 @@ "description": "Include configuration and integrations for Application Insights, both front-end and back-end.", "$coalesceLink": "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview" }, + "EmailAzure": { + "type": "parameter", + "datatype": "bool", + "displayName": "Email: Azure Communication Services", + "description": "Include basic code for sending email with Azure Communication Services. See instructions in appsettings.json - the official ACS documentation is very confusing.", + "$coalesceLink": "https://learn.microsoft.com/en-us/azure/communication-services/concepts/email/prepare-email-communication-resource" + }, + "EmailSendGrid": { + "type": "parameter", + "datatype": "bool", + "displayName": "Email: Twilio SendGrid", + "description": "Include basic code for sending email with Twilio SendGrid.", + "$coalesceLink": "https://www.twilio.com/docs/sendgrid/for-developers/sending-email/email-api-quickstart-for-c" + }, "AzurePipelines": { "type": "parameter", "datatype": "bool", @@ -170,7 +192,7 @@ { "condition": "!Identity", "exclude": [ - "**/AuthenticationConfiguration.cs", + "**/ProgramAuthConfiguration.cs", "**/Forbidden.vue", "**/UserAvatar.vue", "**/UserProfile.vue", @@ -186,7 +208,17 @@ }, { "condition": "!MicrosoftAuth && !GoogleAuth", - "exclude": ["**/SignInService.cs"] + "exclude": ["**/ExternalLogin.*"] + }, + { + "condition": "!LocalAuth", + "exclude": [ + "**/ResetPassword.*", + "**/Register.*", + "**/ForgotPassword.*", + "**/ConfirmEmail.*", + "**/UserManagementService.cs" + ] }, { "condition": "!Tenancy", @@ -235,6 +267,14 @@ "condition": "!MicrosoftAuth", "exclude": ["**/microsoft-logo.svg"] }, + { + "condition": "!EmailAzure", + "exclude": ["**/AzureEmailOptions.cs", "**/AzureEmailService.cs"] + }, + { + "condition": "!EmailSendGrid", + "exclude": ["**/SendGridEmailOptions.cs", "**/SendGridEmailService.cs"] + }, { "condition": "!ExampleModel", "exclude": [ 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 141b8d5bf..19a6bb104 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 @@ -120,6 +120,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) #endif #if Identity .Format(x => x.PasswordHash, x => "") + .Format(x => x.SecurityStamp, x => "") + .ExcludeProperty(x => new { x.ConcurrencyStamp }) #endif #if Tenancy .ExcludeProperty(x => new { x.TenantId }) 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 index 3f6db87e6..115a76938 100644 --- 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 @@ -1,5 +1,7 @@ -using Microsoft.AspNetCore.DataProtection; +using Coalesce.Starter.Vue.Data.Communication; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; +using System.Text.Encodings.Web; using System.Text.Json; namespace Coalesce.Starter.Vue.Data.Auth; @@ -7,7 +9,8 @@ namespace Coalesce.Starter.Vue.Data.Auth; public class InvitationService( AppDbContext db, IDataProtectionProvider dataProtector, - IUrlHelper urlHelper + IUrlHelper urlHelper, + IEmailService emailService ) { private IDataProtector GetProtector() => dataProtector.CreateProtector("invitations"); @@ -19,8 +22,9 @@ Role[] roles { var tenantId = db.TenantIdOrThrow; - if (roles.Any(r => r.TenantId != tenantId)) return "Role/tenant mismatch"; + if (roles.Any(r => db.Roles.FirstOrDefault(dbRole => dbRole.Id == r.Id) is null)) return "One or more roles are invalid"; + var tenant = db.Tenants.Find(tenantId)!; var invitation = new UserInvitation { Email = email, @@ -41,10 +45,11 @@ Role[] roles 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}"); + return await emailService.SendEmailAsync(email, $"Invitation to {tenant.Name}", + $""" + You have been invited to join the {HtmlEncoder.Default.Encode(tenant.Name)} organization. + Please click here to accept the invitation. + """); } public async Task AcceptInvitation( @@ -55,14 +60,24 @@ User acceptingUser var tenant = await db.Tenants.FindAsync(invitation.TenantId); if (tenant is null) return "Tenant not found"; + if (!invitation.Email.Equals(acceptingUser.Email, StringComparison.OrdinalIgnoreCase)) + { + return $"Your email address doesn't match the intended recipient of this invitation."; + } + // Note: `acceptingUser` will be untracked after ForceSetTenant. db.ForceSetTenant(invitation.TenantId); + acceptingUser = db.Users.Find(acceptingUser.Id)!; if (await db.TenantMemberships.AnyAsync(m => m.User == acceptingUser)) { return $"{acceptingUser.UserName ?? acceptingUser.Email} is already a member of {tenant.Name}."; } + // Since invitations are emailed to users, they also act as an email confirmation. + // If this is not true for your application, delete this line. + acceptingUser.EmailConfirmed = true; + db.TenantMemberships.Add(new() { UserId = acceptingUser.Id }); db.UserRoles.AddRange(invitation.Roles.Select(rid => new UserRole { RoleId = rid, UserId = acceptingUser.Id })); await db.SaveChangesAsync(); @@ -75,7 +90,7 @@ public string CreateInvitationLink(UserInvitation invitation) var inviteJson = JsonSerializer.Serialize(invitation); var inviteCode = GetProtector().Protect(inviteJson); - return urlHelper.PageLink("/invitation", values: new { code = inviteCode })!; + return urlHelper.PageLink("/Invitation", values: new { code = inviteCode })!; } public ItemResult DecodeInvitation(string code) diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserManagementService.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserManagementService.cs new file mode 100644 index 000000000..4d973a25d --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/UserManagementService.cs @@ -0,0 +1,102 @@ +using Coalesce.Starter.Vue.Data.Communication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using System.Diagnostics; +using System.Net.Mail; +using System.Text.Encodings.Web; + +namespace Coalesce.Starter.Vue.Data.Auth; + +public class UserManagementService( + UserManager _userManager, + IUrlHelper urlHelper, + IEmailService emailSender +) +{ + public async Task SendEmailConfirmationRequest(User user) + { + if (user.EmailConfirmed) return "Email is already confirmed."; + if (string.IsNullOrWhiteSpace(user.Email)) return "User has no email"; + + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + + var link = urlHelper.PageLink("/ConfirmEmail", values: new { userId = user.Id, code = code })!; + + var result = await emailSender.SendEmailAsync( + user.Email, + "Confirm your email", + $""" + Please click here to confirm your account. + If you didn't request this, ignore this email and do not click the link. + """ + ); + + if (result.WasSuccessful) + { + result.Message += " Please click the link in the email to confirm your account."; + } + + return result; + } + + public async Task SendEmailChangeRequest(User user, string newEmail) + { + // This is secured by virtue of the filtering done in the [DefaultDataSource]. + // Regular users can only fetch themselves out of the data source, + // admins can only view users in their own tenant, + // and tenant admins can view everyone. + + if (string.IsNullOrEmpty(newEmail) || !MailAddress.TryCreate(newEmail, out _)) + { + return "New email is not valid."; + } + + if (string.Equals(user.Email, newEmail, StringComparison.OrdinalIgnoreCase)) + { + return "New email is not different."; + } + + var code = await _userManager.GenerateChangeEmailTokenAsync(user, newEmail); + + var link = urlHelper.PageLink("/ConfirmEmail", values: new { userId = user.Id, code = code, newEmail = newEmail })!; + + var result = await emailSender.SendEmailAsync( + newEmail, + "Confirm your email", + $""" + Please click here to complete your email change request. + If you didn't request this, ignore this email and do not click the link. + """ + ); + + if (result.WasSuccessful) + { + result.Message += " Please click the link in the email to complete the change."; + } + + return result; + } + + public async Task SendPasswordResetRequest(User? user) + { + if (user?.Email is not null) + { + var code = await _userManager.GeneratePasswordResetTokenAsync(user); + + var link = urlHelper.PageLink("ResetPassword", values: new { userId = user.Id, code = code })!; + + await emailSender.SendEmailAsync( + user.Email, + "Password Reset", + $""" + Please click here to reset your password. + If you didn't request this, ignore this email and do not click the link. + """ + ); + } + + return new ItemResult(true, + "If the user account exists, the email address on the account " + + "will receive an email shortly with password reset instructions."); + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Coalesce.Starter.Vue.Data.csproj b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Coalesce.Starter.Vue.Data.csproj index f7acc1d1e..4483d9e98 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Coalesce.Starter.Vue.Data.csproj +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Coalesce.Starter.Vue.Data.csproj @@ -23,5 +23,11 @@ + + + + + + diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/AzureEmailOptions.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/AzureEmailOptions.cs new file mode 100644 index 000000000..7e3f5d154 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/AzureEmailOptions.cs @@ -0,0 +1,15 @@ +using Azure.Identity; + +namespace Coalesce.Starter.Vue.Data.Communication; + +public class AzureEmailOptions +{ + /// + /// The ACS resource endpoint, e.g. "https://my-acs-resource.unitedstates.communication.azure.com". + /// This code is configured to use managed RBAC authentication via + /// and so does not use a connection string or API keys. Assign the Contributor role to allow email sending. + /// + public string? Endpoint { get; set; } + + public string? SenderEmail { get; set; } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/AzureEmailService.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/AzureEmailService.cs new file mode 100644 index 000000000..7d0dfee60 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/AzureEmailService.cs @@ -0,0 +1,56 @@ + +using Azure.Communication.Email; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Coalesce.Starter.Vue.Data.Communication; + +public class AzureEmailService( + TokenCredential credential, + IHostEnvironment env, + IOptionsMonitor config, + ILogger logger +) : IEmailService +{ + public async Task SendEmailAsync(string to, string subject, string htmlMessage) + { + if (!env.IsProduction()) + { + logger.LogWarning("Suppressed email send of '{subject}' to {to}:\n\n{content}", + subject, to, htmlMessage); + return await new NoOpEmailService(env).SendEmailAsync(to, subject, htmlMessage); + } + + var endpoint = config.CurrentValue.Endpoint; + if (string.IsNullOrWhiteSpace(endpoint)) + { + return "Email service not configured."; + } + + // Note: Users and applications will need either the Contributor default azure role, + // or to create a custom role per https://github.com/MicrosoftDocs/azure-docs/issues/109461#issuecomment-1642442691 + + var emailClient = new EmailClient(new Uri(endpoint), credential); + + try + { + var result = await emailClient.SendAsync( + senderAddress: config.CurrentValue.SenderEmail, + recipientAddress: to, + subject: subject, + htmlContent: htmlMessage, + wait: Azure.WaitUntil.Completed + ); + + logger.LogInformation("Sent email '{subject}' to {to}", subject, to); + return new ItemResult(true, $"An email was sent to {to}."); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending email '{subject}' to {to}", subject, to); + return new ItemResult(false, $"Unable to send email."); + } + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/IEmailService.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/IEmailService.cs new file mode 100644 index 000000000..8e2dc6f05 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/IEmailService.cs @@ -0,0 +1,6 @@ +namespace Coalesce.Starter.Vue.Data.Communication; + +public interface IEmailService +{ + Task SendEmailAsync(string to, string subject, string htmlMessage); +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/NoOpEmailService.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/NoOpEmailService.cs new file mode 100644 index 000000000..5c0c7e649 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/NoOpEmailService.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Hosting; + +namespace Coalesce.Starter.Vue.Data.Communication; + +public class NoOpEmailService( + IHostEnvironment env +) : IEmailService +{ + public Task SendEmailAsync(string to, string subject, string htmlMessage) + { + if (env.IsProduction()) + { + throw new NotImplementedException("Email sending has not been implemented."); + } + + // When email sending has not been implemented, dump the email content into the result message + // so that essential functions during initial development (e.g. account setup links) + // can still be used. + + return Task.FromResult(new ItemResult(true, + $"DEVEOPMENT ONLY: Email sending is not configured, or is disabled by configuration. " + + $"The following content would have been sent to {to}:\n\n{htmlMessage}\n\n")); + } +} \ No newline at end of file diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/SendGridEmailOptions.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/SendGridEmailOptions.cs new file mode 100644 index 000000000..f8543f56a --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/SendGridEmailOptions.cs @@ -0,0 +1,8 @@ +namespace Coalesce.Starter.Vue.Data.Communication; + +public class SendGridEmailOptions +{ + public string? ApiKey { get; set; } + + public string? SenderEmail { get; set; } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/SendGridEmailService.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/SendGridEmailService.cs new file mode 100644 index 000000000..df94212e7 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Communication/SendGridEmailService.cs @@ -0,0 +1,63 @@ + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using SendGrid; +using SendGrid.Helpers.Mail; + +namespace Coalesce.Starter.Vue.Data.Communication; + +public class SendGridEmailService( + IHostEnvironment env, + IOptionsMonitor config, + ILogger logger +) : IEmailService +{ + public async Task SendEmailAsync(string to, string subject, string htmlMessage) + { + if (!env.IsProduction()) + { + logger.LogWarning("Suppressed email send of '{subject}' to {to}:\n\n{content}", + subject, to, htmlMessage); + return await new NoOpEmailService(env).SendEmailAsync(to, subject, htmlMessage); + } + + var apiKey = config.CurrentValue.ApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + { + return "Email service not configured."; + } + + var client = new SendGridClient(apiKey); + SendGridMessage message = new() + { + From = new EmailAddress(config.CurrentValue.SenderEmail), + Subject = subject, + HtmlContent = htmlMessage + }; + message.AddTo(new EmailAddress(to)); + + try + { + Response? response = await client.SendEmailAsync(message); + + if (!response.IsSuccessStatusCode) + { + logger.LogError("Error sending email '{subject}' to {to}: Status {status}, error {error}", + subject, + to, + response.StatusCode, + await response.Body.ReadAsStringAsync()); + + return new ItemResult(false, $"Unable to send email."); + } + + logger.LogInformation("Sent email '{subject}' to {to}", subject, to); + return new ItemResult(true, $"An email was sent to {to}."); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending email '{subject}' to {to}", subject, to); + return new ItemResult(false, $"Unable to send email."); + } + } +} 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 cd7d7bb48..e268bdf23 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,4 +1,6 @@ -namespace Coalesce.Starter.Vue.Data; +using Coalesce.Starter.Vue.Data.Models; + +namespace Coalesce.Starter.Vue.Data; public class DatabaseSeeder(AppDbContext db) { @@ -60,5 +62,32 @@ private void SeedRoles() db.SaveChanges(); } } + + /// + /// Grant administrative permissions to the very first user in the application. + /// + public void InitializeFirstUser(User user) + { + if (db.Users.Any()) return; + +#if Tenancy + // If this user is the first user, make them the global admin + 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(); + } +#endif +#else + // If this user is the first user, give them all roles so there is an initial admin. + user.UserRoles = db.Roles.Select(r => new UserRole { Role = r, User = user }).ToList(); +#endif + } #endif } 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 5aa5f60ff..bb71fce2b 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 @@ -152,6 +152,59 @@ public static async Task InviteUser( #endif #endif +#if (LocalAuth) + [Coalesce] + public async Task SetEmail( + [Inject] UserManagementService userService, + ClaimsPrincipal currentUser, + [DataType(DataType.EmailAddress)] string newEmail + ) + { + if (currentUser.GetUserId() != this.Id && !currentUser.Can(Permission.UserAdmin)) return "Unauthorized."; + return await userService.SendEmailChangeRequest(this, newEmail); + } + + [Coalesce] + public async Task SendEmailConfirmation( + [Inject] UserManagementService userService, + ClaimsPrincipal currentUser + ) + { + if (currentUser.GetUserId() != this.Id && !currentUser.Can(Permission.UserAdmin)) return "Unauthorized."; + return await userService.SendEmailConfirmationRequest(this); + } + + [Coalesce] + public async Task SetPassword( + [Inject] UserManager userManager, + [Inject] SignInManager signInManager, + ClaimsPrincipal currentUser, + [DataType(DataType.Password)] string? currentPassword, + [DataType(DataType.Password)] string newPassword, + [DataType(DataType.Password)] string confirmNewPassword + ) + { + if (currentUser.GetUserId() != this.Id) return "Unauthorized."; + + if (newPassword != confirmNewPassword) return "New passwords must match"; + + var result = this.PasswordHash is null + ? await userManager.AddPasswordAsync(this, newPassword) + : await userManager.ChangePasswordAsync(this, currentPassword ?? "", newPassword); + + if (!result.Succeeded) + { + return string.Join("; ", result.Errors.Select(e => e.Description)); + } + + if (currentUser.GetUserId() == this.Id) + { + await signInManager.RefreshSignInAsync(this); + } + return new ItemResult(true, $"Password was successfully changed."); + } +#endif + [DefaultDataSource] public class DefaultSource(CrudContext context) : AppDataSource(context) { 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 8d7a923e5..d2f06efe5 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 @@ -160,7 +160,7 @@ public virtual async Task InviteUser( var _params = new { email = email, - role = role + role = !Request.Form.HasAnyValue(nameof(role)) ? null : role }; if (Context.Options.ValidateAttributesForMethods) @@ -175,7 +175,118 @@ public virtual async Task InviteUser( Db, invitationService, _params.email, - _params.role.MapToNew(_mappingContext) + _params.role?.MapToNew(_mappingContext) + ); + var _result = new ItemResult(_methodResult); + return _result; + } + + /// + /// Method: SetEmail + /// + [HttpPost("SetEmail")] + [Authorize] + public virtual async Task SetEmail( + [FromServices] IDataSourceFactory dataSourceFactory, + [FromServices] Coalesce.Starter.Vue.Data.Auth.UserManagementService userService, + [FromForm(Name = "id")] string id, + [FromForm(Name = "newEmail")] string newEmail) + { + 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 _params = new + { + newEmail = newEmail + }; + + if (Context.Options.ValidateAttributesForMethods) + { + var _validationResult = ItemResult.FromParameterValidation( + GeneratedForClassViewModel!.MethodByName("SetEmail"), _params, HttpContext.RequestServices); + if (!_validationResult.WasSuccessful) return _validationResult; + } + + var _methodResult = await item.SetEmail( + userService, + User, + _params.newEmail + ); + var _result = new ItemResult(_methodResult); + return _result; + } + + /// + /// Method: SendEmailConfirmation + /// + [HttpPost("SendEmailConfirmation")] + [Authorize] + public virtual async Task SendEmailConfirmation( + [FromServices] IDataSourceFactory dataSourceFactory, + [FromServices] Coalesce.Starter.Vue.Data.Auth.UserManagementService userService, + [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 = await item.SendEmailConfirmation( + userService, + User + ); + var _result = new ItemResult(_methodResult); + return _result; + } + + /// + /// Method: SetPassword + /// + [HttpPost("SetPassword")] + [Authorize] + public virtual async Task SetPassword( + [FromServices] IDataSourceFactory dataSourceFactory, + [FromServices] Microsoft.AspNetCore.Identity.UserManager userManager, + [FromServices] Microsoft.AspNetCore.Identity.SignInManager signInManager, + [FromForm(Name = "id")] string id, + [FromForm(Name = "currentPassword")] string currentPassword, + [FromForm(Name = "newPassword")] string newPassword, + [FromForm(Name = "confirmNewPassword")] string confirmNewPassword) + { + 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 _params = new + { + currentPassword = currentPassword, + newPassword = newPassword, + confirmNewPassword = confirmNewPassword + }; + + if (Context.Options.ValidateAttributesForMethods) + { + var _validationResult = ItemResult.FromParameterValidation( + GeneratedForClassViewModel!.MethodByName("SetPassword"), _params, HttpContext.RequestServices); + if (!_validationResult.WasSuccessful) return _validationResult; + } + + var _methodResult = await item.SetPassword( + userManager, + signInManager, + User, + _params.currentPassword, + _params.newPassword, + _params.confirmNewPassword ); var _result = new ItemResult(_methodResult); return _result; 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 deleted file mode 100644 index 19ea78982..000000000 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Auth/SignInService.cs +++ /dev/null @@ -1,282 +0,0 @@ -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/Pages/ConfirmEmail.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ConfirmEmail.cshtml new file mode 100644 index 000000000..ffaeb4eb2 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ConfirmEmail.cshtml @@ -0,0 +1,18 @@ +@page +@model Coalesce.Starter.Vue.Web.Pages.ConfirmEmailModel +@{ + ViewData["Title"] = "Confirm email"; +} + +
+ +@if (ModelState.IsValid) +{ +

Thank you for confirming your email.

+ +
+ +
+} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ConfirmEmail.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ConfirmEmail.cshtml.cs new file mode 100644 index 000000000..1a8fe6c1c --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ConfirmEmail.cshtml.cs @@ -0,0 +1,52 @@ +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; + +namespace Coalesce.Starter.Vue.Web.Pages; + +[AllowAnonymous] +public class ConfirmEmailModel(UserManager userManager, SignInManager signInManager) : PageModel +{ + public const string InvalidError = "The link is no longer valid."; + + public async Task OnGetAsync(string userId, string code, string? newEmail) + { + if ( + string.IsNullOrWhiteSpace(userId) || + string.IsNullOrWhiteSpace(code) || + (await userManager.FindByIdAsync(userId)) is not { } user + ) + { + ModelState.AddModelError("", InvalidError); + return Page(); + } + + var result = string.IsNullOrWhiteSpace(newEmail) + ? await userManager.ConfirmEmailAsync(user, code) + : await userManager.ChangeEmailAsync(user, newEmail, code); + + if (!result.Succeeded) + { + ModelState.AddModelError("", InvalidError); + return Page(); + } + + if (User.GetUserId() == user.Id) + { + // The verifying user is already signed in. Refresh their session + // so they see the new email. + await signInManager.RefreshSignInAsync(user); + } + else + { + // A different user was signed in. Sign the user out so they don't get confused. + await signInManager.SignOutAsync(); + } + + return Page(); + } +} 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 index 9ae9cdf1a..fdde12f7a 100644 --- 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 @@ -1,4 +1,4 @@ -@page "/new-org" +@page @model Coalesce.Starter.Vue.Web.Pages.CreateTenantModel @{ 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 index e3a7ac6b5..b9f5b3dba 100644 --- 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 @@ -7,38 +7,37 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using System.ComponentModel.DataAnnotations; -namespace Coalesce.Starter.Vue.Web.Pages +namespace Coalesce.Starter.Vue.Web.Pages; + +[Authorize] +public class CreateTenantModel(AppDbContext db) : PageModel { - [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 + ) { - [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("/"); - } + 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/ExternalLogin.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ExternalLogin.cshtml new file mode 100644 index 000000000..bfa9ba63a --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ExternalLogin.cshtml @@ -0,0 +1,19 @@ +@page +@model ExternalLoginModel +@{ + ViewData["Title"] = "Sign In"; +} + + +@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage)) +{ +

+ @Model.ErrorMessage +

+ +
+ +
+} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ExternalLogin.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ExternalLogin.cshtml.cs new file mode 100644 index 000000000..706bbf631 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ExternalLogin.cshtml.cs @@ -0,0 +1,331 @@ +using Coalesce.Starter.Vue.Data; +using Coalesce.Starter.Vue.Data.Models; +using IntelliTect.Coalesce.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Authentication.MicrosoftAccount; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +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.Web.Pages; + +[AllowAnonymous] +public class ExternalLoginModel( + AppDbContext db, + SignInManager signInManager, + ILogger logger +) : PageModel +{ + [BindProperty(SupportsGet = true)] + public string? ReturnUrl { get; set; } + + public string? ErrorMessage { get; set; } + + public IActionResult OnGet() => RedirectToPage("Login"); + + public IActionResult OnPost(string provider) + { + // Request a redirect to the external login provider. + var redirectUrl = Url.Page("ExternalLogin", pageHandler: "Callback", values: new { ReturnUrl }); + var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); + return new ChallengeResult(provider, properties); + } + + public async Task OnGetCallbackAsync(string? remoteError = null) + { + if (remoteError != null) + { + ErrorMessage = $"Error from external provider: {remoteError}"; + return Page(); + } + + var info = await signInManager.GetExternalLoginInfoAsync(); + if (info == null) return RedirectToPage("SignIn"); + + switch (info.LoginProvider) + { +#if GoogleAuth + case GoogleDefaults.AuthenticationScheme: + return await OnGoogleTicketReceived(info); +#endif +#if MicrosoftAuth + case MicrosoftAccountDefaults.AuthenticationScheme: + return await OnMicrosoftTicketReceived(info); +#endif + default: + ErrorMessage = "Unknown external provider"; + return Page(); + } + } + +#if GoogleAuth + private async Task OnGoogleTicketReceived(ExternalLoginInfo info) + { + var result = await GetOrCreateUser(info); + if (!result.WasSuccessful || result.Object is not User user) + { + return Forbid(result.Message); + } + +#if TenantCreateExternal + // Note: domain will be null for personal gmail accounts. + string? gSuiteDomain = info.Principal!.FindFirstValue("hd"); + if (!string.IsNullOrWhiteSpace(gSuiteDomain)) + { + await GetAndAssignUserExternalTenant(user, info, gSuiteDomain); + } +#endif + +#if UserPictures + // Populate or update user photo from Google + await UpdateUserPhoto(user, + HttpContext.RequestServices.GetRequiredService>().Value.Backchannel, + () => new HttpRequestMessage(HttpMethod.Get, info.Principal!.FindFirstValue("pictureUrl"))); + +#endif + // OPTIONAL: Populate additional fields on `user` specific to Google, if any. + + await signInManager.UserManager.UpdateAsync(user); + + return await SignInExternalUser(info); + } +#endif + +#if MicrosoftAuth + private async Task OnMicrosoftTicketReceived(ExternalLoginInfo info) + { + var result = await GetOrCreateUser(info); + if (!result.WasSuccessful || result.Object is not User user) + { + return Forbid(result.Message); + } + +#if TenantCreateExternal + try + { + var accessJwt = new JwtSecurityTokenHandler() + .ReadJwtToken(info.AuthenticationProperties!.GetTokenValue(OpenIdConnectParameterNames.AccessToken)); + string? entraTenantId = accessJwt.Claims.FirstOrDefault(c => c.Type == "tid")?.Value; + + if (entraTenantId is not null) + { + await GetAndAssignUserExternalTenant(user, info, 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, + HttpContext.RequestServices.GetRequiredService>().Value.Backchannel, + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me/photos/96x96/$value"); + request.Headers.Authorization = new("Bearer", info.AuthenticationProperties!.GetTokenValue(OpenIdConnectParameterNames.AccessToken)); + return request; + }); + +#endif + // OPTIONAL: Populate additional fields on `user` specific to Microsoft, if any. + + await signInManager.UserManager.UpdateAsync(user); + + return await SignInExternalUser(info); + } +#endif + + private async Task> GetOrCreateUser(ExternalLoginInfo info) + { + var remoteProvider = info.LoginProvider; + var remoteUser = info.Principal!; + var remoteUserId = remoteUser.FindFirstValue(ClaimTypes.NameIdentifier)!; + var remoteUserEmail = remoteUser.FindFirstValue(ClaimTypes.Email); + + // 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); + if (user?.EmailConfirmed == false) + { + // Don't match existing users by email if the email isn't confirmed. +#if (!LocalAuth) + // Note: this error message assumes that the only way an unverified account can exist + // is if the application has local user accounts. Customize this message if needed. +#endif + // https://security.stackexchange.com/questions/260562/adding-sso-to-an-existing-website-should-sso-login-link-to-matching-email-addr + // It is crucial for security that you don't just mark the existing account as verified, + // as it may have a password attached that is controlled/known by a different person + // who is squatting on an email address in the system on hopes of hijacking the account. + // The person who owns the current verified external login needs to sign into that account with its password, + // including performing a "forgot password" request if the password isn't actually known. + return $"An existing unverified user account with email address {remoteUserEmail} already exists. " + + $"You must log into this account with its username and password and verify the account's email address " + + $"before you can link the account to your {info.ProviderDisplayName} login."; + } + } + + if (user is null) + { + if (await CanUserSignUpAsync(info) is { WasSuccessful: false } canSignIn) return new(canSignIn); + + user = new User { UserName = remoteUserEmail, Email = remoteUserEmail, EmailConfirmed = true }; + + new DatabaseSeeder(db).InitializeFirstUser(user); + + var result = await signInManager.UserManager.CreateAsync(user); + if (!result.Succeeded) return string.Join(", ", result.Errors); + } + + if (!foundByLogin) + { + var result = await signInManager.UserManager.AddLoginAsync(user, info); + if (!result.Succeeded) return string.Join(", ", result.Errors); + } + + 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; + } + + +#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(ExternalLoginInfo remoteLoginInfo) + { + // OPTIONAL: Examine the properties of `remoteLoginInfo` and determine if the user is permitted to sign up. + return Task.FromResult(new ItemResult(true)); + } + + private async Task SignInExternalUser(ExternalLoginInfo 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) + { + return Forbid(result.IsLockedOut ? "Account locked." : "Unable to sign in."); + } + + // OPTIONAL: If desired, save the user's OAuth tokens for later user: + // (you'll need to request offline_access OAuth scope for this to be of any real use). + // await signInManager.UpdateExternalAuthenticationTokensAsync(remoteLoginInfo); + + return LocalRedirect(ReturnUrl ?? "/"); + } + + private IActionResult Forbid(string? message = null) + { + message ??= "Forbidden"; + + ErrorMessage = message; + + return new PageResult { StatusCode = StatusCodes.Status403Forbidden }; + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ForgotPassword.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ForgotPassword.cshtml new file mode 100644 index 000000000..c812f249d --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ForgotPassword.cshtml @@ -0,0 +1,31 @@ +@page +@model Coalesce.Starter.Vue.Web.Pages.ForgotPasswordModel +@{ + ViewData["Title"] = "Forgot password"; +} + +@if (Model.Success) +{ +

+ If an account matching your input was found, a message with password reset instructions will be sent to the account's email address. +

+ +
+ +
+} +else +{ +
+
+ + +
+ +
+ + +
+} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ForgotPassword.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ForgotPassword.cshtml.cs new file mode 100644 index 000000000..7bd0de71f --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ForgotPassword.cshtml.cs @@ -0,0 +1,46 @@ +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; + +#nullable disable + +[AllowAnonymous] +public class ForgotPasswordModel( + UserManager userManager, + UserManagementService userManagementService +) : PageModel +{ + public bool Success { get; set; } + + [BindProperty] + [Required] + [EmailAddress] + public string Username { get; set; } + +#nullable restore + + public IActionResult OnGet() + { + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + User? user = await userManager.FindByNameAsync(Username); + await userManagementService.SendPasswordResetRequest(user); + + Success = true; + return Page(); + } +} 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 index c9d283d73..d44e2a6f3 100644 --- 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 @@ -1,4 +1,4 @@ -@page "/invitation" +@page @model Coalesce.Starter.Vue.Web.Pages.InvitationModel @{ ViewData["Title"] = "Join Organization"; @@ -8,7 +8,7 @@ @if (ModelState.IsValid) { -

+

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

@@ -19,14 +19,14 @@ -
+
-
+ 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 ead44f301..9ec0e4fed 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 @@ -10,67 +10,66 @@ #nullable disable -namespace Coalesce.Starter.Vue.Web.Pages +namespace Coalesce.Starter.Vue.Web.Pages; + +[Authorize] +public class InvitationModel( + InvitationService invitationService, + SignInManager signInManager, + AppDbContext db +) : PageModel { - [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() { - [BindProperty(SupportsGet = true), Required] - public string Code { get; set; } + DecodeInvitation(); + } - internal UserInvitation Invitation { get; private set; } + public async Task OnPost() + { + DecodeInvitation(); + if (!ModelState.IsValid) return Page(); - internal Tenant Tenant { get; private set; } + db.ForceSetTenant(Invitation.TenantId); - public void OnGet() + var user = await db.Users.FindAsync(User.GetUserId()); + var result = await invitationService.AcceptInvitation(Invitation, user!); + if (!result.WasSuccessful) { - DecodeInvitation(); + ModelState.AddModelError(nameof(Code), result.Message); + return Page(); } - public async Task OnPost() - { - DecodeInvitation(); - if (!ModelState.IsValid) return Page(); + // Sign the user into the newly joined tenant (uses `db.TenantId`). + await signInManager.RefreshSignInAsync(user); - 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(); - } + return Redirect("/"); + } - // Sign the user into the newly joined tenant (uses `db.TenantId`). - await signInManager.RefreshSignInAsync(user); + private void DecodeInvitation() + { + if (string.IsNullOrWhiteSpace(Code)) return; - return Redirect("/"); + var decodeResult = invitationService.DecodeInvitation(Code); + if (!decodeResult.WasSuccessful) + { + ModelState.AddModelError(nameof(Code), decodeResult.Message); + return; } + Invitation = decodeResult.Object; - private void DecodeInvitation() + var tenant = db.Tenants.Find(Invitation.TenantId); + if (tenant is null) { - 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; + 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/Register.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Register.cshtml new file mode 100644 index 000000000..02101d15c --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Register.cshtml @@ -0,0 +1,60 @@ +@page +@using Microsoft.AspNetCore.Identity +@using Microsoft.Extensions.Options + +@model Coalesce.Starter.Vue.Web.Pages.RegisterModel +@inject IOptions identityOptions + +@{ + ViewData["Title"] = "Register"; + int passwordMinlength = identityOptions.Value.Password.RequiredLength; +} + +@if (Model.SuccessMessage is not null) +{ +

@Model.SuccessMessage

+ + + + + return; +} +@*#if TenantMemberInvites *@ +@if (Model.ReturnUrl?.StartsWith("/invitation", StringComparison.OrdinalIgnoreCase) == true) +{ + +} +@*#endif *@ +
+
+ + +
+
+ + +
Passwords must be at least @passwordMinlength characters.
+
+
+ + +
+ +
+ + +
+ + diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Register.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Register.cshtml.cs new file mode 100644 index 000000000..e509a81d0 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/Register.cshtml.cs @@ -0,0 +1,79 @@ +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; + +[AllowAnonymous] +public class RegisterModel( + AppDbContext db, + UserManager userManager, + SignInManager signInManager, + UserManagementService userManagementService +) : PageModel +{ + [BindProperty(SupportsGet = true)] + public string? ReturnUrl { get; set; } + + [BindProperty] + [Required] + [EmailAddress] + [Display(Name = "Email")] + public string Email { get; set; } = null!; + + [BindProperty] + [Required] + [DataType(DataType.Password)] + [Display(Name = "Password")] + public string Password { get; set; } = null!; + + [BindProperty] + [Required] + [DataType(DataType.Password)] + [Display(Name = "Confirm password")] + [Compare(nameof(Password), ErrorMessage = "The password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } = null!; + + public string? SuccessMessage { get; set; } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) return Page(); + + var user = new User() + { + UserName = Email, + Email = Email + }; + + new DatabaseSeeder(db).InitializeFirstUser(user); + + var result = await userManager.CreateAsync(user, Password); + + if (!result.Succeeded) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); + } + + var emailResult = await userManagementService.SendEmailConfirmationRequest(user); + if (userManager.Options.SignIn.RequireConfirmedAccount) + { + SuccessMessage = emailResult.Message; + return Page(); + } + else + { + await signInManager.SignInAsync(user, isPersistent: false); + return LocalRedirect(ReturnUrl ?? "/"); + } + } +} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ResetPassword.cshtml b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ResetPassword.cshtml new file mode 100644 index 000000000..52c060db7 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ResetPassword.cshtml @@ -0,0 +1,38 @@ +@page +@using Microsoft.AspNetCore.Identity +@using Microsoft.Extensions.Options +@inject IOptions identityOptions +@model Coalesce.Starter.Vue.Web.Pages.ResetPasswordModel +@{ + ViewData["Title"] = "Reset password"; + int passwordMinlength = identityOptions.Value.Password.RequiredLength; +} + +@if (Model.Success) +{ +

Your password has been changed.

+ +
+ +
+} +else +{ +
+
+ + +
Passwords must be at least @passwordMinlength characters.
+
+
+ + +
+ +
+ + +
+} diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ResetPassword.cshtml.cs b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ResetPassword.cshtml.cs new file mode 100644 index 000000000..015c9dc02 --- /dev/null +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/Pages/ResetPassword.cshtml.cs @@ -0,0 +1,76 @@ +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; + +#nullable disable + +[AllowAnonymous] +public class ResetPasswordModel(UserManager userManager, SignInManager signInManager) : PageModel +{ + public const string InvalidError = "The link is no longer valid."; + + public bool Success { get; set; } + + [BindProperty(SupportsGet = true)] + [Required] + public string Code { get; set; } + + [BindProperty(SupportsGet = true)] + [Required] + public string UserId { get; set; } + + [BindProperty] + [Required] + [DataType(DataType.Password)] + [Display(Name = "New Password")] + public string Password { get; set; } + + [BindProperty] + [Required] + [DataType(DataType.Password)] + [Display(Name = "Confirm password")] + [Compare(nameof(Password), ErrorMessage = "The password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + + public IActionResult OnGet() + { + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) return Page(); + + var user = await userManager.FindByIdAsync(UserId); + if (user == null) + { + ModelState.AddModelError("", InvalidError); + return Page(); + } + + var result = await userManager.ResetPasswordAsync(user, Code, Password); + if (result.Succeeded) + { + Success = true; + await signInManager.SignOutAsync(); + return Page(); + } + + foreach (var error in result.Errors) + { + string description = error.Description; + if (error.Code == new IdentityErrorDescriber().InvalidToken().Code) + { + // Default error for an expired/invalid link is not user-friendly. + description = InvalidError; + } + ModelState.AddModelError(string.Empty, description); + } + return Page(); + } +} 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 index 03b20b3c7..d2f9201a1 100644 --- 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 @@ -1,4 +1,4 @@ -@page "/select-org" +@page @model Coalesce.Starter.Vue.Web.Pages.SelectTenantModel @{ @@ -24,6 +24,10 @@ {

@User.GetUserName() is a not a member of any organization. +@*#if (TenantMemberInvites)*@ +
+ If you received an invitation, please open the invitation link. +@*#endif*@

} @@ -31,7 +35,7 @@
@*#if (TenantCreateSelf)*@ -
+ 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 index 3d5dc43a3..869fa89f3 100644 --- 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 @@ -7,52 +7,50 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; -namespace Coalesce.Starter.Vue.Web.Pages +namespace Coalesce.Starter.Vue.Web.Pages; + +[Authorize] +public class SelectTenantModel(AppDbContext db) : PageModel { - [Authorize] - public class SelectTenantModel(AppDbContext db) : PageModel - { - [BindProperty(SupportsGet = true)] - public string? ReturnUrl { get; set; } + [BindProperty(SupportsGet = true)] + public string? ReturnUrl { get; set; } - public List Tenants { get; private set; } = []; + public List Tenants { get; private set; } = []; - public async Task OnGet() - { - await LoadTenants(); - } + public async Task OnGet() + { + await LoadTenants(); + } - public async Task OnPost( - [FromForm] string tenantId, - [FromServices] SignInManager signInManager - ) + public async Task OnPost( + [FromForm] string tenantId, + [FromServices] SignInManager signInManager + ) + { + await LoadTenants(); + if (!Tenants.Any(t => t.TenantId == tenantId)) { - await LoadTenants(); - if (!Tenants.Any(t => t.TenantId == tenantId)) - { - ModelState.AddModelError("tenantId", "Invalid Tenant"); - } - if (!ModelState.IsValid) return Page(); + ModelState.AddModelError("tenantId", "Invalid Tenant"); + } + if (!ModelState.IsValid) return Page(); - db.ForceSetTenant(tenantId); + db.ForceSetTenant(tenantId); - var user = await db.Users.FindAsync(User.GetUserId()); - await signInManager.RefreshSignInAsync(user!); + var user = await db.Users.FindAsync(User.GetUserId()); + await signInManager.RefreshSignInAsync(user!); - return LocalRedirect(string.IsNullOrWhiteSpace(ReturnUrl) ? "/" : ReturnUrl); - } + return LocalRedirect(string.IsNullOrWhiteSpace(ReturnUrl) ? "/" : ReturnUrl); + } - private async Task LoadTenants() - { - var userId = User.GetUserId(); - Tenants = await db.TenantMemberships - .IgnoreTenancy() - .Where(tm => tm.UserId == userId) - .Select(tm => tm.Tenant!) - .OrderBy(t => t.Name) - .ToListAsync(); - } + private async Task LoadTenants() + { + var userId = User.GetUserId(); + Tenants = await db.TenantMemberships + .IgnoreTenancy() + .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 6b257d6dc..7ba590967 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 @@ -1,4 +1,4 @@ -@page "/sign-in" +@page @using Microsoft.AspNetCore.Authentication @model Coalesce.Starter.Vue.Web.Pages.SignInModel @inject IAuthenticationSchemeProvider schemeProvider @@ -10,25 +10,54 @@ } @*#if TenantMemberInvites *@ -@if (Model.ReturnUrl?.StartsWith("/invitation") == true) +@if (Model.ReturnUrl?.StartsWith("/invitation", StringComparison.OrdinalIgnoreCase) == true) { -