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 guest users by email, don't add right away #819

Closed
wants to merge 3 commits into from
Closed
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
60 changes: 36 additions & 24 deletions backend/LexBoxApi/GraphQL/ProjectMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public async Task<IQueryable<Project>> AddProjectMember(IPermissionService permi
}

public record UserProjectRole(string Username, ProjectRole Role);
public record BulkAddProjectMembersResult(List<UserProjectRole> AddedMembers, List<UserProjectRole> CreatedMembers, List<UserProjectRole> ExistingMembers);
public record BulkAddProjectMembersResult(List<UserProjectRole> AddedMembers, List<UserProjectRole> CreatedMembers, List<UserProjectRole> ExistingMembers, List<UserProjectRole> InvitedMembers);

[Error<NotFoundException>]
[Error<InvalidEmailException>]
Expand All @@ -120,16 +120,19 @@ public record BulkAddProjectMembersResult(List<UserProjectRole> AddedMembers, Li
public async Task<BulkAddProjectMembersResult> 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<UserProjectRole> AddedMembers = [];
List<UserProjectRole> CreatedMembers = [];
List<UserProjectRole> ExistingMembers = [];
List<UserProjectRole> 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!);
Expand All @@ -140,28 +143,37 @@ public async Task<BulkAddProjectMembersResult> 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)
{
Expand All @@ -184,7 +196,7 @@ public async Task<BulkAddProjectMembersResult> 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)
Expand Down
49 changes: 27 additions & 22 deletions backend/LexBoxApi/GraphQL/UserMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ EmailService emailService
[Error<NotFoundException>]
[Error<DbError>]
[Error<UniqueValueException>]
[Error<ProjectMemberInvitedByEmail>]
[Error<RequiredException>]
[AdminRequired]
public async Task<LexAuthUser> CreateGuestUserByAdmin(
Expand All @@ -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<User> UpdateUser(
Expand Down
8 changes: 4 additions & 4 deletions backend/LexBoxApi/Services/EmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,10 @@ public async Task SendVerifyAddressEmail(User user, string? newEmail = null)
/// <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,
Guid projectId,
Guid? projectId,
ProjectRole role,
string managerName,
string projectName,
string? projectName,
string? language = null)
{
language ??= User.DefaultLocalizationCode;
Expand All @@ -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,
Expand All @@ -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);

}
Expand Down
3 changes: 2 additions & 1 deletion frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type BulkAddProjectMembersResult {
addedMembers: [UserProjectRole!]!
createdMembers: [UserProjectRole!]!
existingMembers: [UserProjectRole!]!
invitedMembers: [UserProjectRole!]!
}

type ChangeProjectDescriptionPayload {
Expand Down Expand Up @@ -369,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

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/components/Users/CreateUser.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/components/Users/CreateUserModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
<h1 class="text-center text-xl">{$t('admin_dashboard.create_user_modal.create_user')}</h1>
<CreateUser {handleSubmit} allowUsernames skipTurnstile
on:submitted={() => createUserModal.submitModal()}
on:invited={() => createUserModal.submitModal()}
submitButtonText={$t('admin_dashboard.create_user_modal.create_user')}
/>
<!-- TODO: Display toast notification when user invited? Or leave up to caller to decide how to handle, and just pass event on? -->
</Modal>
8 changes: 5 additions & 3 deletions frontend/src/lib/email/CreateAccountRequest.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
</script>

<Email subject={$t('emails.create_account_request_email.subject', {projectName})} name="">
<mj-text>{$t('emails.create_account_request_email.body', {managerName, projectName})}</mj-text>
<mj-button href={verifyUrl}>{$t('emails.create_account_request_email.join_button')}</mj-button>
<Email subject={$t(`${template}.subject`, {projectName})} name="">
<mj-text>{$t(`${template}.body`, {managerName, projectName})}</mj-text>
<mj-button href={verifyUrl}>{$t(`${template}.join_button`)}</mj-button>
<mj-text>{$t(expirationText, expirationParam)}</mj-text>
</Email>
5 changes: 5 additions & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/lib/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export async function login(userId: string, password: string): Promise<LoginResu
: { success: false, error: await response.text() as LoginError };
}

export type RegisterResponse = { error?: { turnstile?: boolean, accountExists?: boolean, invalidInput?: boolean }, user?: LexAuthUser };
export type RegisterResponse = { error?: { turnstile?: boolean, accountExists?: boolean, invalidInput?: boolean, invited?: boolean }, user?: LexAuthUser };
export async function createUser(endpoint: string, password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string): Promise<RegisterResponse> {
const response = await fetch(endpoint, {
method: 'post',
Expand Down Expand Up @@ -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 }};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export async function _bulkAddProjectMembers(input: BulkAddProjectMembersInput):
username
role
}
invitedMembers {
username
role
}
}
errors {
__typename
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof schema> {
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -154,6 +156,21 @@
</div>
{/if}
</div>
<div class="mb-4 ml-8">
<p class="flex gap-1 items-center">
<Icon icon="i-mdi-creation-outline" color="text-success" />
{$t('project_page.bulk_add_members.accounts_invited', {invitedCount: invitedMembers.length})}
</p>
{#if invitedMembers.length > 0}
<div class="mt-2">
<BadgeList>
{#each invitedMembers as user}
<MemberBadge member={{ name: user.username, role: user.role }} />
{/each}
</BadgeList>
</div>
{/if}
</div>
{#if existingMembers.length > 0}
<p class="flex gap-1 items-center">
<Icon icon="i-mdi-account-outline" color="text-info" />
Expand Down
Loading