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

Add "Create User" button to admin dashboard #736

Merged
merged 45 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a3aed11
Add "Create User" button to admin dashboard
rmunn Apr 18, 2024
8d71023
Extract register page into component
rmunn Apr 18, 2024
02bc9e3
Use register page in create-user modal for admins
rmunn Apr 18, 2024
8536fd2
Don't auto-login user if created by admin
rmunn Apr 18, 2024
5d72b13
Hide create-user modal when submit button clicked
rmunn Apr 18, 2024
8a6a9e3
Merge branch 'develop' into feat/admins-can-create-users
rmunn May 13, 2024
cc86680
Add helpful tips to top of create-user modal
rmunn May 13, 2024
2606313
Don't change browser title in modal
rmunn May 13, 2024
b9e0b8e
De-indent CreateUser component HTML
rmunn May 13, 2024
4b0d557
Admins now create guest users
rmunn May 13, 2024
a3e2e5d
Set up Create User modal help for translation
rmunn May 13, 2024
cf11cd7
Remove now-redundant "how to create uesrs" link
rmunn May 13, 2024
efd1699
Give Create User modal a proper title
rmunn May 13, 2024
77c5853
Fix submit button text in Create User modal
rmunn May 13, 2024
0499c4d
Fix lint errors
rmunn May 14, 2024
974743a
Improve alert-link contrast
myieye May 14, 2024
908ecb3
Adapt wording and style it
myieye May 14, 2024
33c1a39
Merge remote-tracking branch 'origin/develop' into feat/admins-can-cr…
myieye May 14, 2024
f9d2be7
Only add jwt-token projects to invited users
myieye May 14, 2024
e21373d
Add GQL mutation for creating guest users
rmunn May 16, 2024
1e1bf8d
Add flow for accepting invitation email
rmunn May 16, 2024
4c7da1d
Refactor some common code in user controller
rmunn May 16, 2024
f19c43a
Require registerAccount flow to be unauthenticated
rmunn May 16, 2024
04cc4eb
Changed my mind about unauth in register flow
rmunn May 16, 2024
327e863
Prep bulk add for being used with no project ID
rmunn May 17, 2024
9a3eb02
Make refactored method private
rmunn May 17, 2024
2b6c2b8
Update API endpoints for register and invite flows
rmunn May 17, 2024
f35f8d0
Another change for optional proj ID in bulk add GQL
rmunn May 17, 2024
5cd69c2
Remove autoLogin from backend API
rmunn May 17, 2024
6b19e6c
CreateUser UI now optionally allows usernames
rmunn May 17, 2024
8c5d0b0
Validate usernames in CreateUser form
rmunn May 20, 2024
57b5ab3
Remove redundant username check in CreateUser
rmunn May 20, 2024
adebdc6
Prettiness
rmunn May 20, 2024
1e3cb44
Remove completed TODO
rmunn May 20, 2024
b7f04bc
Move onSubmit handler to CreateUser callers
rmunn May 20, 2024
328c3bc
Make name a required field for CreateUser GQL
rmunn May 20, 2024
0b9fd48
Skip turnstile token in admin dashboard
rmunn May 20, 2024
b385c69
Fix lint error
rmunn May 20, 2024
50fe0ce
Switch endpoint prop in CreateUser for function
rmunn May 20, 2024
57b4571
Passsword strength now required in CreateGuestUser GQL
rmunn May 20, 2024
024113c
Address review comments
rmunn May 22, 2024
e563e4a
Make RegisterResponse error properties optional
rmunn May 22, 2024
f027c6b
Address review comments about error message
rmunn May 23, 2024
d6564f0
Merge remote-tracking branch 'origin/develop' into feat/admins-can-cr…
rmunn May 23, 2024
f2d011a
Fix and organize email/username validation messages
myieye May 23, 2024
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
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.
myieye marked this conversation as resolved.
Show resolved Hide resolved
ExistingMembers.Add(new UserProjectRole(user.Username ?? user.Email!, ProjectRole.Unknown));
}
}
await dbContext.SaveChangesAsync();
return new BulkAddProjectMembersResult(AddedMembers, CreatedMembers, ExistingMembers);
Expand Down
55 changes: 55 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,50 @@ 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
)
{
using var createGuestUserActivity = LexBoxActivitySource.Get().StartActivity("CreateGuestUser");

var hasExistingUser = input.Email is null && input.Username is null
rmunn marked this conversation as resolved.
Show resolved Hide resolved
? 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();
rmunn marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -179,6 +184,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 @@ -357,6 +363,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 @@ -385,7 +393,7 @@ input BooleanOperationFilterInput {
}

input BulkAddProjectMembersInput {
projectId: UUID!
projectId: UUID
usernames: [String!]!
role: ProjectRole!
passwordHash: String!
Expand Down Expand Up @@ -421,6 +429,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
Loading