Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

template: add local auth and email sending #484

Merged
merged 5 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/stacks/vue/TemplateBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ const selections = ref([
"DarkMode",
"AuditLogs",
"UserPictures",
"ExampleModel",
// "ExampleModel",
]);

watch(
Expand Down
49 changes: 49 additions & 0 deletions src/coalesce-vue/src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1768,6 +1782,18 @@ export class ItemApiState<TArgs extends any[], TResult> 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;
Expand Down Expand Up @@ -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<TResult> {
Expand Down Expand Up @@ -1991,6 +2024,15 @@ export class ListApiState<TArgs extends any[], TResult> 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<TResult>) {
this.wasSuccessful = data.wasSuccessful;
this.message = data.message || null;
Expand Down Expand Up @@ -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<TResult> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -170,7 +192,7 @@
{
"condition": "!Identity",
"exclude": [
"**/AuthenticationConfiguration.cs",
"**/ProgramAuthConfiguration.cs",
"**/Forbidden.vue",
"**/UserAvatar.vue",
"**/UserProfile.vue",
Expand All @@ -186,7 +208,17 @@
},
{
"condition": "!MicrosoftAuth && !GoogleAuth",
"exclude": ["**/SignInService.cs"]
"exclude": ["**/ExternalLogin.*"]
},
{
"condition": "!LocalAuth",
"exclude": [
"**/ResetPassword.*",
"**/Register.*",
"**/ForgotPassword.*",
"**/ConfirmEmail.*",
"**/UserManagementService.cs"
]
},
{
"condition": "!Tenancy",
Expand Down Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#endif
#if Identity
.Format<User>(x => x.PasswordHash, x => "<password changed>")
.Format<User>(x => x.SecurityStamp, x => "<stamp changed>")
.ExcludeProperty<User>(x => new { x.ConcurrencyStamp })
#endif
#if Tenancy
.ExcludeProperty<ITenanted>(x => new { x.TenantId })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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;

public class InvitationService(
AppDbContext db,
IDataProtectionProvider dataProtector,
IUrlHelper urlHelper
IUrlHelper urlHelper,
IEmailService emailService
)
{
private IDataProtector GetProtector() => dataProtector.CreateProtector("invitations");
Expand All @@ -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,
Expand All @@ -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 <b>{HtmlEncoder.Default.Encode(tenant.Name)}</b> organization.
Please <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> to accept the invitation.
""");
}

public async Task<ItemResult> AcceptInvitation(
Expand All @@ -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();
Expand All @@ -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<UserInvitation> DecodeInvitation(string code)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<User> _userManager,
IUrlHelper urlHelper,
IEmailService emailSender
)
{
public async Task<ItemResult> 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 <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> 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<ItemResult> 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 <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> 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<ItemResult> 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 <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> 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.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,11 @@
<!--#if (AuditLogs) -->
<PackageReference Include="IntelliTect.Coalesce.AuditLogging" Version="$(CoalesceVersion)" />
<!--#endif -->
<!--#if (EmailAzure) -->
<PackageReference Include="Azure.Communication.Email" Version="1.0.1" />
<!--#endif -->
<!--#if (EmailSendGrid) -->
<PackageReference Include="SendGrid" Version="9.29.3" />
<!--#endif -->
</ItemGroup>
</Project>
Loading