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

invite users to lexbox when added to org #940

Merged
merged 19 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from 17 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: 4 additions & 0 deletions backend/LexBoxApi/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ public async Task<ActionResult<LexAuthUser>> AcceptEmailInvitation(RegisterAccou
{
userEntity.Projects = jwtUser.Projects.Select(p => new ProjectUsers { Role = p.Role, ProjectId = p.ProjectId }).ToList();
}
if (jwtUser.Audience == LexboxAudience.RegisterAccount && jwtUser.Orgs.Length > 0)
{
userEntity.Organizations = jwtUser.Orgs.Select(o => new OrgMember { Role = o.Role, OrgId = o.OrgId }).ToList();
}
await _lexBoxDbContext.SaveChangesAsync();

var user = new LexAuthUser(userEntity);
Expand Down
42 changes: 38 additions & 4 deletions backend/LexBoxApi/GraphQL/OrgMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using LexBoxApi.Auth.Attributes;
using LexBoxApi.Models.Org;
using LexBoxApi.Services;
using LexBoxApi.Services.Email;
using LexCore.Entities;
using LexCore.Exceptions;
using LexCore.ServiceInterfaces;
Expand Down Expand Up @@ -131,19 +132,52 @@ public async Task<IQueryable<Organization>> RemoveProjectFromOrg(
/// <param name="emailOrUsername">either an email or a username for the user whos membership to update</param>
[Error<DbError>]
[Error<NotFoundException>]
[Error<OrgMemberInvitedByEmail>]
[UseMutationConvention]
[UseFirstOrDefault]
[UseProjection]
public async Task<IQueryable<Organization>> SetOrgMemberRole(
psh0078 marked this conversation as resolved.
Show resolved Hide resolved
LexBoxDbContext dbContext,
LoggedInContext loggedInContext,
IPermissionService permissionService,
Guid orgId,
OrgRole? role,
string emailOrUsername)
OrgRole role,
string emailOrUsername,
bool canInvite,
[Service] IEmailService emailService)
{
var org = await dbContext.Orgs.FindAsync(orgId);
NotFoundException.ThrowIfNull(org);
permissionService.AssertCanEditOrg(org);
var user = await dbContext.Users.FindByEmailOrUsername(emailOrUsername);
NotFoundException.ThrowIfNull(user); // TODO: Implement inviting user
return await ChangeOrgMemberRole(dbContext, permissionService, orgId, user.Id, role);
if (user is null)
{
var (_, email, _) = UserService.ExtractNameAndAddressFromUsernameOrEmail(emailOrUsername);
if (email is null)
{
throw NotFoundException.ForType<User>();
}
else if (canInvite)
{
var manager = loggedInContext.User;
await emailService.SendCreateAccountWithOrgEmail(
email,
manager.Name,
orgId: orgId,
orgRole: role,
orgName: org.Name);
throw new OrgMemberInvitedByEmail("Invitation email sent");
}
else
{
throw NotFoundException.ForType<User>();
}
}
else if (user.Organizations.Any(o => o.OrgId == orgId))
psh0078 marked this conversation as resolved.
Show resolved Hide resolved
{
return await ChangeOrgMemberRole(dbContext, permissionService, orgId, user.Id, role);
}
return dbContext.Orgs.Where(o => o.Id == orgId);
}

/// <summary>
Expand Down
46 changes: 10 additions & 36 deletions backend/LexBoxApi/GraphQL/ProjectMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
using LexData.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.Net.Mail;
using LexBoxApi.Services.Email;

namespace LexBoxApi.GraphQL;
Expand Down Expand Up @@ -69,7 +68,8 @@ public record CreateProjectResponse(Guid? Id, CreateProjectResult Result);
[UseMutationConvention]
[UseFirstOrDefault]
[UseProjection]
public async Task<IQueryable<Project>> AddProjectMember(IPermissionService permissionService,
public async Task<IQueryable<Project>> AddProjectMember(
IPermissionService permissionService,
LoggedInContext loggedInContext,
AddProjectMemberInput input,
LexBoxDbContext dbContext,
Expand All @@ -81,7 +81,7 @@ public async Task<IQueryable<Project>> AddProjectMember(IPermissionService permi
var user = await dbContext.Users.Include(u => u.Projects).FindByEmailOrUsername(input.UsernameOrEmail);
if (user is null)
{
var (_, email, _) = ExtractNameAndAddressFromUsernameOrEmail(input.UsernameOrEmail);
var (_, email, _) = UserService.ExtractNameAndAddressFromUsernameOrEmail(input.UsernameOrEmail);
// We don't try to catch InvalidEmailException; if it happens, we let it get sent to the frontend
if (email is null)
{
Expand All @@ -90,7 +90,12 @@ public async Task<IQueryable<Project>> AddProjectMember(IPermissionService permi
else
{
var manager = loggedInContext.User;
await emailService.SendCreateAccountEmail(email, input.ProjectId, input.Role, manager.Name, project.Name);
await emailService.SendCreateAccountWithProjectEmail(
email,
manager.Name,
projectId: input.ProjectId,
role: input.Role,
projectName: project.Name);
throw new ProjectMemberInvitedByEmail("Invitation email sent");
}
}
Expand Down Expand Up @@ -140,7 +145,7 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
if (user is null)
{
var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var (name, email, username) = ExtractNameAndAddressFromUsernameOrEmail(usernameOrEmail);
var (name, email, username) = UserService.ExtractNameAndAddressFromUsernameOrEmail(usernameOrEmail);
user = new User
{
Id = Guid.NewGuid(),
Expand Down Expand Up @@ -188,37 +193,6 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
return new BulkAddProjectMembersResult(AddedMembers, CreatedMembers, ExistingMembers);
}

public static (string name, string? email, string? username) ExtractNameAndAddressFromUsernameOrEmail(string usernameOrEmail)
{
var isEmailAddress = usernameOrEmail.Contains('@');
string name;
string? email;
string? username;
if (isEmailAddress)
{
try
{
var parsed = new MailAddress(usernameOrEmail);
email = parsed.Address;
username = null;
name = parsed.DisplayName;
if (string.IsNullOrEmpty(name)) name = email.Split('@')[0];
}
catch (FormatException)
{
// FormatException message from .NET talks about mail headers, which is confusing here
throw new InvalidEmailException("Invalid email address", usernameOrEmail);
}
}
else
{
username = usernameOrEmail;
email = null;
name = username;
}
return (name, email, username);
}

[Error<NotFoundException>]
[Error<DbError>]
[Error<ProjectMembersMustBeVerified>]
Expand Down
6 changes: 4 additions & 2 deletions backend/LexBoxApi/Services/Email/EmailTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public enum EmailTemplate
NewAdmin,
VerifyEmailAddress,
PasswordChanged,
CreateAccountRequest,
CreateAccountRequestProject,
CreateAccountRequestOrg,
CreateProjectRequest,
ApproveProjectRequest,
UserAdded,
Expand All @@ -29,7 +30,8 @@ public record NewAdminEmail(string Name, string AdminName, string AdminEmail) :

public record VerifyAddressEmail(string Name, string VerifyUrl, bool newAddress, TimeSpan lifetime) : EmailTemplateBase(EmailTemplate.VerifyEmailAddress);

public record ProjectInviteEmail(string Email, string ProjectId, string ManagerName, string ProjectName, string VerifyUrl, TimeSpan lifetime) : EmailTemplateBase(EmailTemplate.CreateAccountRequest);
public record ProjectInviteEmail(string Email, string ProjectId, string ManagerName, string ProjectName, string VerifyUrl, TimeSpan lifetime) : EmailTemplateBase(EmailTemplate.CreateAccountRequestProject);
public record OrgInviteEmail(string Email, string OrgId, string ManagerName, string OrgName, string VerifyUrl, TimeSpan lifetime) : EmailTemplateBase(EmailTemplate.CreateAccountRequestOrg);
psh0078 marked this conversation as resolved.
Show resolved Hide resolved

public record PasswordChangedEmail(string Name) : EmailTemplateBase(EmailTemplate.PasswordChanged);

Expand Down
12 changes: 10 additions & 2 deletions backend/LexBoxApi/Services/Email/IEmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,18 @@ public interface IEmailService
/// <param name="emailAddress">The email address to send the invitation to</param>
/// <param name="projectId">The GUID of the project the user is being invited to</param>
/// <param name="language">The language in which the invitation email should be sent (default English)</param>
public Task SendCreateAccountEmail(string emailAddress,
public Task SendCreateAccountWithOrgEmail(
psh0078 marked this conversation as resolved.
Show resolved Hide resolved
string emailAddress,
string managerName,
Guid orgId,
OrgRole orgRole,
string orgName,
string? language = null);
public Task SendCreateAccountWithProjectEmail(
string emailAddress,
string managerName,
Guid projectId,
ProjectRole role,
string managerName,
string projectName,
string? language = null);

Expand Down
102 changes: 75 additions & 27 deletions backend/LexBoxApi/Services/EmailService.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
// using System.Text.Json.Serialization; This is not being used.
psh0078 marked this conversation as resolved.
Show resolved Hide resolved
using LexBoxApi.Auth;
using LexBoxApi.Config;
using LexBoxApi.Jobs;
Expand Down Expand Up @@ -48,7 +48,7 @@ public async Task SendForgotPasswordEmail(string emailAddress)
new { jwt, returnTo = "/resetPassword" });
ArgumentException.ThrowIfNullOrEmpty(forgotLink);
await RenderEmail(email, new ForgotPasswordEmail(user.Name, forgotLink, lifetime), user.LocalizationCode);
await SendEmailWithRetriesAsync(email, retryCount:5, retryWaitSeconds:30);
await SendEmailWithRetriesAsync(email, retryCount: 5, retryWaitSeconds: 30);
}

public async Task SendNewAdminEmail(IAsyncEnumerable<User> admins, string newAdminName, string newAdminEmail)
Expand All @@ -73,9 +73,10 @@ public async Task SendNewAdminEmail(IAsyncEnumerable<User> admins, string newAdm
public async Task SendVerifyAddressEmail(User user, string? newEmail = null)
{
var (jwt, _, lifetime) = lexAuthService.GenerateJwt(new LexAuthUser(user)
{
EmailVerificationRequired = null, Email = newEmail ?? user.Email,
},
{
EmailVerificationRequired = null,
Email = newEmail ?? user.Email,
},
useEmailLifetime: true
);
var email = StartUserEmail(user, newEmail);
Expand All @@ -92,53 +93,100 @@ public async Task SendVerifyAddressEmail(User user, string? newEmail = null)
await SendEmailWithRetriesAsync(email);
}

/// <summary>
/// Sends a organization invitation email to a new user, whose account will be created when they accept.
/// </summary>
/// <param name="name">The name (real name, NOT username) of user to invite.</param>
/// <param name="emailAddress">The email address to send the invitation to</param>
/// <param name="orgId">The GUID of the organization the user is being invited to</param>
/// <param name="language">The language in which the invitation email should be sent (default English)</param>
public async Task SendCreateAccountWithOrgEmail(
string emailAddress,
string managerName,
Guid orgId,
OrgRole orgRole,
string orgName,
string? language = null)
{
language ??= User.DefaultLocalizationCode;
var authUser = CreateAuthUser(emailAddress, language);
authUser.Orgs = [new AuthUserOrg(orgRole, orgId)];
await SendInvitationEmail(authUser, emailAddress, managerName, orgId.ToString(), orgName, language, isProjectInvitation: false);

}
/// <summary>
/// Sends a project invitation email to a new user, whose account will be created when they accept.
/// </summary>
/// <param name="name">The name (real name, NOT username) of user to invite.</param>
/// <param name="emailAddress">The email address to send the invitation to</param>
/// <param name="projectId">The GUID of the project the user is being invited to</param>
/// <param name="language">The language in which the invitation email should be sent (default English)</param>
public async Task SendCreateAccountEmail(string emailAddress,
public async Task SendCreateAccountWithProjectEmail(
string emailAddress,
string managerName,
Guid projectId,
ProjectRole role,
string managerName,
string projectName,
string? language = null)
{
language ??= User.DefaultLocalizationCode;
var (jwt, _, lifetime) = lexAuthService.GenerateJwt(new LexAuthUser()
{
Id = Guid.NewGuid(),
Audience = LexboxAudience.RegisterAccount,
Name = "",
Email = emailAddress,
EmailVerificationRequired = null,
Role = UserRole.user,
UpdatedDate = DateTimeOffset.Now.ToUnixTimeSeconds(),
Projects = [new AuthUserProject(role, projectId)],
CanCreateProjects = null,
Locale = language,
Locked = null,
},
useEmailLifetime: true
);
var authUser = CreateAuthUser(emailAddress, language);
authUser.Projects = [new AuthUserProject(role, projectId)];
await SendInvitationEmail(authUser, emailAddress, managerName, projectId.ToString(), projectName, language, isProjectInvitation: true);

}
private LexAuthUser CreateAuthUser(string emailAddress, string? language)
psh0078 marked this conversation as resolved.
Show resolved Hide resolved
{
language ??= User.DefaultLocalizationCode;
return new LexAuthUser
{
Id = Guid.NewGuid(),
Audience = LexboxAudience.RegisterAccount,
Name = "",
Email = emailAddress,
EmailVerificationRequired = null,
Role = UserRole.user,
UpdatedDate = DateTimeOffset.Now.ToUnixTimeSeconds(),
CanCreateProjects = null,
Locale = language,
Locked = null,
Projects = [],
Orgs = [],
};
}
private async Task SendInvitationEmail(
LexAuthUser authUser,
string emailAddress,
string managerName,
string id,
string name,
psh0078 marked this conversation as resolved.
Show resolved Hide resolved
string? language,
bool isProjectInvitation)
{
language ??= User.DefaultLocalizationCode;
var (jwt, _, lifetime) = lexAuthService.GenerateJwt(authUser, useEmailLifetime: true);
var email = StartUserEmail(name: "", emailAddress);
var httpContext = httpContextAccessor.HttpContext;
ArgumentNullException.ThrowIfNull(httpContext);

var queryString = QueryString.Create("email", emailAddress);
var returnTo = new UriBuilder() { Path = "/acceptInvitation", Query = queryString.Value }.Uri.PathAndQuery;
var returnTo = new UriBuilder { Path = "/acceptInvitation", Query = queryString.Value }.Uri.PathAndQuery;
var registerLink = _linkGenerator.GetUriByAction(httpContext,
"LoginRedirect",
"Login",
new { jwt, returnTo });

ArgumentException.ThrowIfNullOrEmpty(registerLink);
await RenderEmail(email, new ProjectInviteEmail(emailAddress, projectId.ToString(), managerName, projectName, registerLink, lifetime), language);
if (isProjectInvitation)
{
await RenderEmail(email, new ProjectInviteEmail(emailAddress, id.ToString() ?? "", managerName, name ?? "", registerLink, lifetime), language);
}
else
{
await RenderEmail(email, new OrgInviteEmail(emailAddress, id.ToString() ?? "", managerName, name ?? "", registerLink, lifetime), language);
}
await SendEmailAsync(email);

}

public async Task SendPasswordChangedEmail(User user)
{
var email = StartUserEmail(user);
Expand Down
Loading
Loading