Skip to content

Commit a99f872

Browse files
authored
template: add local auth and email sending (#484)
- Add Individual user accounts (local auth) as a template option - Add Azure ACS as an email option - Add SendGrid as an email option - Add `reset` method to API callers - Switch external logins in template to use ExternalLogin razor page instead of OnTicketReceived hooks
1 parent 74431fd commit a99f872

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1973
-530
lines changed

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ jobs:
4444
- "--Identity --MicrosoftAuth --AuditLogs"
4545
# Tenancy variants:
4646
- "--Identity --Tenancy --TenantCreateExternal --GoogleAuth"
47-
- "--Identity --Tenancy --TenantCreateSelf --TenantMemberInvites --AuditLogs" # todo: add local accounts to this case when we add it
48-
- "--Identity --Tenancy --TenantCreateAdmin --TenantMemberInvites --MicrosoftAuth" # todo: add local accounts to this case when we add it
47+
- "--Identity --Tenancy --TenantCreateSelf --TenantMemberInvites --AuditLogs --LocalAuth --EmailSendGrid"
48+
- "--Identity --Tenancy --TenantCreateAdmin --TenantMemberInvites --MicrosoftAuth --LocalAuth --EmailAzure"
4949

5050

5151
defaults:

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 5.2.0
2+
3+
- feat: Added a `reset` method to all API caller objects. This method resets all stateful fields on the object to default values.
4+
15
# 5.1.0
26

37
- 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.

docs/stacks/vue/TemplateBuilder.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ const selections = ref([
139139
"DarkMode",
140140
"AuditLogs",
141141
"UserPictures",
142-
"ExampleModel",
142+
// "ExampleModel",
143143
]);
144144
145145
watch(

src/coalesce-vue/src/api-client.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,20 @@ export abstract class ApiState<
12781278
return this.__rawResponse.value;
12791279
}
12801280

1281+
/** Reset all state fields of the instance.
1282+
* Does not reset configuration like response caching and concurrency mode.
1283+
*/
1284+
public reset() {
1285+
if (this.isLoading)
1286+
throw new Error("Cannot reset while a request is pending.");
1287+
this.hasResult = false;
1288+
this.result = null;
1289+
this.wasSuccessful = null;
1290+
this.message = null;
1291+
this.isLoading = false;
1292+
this.__rawResponse.value = undefined;
1293+
}
1294+
12811295
private __responseCacheConfig?: ResponseCachingConfiguration;
12821296
/**
12831297
* Enable response caching for the API caller,
@@ -1768,6 +1782,18 @@ export class ItemApiState<TArgs extends any[], TResult> extends ApiState<
17681782
super(apiClient, invoker);
17691783
}
17701784

1785+
public override reset(): void {
1786+
super.reset();
1787+
this.result = null;
1788+
this.validationIssues = null;
1789+
if (this._objectUrl?.url) {
1790+
URL.revokeObjectURL(this._objectUrl.url);
1791+
this._objectUrl.url = undefined;
1792+
this._objectUrl.target = undefined;
1793+
this._objectUrl = undefined;
1794+
}
1795+
}
1796+
17711797
private _objectUrl?: {
17721798
url?: string;
17731799
target?: TResult;
@@ -1857,6 +1883,13 @@ export class ItemApiStateWithArgs<
18571883
this.__args.value = v;
18581884
}
18591885

1886+
public override reset(resetArgs = true) {
1887+
super.reset();
1888+
if (resetArgs) {
1889+
this.resetArgs();
1890+
}
1891+
}
1892+
18601893
/** Invokes a call to this API endpoint.
18611894
* If `args` is not provided, the values in `this.args` will be used for the method's parameters. */
18621895
public invokeWithArgs(args: TArgsObj = this.args): Promise<TResult> {
@@ -1991,6 +2024,15 @@ export class ListApiState<TArgs extends any[], TResult> extends ApiState<
19912024
super(apiClient, invoker);
19922025
}
19932026

2027+
override reset() {
2028+
super.reset();
2029+
this.result = null;
2030+
this.totalCount = null;
2031+
this.pageCount = null;
2032+
this.pageSize = null;
2033+
this.page = null;
2034+
}
2035+
19942036
protected setResponseProps(data: ListResult<TResult>) {
19952037
this.wasSuccessful = data.wasSuccessful;
19962038
this.message = data.message || null;
@@ -2023,6 +2065,13 @@ export class ListApiStateWithArgs<
20232065
this.__args.value = v;
20242066
}
20252067

2068+
public override reset(resetArgs = true) {
2069+
super.reset();
2070+
if (resetArgs) {
2071+
this.resetArgs();
2072+
}
2073+
}
2074+
20262075
/** Invokes a call to this API endpoint.
20272076
* If `args` is not provided, the values in `this.args` will be used for the method's parameters. */
20282077
public invokeWithArgs(args: TArgsObj = this.args): Promise<TResult> {

templates/Coalesce.Vue.Template/content/.template.config/template.json

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@
5252
"$coalesceRequires": ["and", "Identity"],
5353
"$coalesceLink": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/google-logins"
5454
},
55+
"LocalAuth": {
56+
"type": "parameter",
57+
"datatype": "bool",
58+
"displayName": "Sign-in with Username/Password",
59+
"description": "Adds infrastructure for supporting individual user accounts with first-party usernames and passwords.",
60+
"$coalesceRequires": ["and", "Identity"],
61+
"$coalesceLink": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/individual"
62+
},
5563
"UserPictures": {
5664
"type": "parameter",
5765
"datatype": "bool",
@@ -136,6 +144,20 @@
136144
"description": "Include configuration and integrations for Application Insights, both front-end and back-end.",
137145
"$coalesceLink": "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview"
138146
},
147+
"EmailAzure": {
148+
"type": "parameter",
149+
"datatype": "bool",
150+
"displayName": "Email: Azure Communication Services",
151+
"description": "Include basic code for sending email with Azure Communication Services. See instructions in appsettings.json - the official ACS documentation is very confusing.",
152+
"$coalesceLink": "https://learn.microsoft.com/en-us/azure/communication-services/concepts/email/prepare-email-communication-resource"
153+
},
154+
"EmailSendGrid": {
155+
"type": "parameter",
156+
"datatype": "bool",
157+
"displayName": "Email: Twilio SendGrid",
158+
"description": "Include basic code for sending email with Twilio SendGrid.",
159+
"$coalesceLink": "https://www.twilio.com/docs/sendgrid/for-developers/sending-email/email-api-quickstart-for-c"
160+
},
139161
"AzurePipelines": {
140162
"type": "parameter",
141163
"datatype": "bool",
@@ -170,7 +192,7 @@
170192
{
171193
"condition": "!Identity",
172194
"exclude": [
173-
"**/AuthenticationConfiguration.cs",
195+
"**/ProgramAuthConfiguration.cs",
174196
"**/Forbidden.vue",
175197
"**/UserAvatar.vue",
176198
"**/UserProfile.vue",
@@ -186,7 +208,17 @@
186208
},
187209
{
188210
"condition": "!MicrosoftAuth && !GoogleAuth",
189-
"exclude": ["**/SignInService.cs"]
211+
"exclude": ["**/ExternalLogin.*"]
212+
},
213+
{
214+
"condition": "!LocalAuth",
215+
"exclude": [
216+
"**/ResetPassword.*",
217+
"**/Register.*",
218+
"**/ForgotPassword.*",
219+
"**/ConfirmEmail.*",
220+
"**/UserManagementService.cs"
221+
]
190222
},
191223
{
192224
"condition": "!Tenancy",
@@ -235,6 +267,14 @@
235267
"condition": "!MicrosoftAuth",
236268
"exclude": ["**/microsoft-logo.svg"]
237269
},
270+
{
271+
"condition": "!EmailAzure",
272+
"exclude": ["**/AzureEmailOptions.cs", "**/AzureEmailService.cs"]
273+
},
274+
{
275+
"condition": "!EmailSendGrid",
276+
"exclude": ["**/SendGridEmailOptions.cs", "**/SendGridEmailService.cs"]
277+
},
238278
{
239279
"condition": "!ExampleModel",
240280
"exclude": [

templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/AppDbContext.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
120120
#endif
121121
#if Identity
122122
.Format<User>(x => x.PasswordHash, x => "<password changed>")
123+
.Format<User>(x => x.SecurityStamp, x => "<stamp changed>")
124+
.ExcludeProperty<User>(x => new { x.ConcurrencyStamp })
123125
#endif
124126
#if Tenancy
125127
.ExcludeProperty<ITenanted>(x => new { x.TenantId })

templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Auth/InvitationService.cs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
using Microsoft.AspNetCore.DataProtection;
1+
using Coalesce.Starter.Vue.Data.Communication;
2+
using Microsoft.AspNetCore.DataProtection;
23
using Microsoft.AspNetCore.Mvc;
4+
using System.Text.Encodings.Web;
35
using System.Text.Json;
46

57
namespace Coalesce.Starter.Vue.Data.Auth;
68

79
public class InvitationService(
810
AppDbContext db,
911
IDataProtectionProvider dataProtector,
10-
IUrlHelper urlHelper
12+
IUrlHelper urlHelper,
13+
IEmailService emailService
1114
)
1215
{
1316
private IDataProtector GetProtector() => dataProtector.CreateProtector("invitations");
@@ -19,8 +22,9 @@ Role[] roles
1922
{
2023
var tenantId = db.TenantIdOrThrow;
2124

22-
if (roles.Any(r => r.TenantId != tenantId)) return "Role/tenant mismatch";
25+
if (roles.Any(r => db.Roles.FirstOrDefault(dbRole => dbRole.Id == r.Id) is null)) return "One or more roles are invalid";
2326

27+
var tenant = db.Tenants.Find(tenantId)!;
2428
var invitation = new UserInvitation
2529
{
2630
Email = email,
@@ -41,10 +45,11 @@ Role[] roles
4145

4246
var link = CreateInvitationLink(invitation);
4347

44-
// TODO: Implement email sending and send the invitation link directly to `email`.
45-
// Returning it directly in the result message is a temporary measure.
46-
47-
return new(true, message: $"Give the following invitation link to {email}:\n\n{link}");
48+
return await emailService.SendEmailAsync(email, $"Invitation to {tenant.Name}",
49+
$"""
50+
You have been invited to join the <b>{HtmlEncoder.Default.Encode(tenant.Name)}</b> organization.
51+
Please <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> to accept the invitation.
52+
""");
4853
}
4954

5055
public async Task<ItemResult> AcceptInvitation(
@@ -55,14 +60,24 @@ User acceptingUser
5560
var tenant = await db.Tenants.FindAsync(invitation.TenantId);
5661
if (tenant is null) return "Tenant not found";
5762

63+
if (!invitation.Email.Equals(acceptingUser.Email, StringComparison.OrdinalIgnoreCase))
64+
{
65+
return $"Your email address doesn't match the intended recipient of this invitation.";
66+
}
67+
5868
// Note: `acceptingUser` will be untracked after ForceSetTenant.
5969
db.ForceSetTenant(invitation.TenantId);
70+
acceptingUser = db.Users.Find(acceptingUser.Id)!;
6071

6172
if (await db.TenantMemberships.AnyAsync(m => m.User == acceptingUser))
6273
{
6374
return $"{acceptingUser.UserName ?? acceptingUser.Email} is already a member of {tenant.Name}.";
6475
}
6576

77+
// Since invitations are emailed to users, they also act as an email confirmation.
78+
// If this is not true for your application, delete this line.
79+
acceptingUser.EmailConfirmed = true;
80+
6681
db.TenantMemberships.Add(new() { UserId = acceptingUser.Id });
6782
db.UserRoles.AddRange(invitation.Roles.Select(rid => new UserRole { RoleId = rid, UserId = acceptingUser.Id }));
6883
await db.SaveChangesAsync();
@@ -75,7 +90,7 @@ public string CreateInvitationLink(UserInvitation invitation)
7590
var inviteJson = JsonSerializer.Serialize(invitation);
7691
var inviteCode = GetProtector().Protect(inviteJson);
7792

78-
return urlHelper.PageLink("/invitation", values: new { code = inviteCode })!;
93+
return urlHelper.PageLink("/Invitation", values: new { code = inviteCode })!;
7994
}
8095

8196
public ItemResult<UserInvitation> DecodeInvitation(string code)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using Coalesce.Starter.Vue.Data.Communication;
2+
using Microsoft.AspNetCore.Identity;
3+
using Microsoft.AspNetCore.Mvc;
4+
using System.Diagnostics;
5+
using System.Net.Mail;
6+
using System.Text.Encodings.Web;
7+
8+
namespace Coalesce.Starter.Vue.Data.Auth;
9+
10+
public class UserManagementService(
11+
UserManager<User> _userManager,
12+
IUrlHelper urlHelper,
13+
IEmailService emailSender
14+
)
15+
{
16+
public async Task<ItemResult> SendEmailConfirmationRequest(User user)
17+
{
18+
if (user.EmailConfirmed) return "Email is already confirmed.";
19+
if (string.IsNullOrWhiteSpace(user.Email)) return "User has no email";
20+
21+
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
22+
23+
var link = urlHelper.PageLink("/ConfirmEmail", values: new { userId = user.Id, code = code })!;
24+
25+
var result = await emailSender.SendEmailAsync(
26+
user.Email,
27+
"Confirm your email",
28+
$"""
29+
Please <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> to confirm your account.
30+
If you didn't request this, ignore this email and do not click the link.
31+
"""
32+
);
33+
34+
if (result.WasSuccessful)
35+
{
36+
result.Message += " Please click the link in the email to confirm your account.";
37+
}
38+
39+
return result;
40+
}
41+
42+
public async Task<ItemResult> SendEmailChangeRequest(User user, string newEmail)
43+
{
44+
// This is secured by virtue of the filtering done in the [DefaultDataSource].
45+
// Regular users can only fetch themselves out of the data source,
46+
// admins can only view users in their own tenant,
47+
// and tenant admins can view everyone.
48+
49+
if (string.IsNullOrEmpty(newEmail) || !MailAddress.TryCreate(newEmail, out _))
50+
{
51+
return "New email is not valid.";
52+
}
53+
54+
if (string.Equals(user.Email, newEmail, StringComparison.OrdinalIgnoreCase))
55+
{
56+
return "New email is not different.";
57+
}
58+
59+
var code = await _userManager.GenerateChangeEmailTokenAsync(user, newEmail);
60+
61+
var link = urlHelper.PageLink("/ConfirmEmail", values: new { userId = user.Id, code = code, newEmail = newEmail })!;
62+
63+
var result = await emailSender.SendEmailAsync(
64+
newEmail,
65+
"Confirm your email",
66+
$"""
67+
Please <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> to complete your email change request.
68+
If you didn't request this, ignore this email and do not click the link.
69+
"""
70+
);
71+
72+
if (result.WasSuccessful)
73+
{
74+
result.Message += " Please click the link in the email to complete the change.";
75+
}
76+
77+
return result;
78+
}
79+
80+
public async Task<ItemResult> SendPasswordResetRequest(User? user)
81+
{
82+
if (user?.Email is not null)
83+
{
84+
var code = await _userManager.GeneratePasswordResetTokenAsync(user);
85+
86+
var link = urlHelper.PageLink("ResetPassword", values: new { userId = user.Id, code = code })!;
87+
88+
await emailSender.SendEmailAsync(
89+
user.Email,
90+
"Password Reset",
91+
$"""
92+
Please <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> to reset your password.
93+
If you didn't request this, ignore this email and do not click the link.
94+
"""
95+
);
96+
}
97+
98+
return new ItemResult(true,
99+
"If the user account exists, the email address on the account " +
100+
"will receive an email shortly with password reset instructions.");
101+
}
102+
}

templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Data/Coalesce.Starter.Vue.Data.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,11 @@
2323
<!--#if (AuditLogs) -->
2424
<PackageReference Include="IntelliTect.Coalesce.AuditLogging" Version="$(CoalesceVersion)" />
2525
<!--#endif -->
26+
<!--#if (EmailAzure) -->
27+
<PackageReference Include="Azure.Communication.Email" Version="1.0.1" />
28+
<!--#endif -->
29+
<!--#if (EmailSendGrid) -->
30+
<PackageReference Include="SendGrid" Version="9.29.3" />
31+
<!--#endif -->
2632
</ItemGroup>
2733
</Project>

0 commit comments

Comments
 (0)