From b2c45263dade7dd699b7e70124f28a8e0fe1b4bd Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 24 May 2024 13:21:13 +0700 Subject: [PATCH 1/3] Make project optional in create-account invite emails We're going to change the Create User dialog for admins, and the Bulk Add feature, to send invitation emails to any users who have an email address, rather than automatically adding them without their input. This means that some invitation emails will need to be sent out without a project ID attached. --- backend/LexBoxApi/Services/EmailService.cs | 8 ++++---- frontend/src/lib/email/CreateAccountRequest.svelte | 8 +++++--- frontend/src/lib/i18n/locales/en.json | 5 +++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index af8c46a93..17136c762 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -100,10 +100,10 @@ public async Task SendVerifyAddressEmail(User user, string? newEmail = null) /// The GUID of the project the user is being invited to /// The language in which the invitation email should be sent (default English) public async Task SendCreateAccountEmail(string emailAddress, - Guid projectId, + Guid? projectId, ProjectRole role, string managerName, - string projectName, + string? projectName, string? language = null) { language ??= User.DefaultLocalizationCode; @@ -116,7 +116,7 @@ public async Task SendCreateAccountEmail(string emailAddress, EmailVerificationRequired = null, Role = UserRole.user, UpdatedDate = DateTimeOffset.Now.ToUnixTimeSeconds(), - Projects = [new AuthUserProject(role, projectId)], + Projects = projectId.HasValue ? [new AuthUserProject(role, projectId.Value)] : [], CanCreateProjects = null, Locale = language, Locked = null, @@ -134,7 +134,7 @@ public async Task SendCreateAccountEmail(string emailAddress, new { jwt, returnTo }); ArgumentException.ThrowIfNullOrEmpty(registerLink); - await RenderEmail(email, new ProjectInviteEmail(emailAddress, projectId.ToString(), managerName, projectName, registerLink, lifetime), language); + await RenderEmail(email, new ProjectInviteEmail(emailAddress, projectId.ToString() ?? "", managerName, projectName ?? "", registerLink, lifetime), language); await SendEmailAsync(email); } diff --git a/frontend/src/lib/email/CreateAccountRequest.svelte b/frontend/src/lib/email/CreateAccountRequest.svelte index edf234dcc..7c8cd9104 100644 --- a/frontend/src/lib/email/CreateAccountRequest.svelte +++ b/frontend/src/lib/email/CreateAccountRequest.svelte @@ -9,10 +9,12 @@ export let lifetime: string; $: [expirationText, expirationParam] = toI18nKey(lifetime); + let template: 'emails.create_account_request_email' | 'emails.create_account_without_project_request_email'; + $: template = projectName ? 'emails.create_account_request_email' : 'emails.create_account_without_project_request_email'; - - {$t('emails.create_account_request_email.body', {managerName, projectName})} - {$t('emails.create_account_request_email.join_button')} + + {$t(`${template}.body`, {managerName, projectName})} + {$t(`${template}.join_button`)} {$t(expirationText, expirationParam)} diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 70a88d5a3..53a7865bc 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -475,6 +475,11 @@ If you don't see a dialog or already closed it, click the button below:", "body": "{managerName} has invited you to join the {projectName} language project. Click below to join.", "join_button": "Join project" }, + "create_account_without_project_request_email": { + "subject": "Invitation to join Language Depot", + "body": "{managerName} has invited you to join the Language Depot website. Click below to create your account.", + "join_button": "Create account" + }, "create_project_request_email": { "subject": "Project request: {projectName}", "heading": "User {name} ({email}) requested that a project be created for them. Details below:" From 4eff69996a0db8ed362d72602cf9621c5bb0b4e5 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 24 May 2024 13:55:20 +0700 Subject: [PATCH 2/3] Send project invitations in bulk-add flow Now, when admins add an email address to the bulk-add dialog, that user will receive an invitation email instead of being added immediately. --- backend/LexBoxApi/GraphQL/ProjectMutations.cs | 60 +++++++++++-------- frontend/schema.graphql | 1 + .../project/[project_code]/+page.ts | 4 ++ .../BulkAddProjectMembers.svelte | 17 ++++++ 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index 9f4c87bd0..a2936375a 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -110,7 +110,7 @@ public async Task> AddProjectMember(IPermissionService permi } public record UserProjectRole(string Username, ProjectRole Role); - public record BulkAddProjectMembersResult(List AddedMembers, List CreatedMembers, List ExistingMembers); + public record BulkAddProjectMembersResult(List AddedMembers, List CreatedMembers, List ExistingMembers, List InvitedMembers); [Error] [Error] @@ -120,16 +120,19 @@ public record BulkAddProjectMembersResult(List AddedMembers, Li public async Task BulkAddProjectMembers( LoggedInContext loggedInContext, BulkAddProjectMembersInput input, - LexBoxDbContext dbContext) + LexBoxDbContext dbContext, + [Service] EmailService emailService) { + Project? project = null; if (input.ProjectId.HasValue) { - var projectExists = await dbContext.Projects.AnyAsync(p => p.Id == input.ProjectId.Value); - if (!projectExists) throw new NotFoundException("Project not found", "project"); + project = await dbContext.Projects.FindAsync(input.ProjectId.Value); + if (project is null) throw new NotFoundException("Project not found", "project"); } List AddedMembers = []; List CreatedMembers = []; List ExistingMembers = []; + List InvitedMembers = []; var existingUsers = await dbContext.Users.Include(u => u.Projects).Where(u => input.Usernames.Contains(u.Username) || input.Usernames.Contains(u.Email)).ToArrayAsync(); var byUsername = existingUsers.Where(u => u.Username is not null).ToDictionary(u => u.Username!); var byEmail = existingUsers.Where(u => u.Email is not null).ToDictionary(u => u.Email!); @@ -140,28 +143,37 @@ public async Task BulkAddProjectMembers( { var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes)); var (name, email, username) = ExtractNameAndAddressFromUsernameOrEmail(usernameOrEmail); - user = new User + if (email is null) { - Id = Guid.NewGuid(), - Username = username, - Name = name, - Email = email, - LocalizationCode = "en", // TODO: input.Locale, - Salt = salt, - PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true), - PasswordStrength = 0, // Shared password, so always considered strength 0, we don't call Zxcvbn here - IsAdmin = false, - EmailVerified = false, - CreatedById = loggedInContext.User.Id, - Locked = false, - CanCreateProjects = false - }; - CreatedMembers.Add(new UserProjectRole(usernameOrEmail, input.Role)); - if (input.ProjectId.HasValue) + user = new User + { + Id = Guid.NewGuid(), + Username = username, + Name = name, + Email = email, + LocalizationCode = "en", // TODO: input.Locale, + Salt = salt, + PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true), + PasswordStrength = 0, // Shared password, so always considered strength 0, we don't call Zxcvbn here + IsAdmin = false, + EmailVerified = false, + CreatedById = loggedInContext.User.Id, + Locked = false, + CanCreateProjects = false + }; + CreatedMembers.Add(new UserProjectRole(usernameOrEmail, input.Role)); + if (input.ProjectId.HasValue) + { + user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id }); + } + dbContext.Add(user); + } + else { - user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id }); + var admin = loggedInContext.User; + await emailService.SendCreateAccountEmail(email, project?.Id, input.Role, admin.Name, project?.Name); + InvitedMembers.Add(new UserProjectRole(email, input.Role)); } - dbContext.Add(user); } else if (input.ProjectId.HasValue) { @@ -184,7 +196,7 @@ public async Task BulkAddProjectMembers( } } await dbContext.SaveChangesAsync(); - return new BulkAddProjectMembersResult(AddedMembers, CreatedMembers, ExistingMembers); + return new BulkAddProjectMembersResult(AddedMembers, CreatedMembers, ExistingMembers, InvitedMembers); } public static (string name, string? email, string? username) ExtractNameAndAddressFromUsernameOrEmail(string usernameOrEmail) diff --git a/frontend/schema.graphql b/frontend/schema.graphql index ce9aa959f..27259de60 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -30,6 +30,7 @@ type BulkAddProjectMembersResult { addedMembers: [UserProjectRole!]! createdMembers: [UserProjectRole!]! existingMembers: [UserProjectRole!]! + invitedMembers: [UserProjectRole!]! } type ChangeProjectDescriptionPayload { diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts index 9141f6979..6432568e8 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts @@ -173,6 +173,10 @@ export async function _bulkAddProjectMembers(input: BulkAddProjectMembersInput): username role } + invitedMembers { + username + role + } } errors { __typename diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte index 3355eb3ac..6facfc28f 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte @@ -34,6 +34,7 @@ let addedMembers: BulkAddProjectMembersResult['addedMembers'] = []; let createdMembers: BulkAddProjectMembersResult['createdMembers'] = []; let existingMembers: BulkAddProjectMembersResult['existingMembers'] = []; + let invitedMembers: BulkAddProjectMembersResult['invitedMembers'] = []; $: addedCount = addedMembers.length + createdMembers.length; function validateBulkAddInput(usernames: string[]): FormSubmitReturn { @@ -79,6 +80,7 @@ addedMembers = data?.bulkAddProjectMembers.bulkAddProjectMembersResult?.addedMembers ?? []; createdMembers = data?.bulkAddProjectMembers.bulkAddProjectMembersResult?.createdMembers ?? []; existingMembers = data?.bulkAddProjectMembers.bulkAddProjectMembersResult?.existingMembers ?? []; + invitedMembers = data?.bulkAddProjectMembers.bulkAddProjectMembersResult?.invitedMembers ?? []; return error?.message; }, { keepOpenOnSubmit: true }); @@ -154,6 +156,21 @@ {/if} +
+

+ + {$t('project_page.bulk_add_members.accounts_invited', {invitedCount: invitedMembers.length})} +

+ {#if invitedMembers.length > 0} +
+ + {#each invitedMembers as user} + + {/each} + +
+ {/if} +
{#if existingMembers.length > 0}

From a08e6824bf0dd984cd669f27c81b88a24a51e9db Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 24 May 2024 15:25:28 +0700 Subject: [PATCH 3/3] Send project invitations in CreateUser modal Now admins will create users if they just have usernames, but send email invites instaed if they type an email address. --- backend/LexBoxApi/GraphQL/UserMutations.cs | 49 ++++++++++--------- frontend/schema.graphql | 2 +- .../lib/components/Users/CreateUser.svelte | 3 ++ .../components/Users/CreateUserModal.svelte | 2 + frontend/src/lib/user.ts | 5 +- 5 files changed, 37 insertions(+), 24 deletions(-) diff --git a/backend/LexBoxApi/GraphQL/UserMutations.cs b/backend/LexBoxApi/GraphQL/UserMutations.cs index aff7f6108..e96ae8445 100644 --- a/backend/LexBoxApi/GraphQL/UserMutations.cs +++ b/backend/LexBoxApi/GraphQL/UserMutations.cs @@ -77,6 +77,7 @@ EmailService emailService [Error] [Error] [Error] + [Error] [Error] [AdminRequired] public async Task CreateGuestUserByAdmin( @@ -96,31 +97,35 @@ EmailService emailService var admin = loggedInContext.User; - var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes)); - var userEntity = new User + if (string.IsNullOrEmpty(input.Email)) { - Id = Guid.NewGuid(), - Name = input.Name, - Email = input.Email, - Username = input.Username, - LocalizationCode = input.Locale, - Salt = salt, - PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true), - PasswordStrength = UserService.ClampPasswordStrength(input.PasswordStrength), - IsAdmin = false, - EmailVerified = false, - CreatedById = admin.Id, - Locked = false, - CanCreateProjects = false - }; - createGuestUserActivity?.AddTag("app.user.id", userEntity.Id); - dbContext.Users.Add(userEntity); - await dbContext.SaveChangesAsync(); - if (!string.IsNullOrEmpty(input.Email)) + var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes)); + var userEntity = new User + { + Id = Guid.NewGuid(), + Name = input.Name, + Email = input.Email, + Username = input.Username, + LocalizationCode = input.Locale, + Salt = salt, + PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true), + PasswordStrength = UserService.ClampPasswordStrength(input.PasswordStrength), + IsAdmin = false, + EmailVerified = false, + CreatedById = admin.Id, + Locked = false, + CanCreateProjects = false + }; + createGuestUserActivity?.AddTag("app.user.id", userEntity.Id); + dbContext.Users.Add(userEntity); + await dbContext.SaveChangesAsync(); + return new LexAuthUser(userEntity); + } + else { - await emailService.SendVerifyAddressEmail(userEntity); + await emailService.SendCreateAccountEmail(input.Email, null, ProjectRole.Editor, admin.Name, null, input.Locale); + throw new ProjectMemberInvitedByEmail("Invitation email sent"); } - return new LexAuthUser(userEntity); } private static async Task UpdateUser( diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 27259de60..be5245c3b 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -370,7 +370,7 @@ union ChangeUserAccountByAdminError = NotFoundError | DbError | UniqueValueError union ChangeUserAccountBySelfError = NotFoundError | DbError | UniqueValueError -union CreateGuestUserByAdminError = NotFoundError | DbError | UniqueValueError | RequiredError +union CreateGuestUserByAdminError = NotFoundError | DbError | UniqueValueError | ProjectMemberInvitedByEmail | RequiredError union CreateOrganizationError = DbError diff --git a/frontend/src/lib/components/Users/CreateUser.svelte b/frontend/src/lib/components/Users/CreateUser.svelte index 9009bc3e0..05a75c916 100644 --- a/frontend/src/lib/components/Users/CreateUser.svelte +++ b/frontend/src/lib/components/Users/CreateUser.svelte @@ -51,6 +51,9 @@ if (error.invalidInput) { $errors.email = [validateAsEmail($form.email) ? $t('form.invalid_email') : $t('register.invalid_username')]; } + if (error.invited) { + dispatch('invited'); + } return; } if (user) { diff --git a/frontend/src/lib/components/Users/CreateUserModal.svelte b/frontend/src/lib/components/Users/CreateUserModal.svelte index 6c8e5fde4..fd6155c12 100644 --- a/frontend/src/lib/components/Users/CreateUserModal.svelte +++ b/frontend/src/lib/components/Users/CreateUserModal.svelte @@ -36,6 +36,8 @@

{$t('admin_dashboard.create_user_modal.create_user')}

createUserModal.submitModal()} + on:invited={() => createUserModal.submitModal()} submitButtonText={$t('admin_dashboard.create_user_modal.create_user')} /> + diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index c747938da..a14c47cd9 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -85,7 +85,7 @@ export async function login(userId: string, password: string): Promise { const response = await fetch(endpoint, { method: 'post', @@ -138,6 +138,9 @@ export async function createGuestUserByAdmin(password: string, passwordStrength: if (gqlResponse.error?.byType('RequiredError')) { return { error: { invalidInput: true }}; } + if (gqlResponse.error?.byType('ProjectMemberInvitedByEmail')) { + return { error: { invited: true }}; + } if (!gqlResponse.data?.createGuestUserByAdmin.lexAuthUser ) { return { error: { invalidInput: true }}; }