Skip to content

Commit

Permalink
Add "Create User" button to admin dashboard (#736)
Browse files Browse the repository at this point in the history
Admins now have a "Create User" button that pops up a modal dialog to
create guest users (with the CreatedById set to the user ID of the admin
who created their account).

Also, the register page has been split into two pages: "register" and
"accept email invitation". The register page now ignores any JWTs that
might be presented, whereas accept-invite page uses the projects list
from the JWT (if present) to add the new account to those projects. This
avoids a possible scenario where two users share a computer, and one of
them registered an account while the other one was logged in: the new
account would have inherited the project list from the other person's
account, even if that would have been inappropriate.

---------

Co-authored-by: Tim Haasdyk <[email protected]>
  • Loading branch information
rmunn and myieye authored May 24, 2024
1 parent 62c170a commit 5c40148
Show file tree
Hide file tree
Showing 20 changed files with 481 additions and 131 deletions.
82 changes: 64 additions & 18 deletions backend/LexBoxApi/Controllers/UserController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
using LexBoxApi.Models;
using LexBoxApi.Otel;
using LexBoxApi.Services;
Expand Down Expand Up @@ -65,27 +66,51 @@ public async Task<ActionResult<LexAuthUser>> RegisterAccount(RegisterAccountInpu
return ValidationProblem(ModelState);
}

var jwtUser = _loggedInContext.MaybeUser;
var emailVerified = jwtUser?.Email == accountInput.Email;
var userEntity = CreateUserEntity(accountInput, emailVerified: false);
registerActivity?.AddTag("app.user.id", userEntity.Id);
_lexBoxDbContext.Users.Add(userEntity);
await _lexBoxDbContext.SaveChangesAsync();

var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var userEntity = new User
var user = new LexAuthUser(userEntity);
await HttpContext.SignInAsync(user.GetPrincipal("Registration"),
new AuthenticationProperties { IsPersistent = true });

await _emailService.SendVerifyAddressEmail(userEntity);
return Ok(user);
}

[HttpPost("acceptInvitation")]
[RequireAudience(LexboxAudience.RegisterAccount, true)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesErrorResponseType(typeof(Dictionary<string, string[]>))]
[ProducesDefaultResponseType]
public async Task<ActionResult<LexAuthUser>> AcceptEmailInvitation(RegisterAccountInput accountInput)
{
using var acceptActivity = LexBoxActivitySource.Get().StartActivity("AcceptInvitation");
var validToken = await _turnstileService.IsTokenValid(accountInput.TurnstileToken, accountInput.Email);
acceptActivity?.AddTag("app.turnstile_token_valid", validToken);
if (!validToken)
{
Id = Guid.NewGuid(),
Name = accountInput.Name,
Email = accountInput.Email,
LocalizationCode = accountInput.Locale,
Salt = salt,
PasswordHash = PasswordHashing.HashPassword(accountInput.PasswordHash, salt, true),
PasswordStrength = UserService.ClampPasswordStrength(accountInput.PasswordStrength),
IsAdmin = false,
EmailVerified = emailVerified,
Locked = false,
CanCreateProjects = false
};
registerActivity?.AddTag("app.user.id", userEntity.Id);
ModelState.AddModelError<RegisterAccountInput>(r => r.TurnstileToken, "token invalid");
return ValidationProblem(ModelState);
}

var jwtUser = _loggedInContext.User;

var hasExistingUser = await _lexBoxDbContext.Users.FilterByEmailOrUsername(accountInput.Email).AnyAsync();
acceptActivity?.AddTag("app.email_available", !hasExistingUser);
if (hasExistingUser)
{
ModelState.AddModelError<RegisterAccountInput>(r => r.Email, "email already in use");
return ValidationProblem(ModelState);
}

var emailVerified = jwtUser.Email == accountInput.Email;
var userEntity = CreateUserEntity(accountInput, emailVerified);
acceptActivity?.AddTag("app.user.id", userEntity.Id);
_lexBoxDbContext.Users.Add(userEntity);
if (jwtUser is not null && jwtUser.Projects.Length > 0)
// This audience check is redundant now because of [RequireAudience(LexboxAudience.RegisterAccount, true)], but let's leave it in for safety
if (jwtUser.Audience == LexboxAudience.RegisterAccount && jwtUser.Projects.Length > 0)
{
userEntity.Projects = jwtUser.Projects.Select(p => new ProjectUsers { Role = p.Role, ProjectId = p.ProjectId }).ToList();
}
Expand All @@ -99,6 +124,27 @@ await HttpContext.SignInAsync(user.GetPrincipal("Registration"),
return Ok(user);
}

private User CreateUserEntity(RegisterAccountInput input, bool emailVerified, Guid? creatorId = null)
{
var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var userEntity = new User
{
Id = Guid.NewGuid(),
Name = input.Name,
Email = input.Email,
LocalizationCode = input.Locale,
Salt = salt,
PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true),
PasswordStrength = UserService.ClampPasswordStrength(input.PasswordStrength),
IsAdmin = false,
EmailVerified = emailVerified,
CreatedById = creatorId,
Locked = false,
CanCreateProjects = false
};
return userEntity;
}

[HttpPost("sendVerificationEmail")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand Down
21 changes: 16 additions & 5 deletions backend/LexBoxApi/GraphQL/ProjectMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,11 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
BulkAddProjectMembersInput input,
LexBoxDbContext dbContext)
{
var project = await dbContext.Projects.FindAsync(input.ProjectId);
if (project is null) throw new NotFoundException("Project not found", "project");
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");
}
List<UserProjectRole> AddedMembers = [];
List<UserProjectRole> CreatedMembers = [];
List<UserProjectRole> ExistingMembers = [];
Expand Down Expand Up @@ -154,10 +157,13 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
CanCreateProjects = false
};
CreatedMembers.Add(new UserProjectRole(usernameOrEmail, input.Role));
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id });
if (input.ProjectId.HasValue)
{
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id });
}
dbContext.Add(user);
}
else
else if (input.ProjectId.HasValue)
{
var userProject = user.Projects.FirstOrDefault(p => p.ProjectId == input.ProjectId);
if (userProject is not null)
Expand All @@ -168,9 +174,14 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
{
AddedMembers.Add(new UserProjectRole(user.Username ?? user.Email!, input.Role));
// Not yet a member, so add a membership. We don't want to touch existing memberships, which might have other roles
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id });
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id });
}
}
else
{
// No project ID specified, user already exists. This is probably part of bulk-adding through the admin dashboard or org page.
ExistingMembers.Add(new UserProjectRole(user.Username ?? user.Email!, ProjectRole.Unknown));
}
}
await dbContext.SaveChangesAsync();
return new BulkAddProjectMembersResult(AddedMembers, CreatedMembers, ExistingMembers);
Expand Down
60 changes: 60 additions & 0 deletions backend/LexBoxApi/GraphQL/UserMutations.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
using LexBoxApi.GraphQL.CustomTypes;
using LexBoxApi.Models.Project;
using LexBoxApi.Otel;
using LexBoxApi.Services;
using LexCore;
using LexCore.Auth;
using LexCore.Entities;
using LexCore.Exceptions;
using LexCore.ServiceInterfaces;
using LexData;
using LexData.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
Expand All @@ -23,6 +27,13 @@ public record ChangeUserAccountBySelfInput(Guid UserId, string? Email, string Na
: ChangeUserAccountDataInput(UserId, Email, Name);
public record ChangeUserAccountByAdminInput(Guid UserId, string? Email, string Name, UserRole Role)
: ChangeUserAccountDataInput(UserId, Email, Name);
public record CreateGuestUserByAdminInput(
string? Email,
string Name,
string? Username,
string Locale,
string PasswordHash,
int PasswordStrength);

[Error<NotFoundException>]
[Error<DbError>]
Expand Down Expand Up @@ -63,6 +74,55 @@ EmailService emailService
return UpdateUser(loggedInContext, permissionService, input, dbContext, emailService);
}

[Error<NotFoundException>]
[Error<DbError>]
[Error<UniqueValueException>]
[Error<RequiredException>]
[AdminRequired]
public async Task<LexAuthUser> CreateGuestUserByAdmin(
LoggedInContext loggedInContext,
CreateGuestUserByAdminInput input,
LexBoxDbContext dbContext,
EmailService emailService
)
{
using var createGuestUserActivity = LexBoxActivitySource.Get().StartActivity("CreateGuestUser");

var hasExistingUser = input.Email is null && input.Username is null
? throw new RequiredException("Guest users must have either an email or a username")
: await dbContext.Users.FilterByEmailOrUsername(input.Email ?? input.Username!).AnyAsync();
createGuestUserActivity?.AddTag("app.email_available", !hasExistingUser);
if (hasExistingUser) throw new UniqueValueException("Email");

var admin = loggedInContext.User;

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();
if (!string.IsNullOrEmpty(input.Email))
{
await emailService.SendVerifyAddressEmail(userEntity);
}
return new LexAuthUser(userEntity);
}

private static async Task<User> UpdateUser(
LoggedInContext loggedInContext,
IPermissionService permissionService,
Expand Down
2 changes: 1 addition & 1 deletion backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace LexBoxApi.Models.Project;

public record AddProjectMemberInput(Guid ProjectId, string UsernameOrEmail, ProjectRole Role);

public record BulkAddProjectMembersInput(Guid ProjectId, string[] Usernames, ProjectRole Role, string PasswordHash);
public record BulkAddProjectMembersInput(Guid? ProjectId, string[] Usernames, ProjectRole Role, string PasswordHash);

public record ChangeProjectMemberRoleInput(Guid ProjectId, Guid UserId, ProjectRole Role);
2 changes: 1 addition & 1 deletion backend/LexBoxApi/Services/EmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public async Task SendCreateAccountEmail(string emailAddress,
var httpContext = httpContextAccessor.HttpContext;
ArgumentNullException.ThrowIfNull(httpContext);
var queryString = QueryString.Create("email", emailAddress);
var returnTo = new UriBuilder() { Path = "/register", Query = queryString.Value }.Uri.PathAndQuery;
var returnTo = new UriBuilder() { Path = "/acceptInvitation", Query = queryString.Value }.Uri.PathAndQuery;
var registerLink = _linkGenerator.GetUriByAction(httpContext,
"LoginRedirect",
"Login",
Expand Down
19 changes: 18 additions & 1 deletion frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ type CollectionSegmentInfo {
hasPreviousPage: Boolean!
}

type CreateGuestUserByAdminPayload {
lexAuthUser: LexAuthUser
errors: [CreateGuestUserByAdminError!]
}

type CreateOrganizationPayload {
organization: Organization
errors: [CreateOrganizationError!]
Expand Down Expand Up @@ -185,6 +190,7 @@ type Mutation {
softDeleteProject(input: SoftDeleteProjectInput!): SoftDeleteProjectPayload!
changeUserAccountBySelf(input: ChangeUserAccountBySelfInput!): ChangeUserAccountBySelfPayload!
changeUserAccountByAdmin(input: ChangeUserAccountByAdminInput!): ChangeUserAccountByAdminPayload! @authorize(policy: "AdminRequiredPolicy")
createGuestUserByAdmin(input: CreateGuestUserByAdminInput!): CreateGuestUserByAdminPayload! @authorize(policy: "AdminRequiredPolicy")
deleteUserByAdminOrSelf(input: DeleteUserByAdminOrSelfInput!): DeleteUserByAdminOrSelfPayload!
setUserLocked(input: SetUserLockedInput!): SetUserLockedPayload! @authorize(policy: "AdminRequiredPolicy")
}
Expand Down Expand Up @@ -363,6 +369,8 @@ union ChangeUserAccountByAdminError = NotFoundError | DbError | UniqueValueError

union ChangeUserAccountBySelfError = NotFoundError | DbError | UniqueValueError

union CreateGuestUserByAdminError = NotFoundError | DbError | UniqueValueError | RequiredError

union CreateOrganizationError = DbError

union CreateProjectError = DbError | AlreadyExistsError | ProjectCreatorsMustHaveEmail
Expand Down Expand Up @@ -393,7 +401,7 @@ input BooleanOperationFilterInput {
}

input BulkAddProjectMembersInput {
projectId: UUID!
projectId: UUID
usernames: [String!]!
role: ProjectRole!
passwordHash: String!
Expand Down Expand Up @@ -429,6 +437,15 @@ input ChangeUserAccountBySelfInput {
name: String!
}

input CreateGuestUserByAdminInput {
email: String
name: String!
username: String
locale: String!
passwordHash: String!
passwordStrength: Int!
}

input CreateOrganizationInput {
name: String!
}
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/lib/app.postcss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
@tailwind components;
@tailwind utilities;

@layer base {
:root {
--alert-link-color: #4100ff;
}

@media (prefers-color-scheme: dark) {
:root {
--alert-link-color: #4dd0ff;
}
}
}

html,
body,
.drawer-side,
Expand Down Expand Up @@ -152,7 +164,7 @@ input[readonly]:focus {
}

.alert a:not(.btn) {
color: #0024b9;
color: var(--alert-link-color, #0024b9);
}

.collapse input:hover ~ .collapse-title {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/components/Projects/ProjectFilter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@
</div>
{:else}
<div class="alert alert-info gap-2">
<span class="i-mdi-info-outline text-xl"></span>
<div class="flex_ items-center gap-2">
<Icon icon="i-mdi-info-outline" size="text-2xl" />
<div>
<span class="mr-1">{$t('project.filter.select_user_from_table')}</span>
<span class="btn btn-sm btn-square pointer-events-none">
<span class="i-mdi-dots-vertical"></span>
Expand Down
Loading

0 comments on commit 5c40148

Please sign in to comment.