diff --git a/backend/LexBoxApi/Controllers/UserController.cs b/backend/LexBoxApi/Controllers/UserController.cs index 01fc14a95..92b6c7020 100644 --- a/backend/LexBoxApi/Controllers/UserController.cs +++ b/backend/LexBoxApi/Controllers/UserController.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.Models; using LexBoxApi.Otel; using LexBoxApi.Services; @@ -65,27 +66,51 @@ public async Task> 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))] + [ProducesDefaultResponseType] + public async Task> 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(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(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(); } @@ -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)] diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index 2aea2fe0f..9f4c87bd0 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -122,8 +122,11 @@ public async Task 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 AddedMembers = []; List CreatedMembers = []; List ExistingMembers = []; @@ -154,10 +157,13 @@ public async Task 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) @@ -168,9 +174,14 @@ public async Task 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); diff --git a/backend/LexBoxApi/GraphQL/UserMutations.cs b/backend/LexBoxApi/GraphQL/UserMutations.cs index faea9fc8b..aff7f6108 100644 --- a/backend/LexBoxApi/GraphQL/UserMutations.cs +++ b/backend/LexBoxApi/GraphQL/UserMutations.cs @@ -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; @@ -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] [Error] @@ -63,6 +74,55 @@ EmailService emailService return UpdateUser(loggedInContext, permissionService, input, dbContext, emailService); } + [Error] + [Error] + [Error] + [Error] + [AdminRequired] + public async Task 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 UpdateUser( LoggedInContext loggedInContext, IPermissionService permissionService, diff --git a/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs b/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs index 2bcdd5a19..13a787fba 100644 --- a/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs +++ b/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs @@ -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); diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index b866dcd22..af8c46a93 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -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", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index ad6c28db1..ce9aa959f 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -77,6 +77,11 @@ type CollectionSegmentInfo { hasPreviousPage: Boolean! } +type CreateGuestUserByAdminPayload { + lexAuthUser: LexAuthUser + errors: [CreateGuestUserByAdminError!] +} + type CreateOrganizationPayload { organization: Organization errors: [CreateOrganizationError!] @@ -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") } @@ -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 @@ -393,7 +401,7 @@ input BooleanOperationFilterInput { } input BulkAddProjectMembersInput { - projectId: UUID! + projectId: UUID usernames: [String!]! role: ProjectRole! passwordHash: String! @@ -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! } diff --git a/frontend/src/lib/app.postcss b/frontend/src/lib/app.postcss index 1ff4078b7..7d170db1f 100644 --- a/frontend/src/lib/app.postcss +++ b/frontend/src/lib/app.postcss @@ -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, @@ -152,7 +164,7 @@ input[readonly]:focus { } .alert a:not(.btn) { - color: #0024b9; + color: var(--alert-link-color, #0024b9); } .collapse input:hover ~ .collapse-title { diff --git a/frontend/src/lib/components/Projects/ProjectFilter.svelte b/frontend/src/lib/components/Projects/ProjectFilter.svelte index 04ff9b8c6..a208813e5 100644 --- a/frontend/src/lib/components/Projects/ProjectFilter.svelte +++ b/frontend/src/lib/components/Projects/ProjectFilter.svelte @@ -118,8 +118,8 @@ {:else}
- -
+ +
{$t('project.filter.select_user_from_table')} diff --git a/frontend/src/lib/components/Users/CreateUser.svelte b/frontend/src/lib/components/Users/CreateUser.svelte new file mode 100644 index 000000000..9009bc3e0 --- /dev/null +++ b/frontend/src/lib/components/Users/CreateUser.svelte @@ -0,0 +1,104 @@ + + + + + + + + + + {submitButtonText} + + + diff --git a/frontend/src/lib/components/Users/CreateUserModal.svelte b/frontend/src/lib/components/Users/CreateUserModal.svelte new file mode 100644 index 000000000..6c8e5fde4 --- /dev/null +++ b/frontend/src/lib/components/Users/CreateUserModal.svelte @@ -0,0 +1,41 @@ + + + +
+ +
+

{$t('common.did_you_know')}

+
+ + +
+
+
+

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

+ createUserModal.submitModal()} + submitButtonText={$t('admin_dashboard.create_user_modal.create_user')} + /> +
diff --git a/frontend/src/lib/gql/gql-client.ts b/frontend/src/lib/gql/gql-client.ts index 1ac423236..124743f60 100644 --- a/frontend/src/lib/gql/gql-client.ts +++ b/frontend/src/lib/gql/gql-client.ts @@ -62,7 +62,9 @@ function createGqlClient(_gqlEndpoint?: string): Client { cache.invalidate({__typename: 'User', id: args.input.userId}); }, bulkAddProjectMembers: (result, args: BulkAddProjectMembersMutationVariables, cache, _info) => { - cache.invalidate({__typename: 'Project', id: args.input.projectId}); + if (args.input.projectId) { + cache.invalidate({__typename: 'Project', id: args.input.projectId}); + } }, leaveProject: (result, args: LeaveProjectMutationVariables, cache, _info) => { cache.invalidate({__typename: 'Project', id: args.input.projectId}); diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index df4791617..70a88d5a3 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -39,6 +39,11 @@ } } }, + "create_user_modal": { + "create_user": "Create User", + "help_create_single_guest_user": "You can invite users to register and join a project by themselves with the **Add Project Member** button. See [Add Project Member]({helpLink}) for details.", + "help_create_bulk_guest_users": "You can also create and add users to a project in bulk. Look for the **Bulk Add/Create Members** button or see [Bulk Add/Create Project Members]({helpLink}) for details.", + }, "user_details_modal": { "registered": "Registered", "locked": "Locked", @@ -220,7 +225,7 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "shared_password_description": "For new users", "usernames": "Logins or emails (one per line)", "usernames_description": "This will be the **Send/Receive login** for new users", - "invalid_username": "Invalid login/username: {username}. Can only use letters, numbers, and underscore (_) characters.", + "invalid_username": "Invalid login/username: {username}. Only letters, numbers, and underscore (_) characters are allowed.", "empty_user_field": "Please enter email addresses and/or logins", "creator_must_have_email": "You must have an email address in order to create a project.", "members_added": "{addedCount} new {addedCount, plural, one {member was} other {members were}} added to project.", @@ -384,13 +389,18 @@ If you don't see a dialog or already closed it, click the button below:", "register": { "title": "Register", "account_exists": "An account with this email already exists", + "invalid_username": "Invalid login/username. Only letters, numbers, and underscore (_) characters are allowed.", "button_register": "Register", "label_email": "Email", + "label_email_or_username": "Email or login/username", "description_email": "This will be your **Send/Receive login**", "label_name": "Name", "label_password": "Password", "name_missing": "Name missing", }, + "accept_invitation": { + "title": "Accept Invitation", + }, "reset_password": { "title": "Reset Password", "new_password": "New Password", @@ -522,5 +532,6 @@ If you don't see a dialog or already closed it, click the button below:", "or": "Or", "any": "Any", "yes_no": "{value, select, true {Yes} false {No} other {Unknown}}", + "did_you_know": "Did you know?", } } diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index b18821510..c747938da 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -5,7 +5,8 @@ import { deleteCookie, getCookie } from './util/cookies' import {hash} from '$lib/util/hash'; import { ensureErrorIsTraced, errorSourceTag } from './otel' import zxcvbn from 'zxcvbn'; -import { type AuthUserProject, ProjectRole, UserRole } from './gql/types'; +import { type AuthUserProject, ProjectRole, UserRole, type CreateGuestUserByAdminInput } from './gql/types'; +import { _createGuestUserByAdmin } from '../routes/(authenticated)/admin/+page'; type LoginError = 'BadCredentials' | 'Locked'; type LoginResult = { @@ -18,6 +19,7 @@ type RegisterResponseErrors = { /* eslint-disable @typescript-eslint/naming-convention */ TurnstileToken?: unknown, Email?: unknown, + Required?: unknown, // RequiredException is thrown if GQL input is invalid, e.g. missing both email *and* username for CreateUser /* eslint-enable @typescript-eslint/naming-convention */ } } @@ -55,6 +57,8 @@ export type LexAuthUser = { export const USER_LOAD_KEY = 'user:current'; export const AUTH_COOKIE_NAME = '.LexBoxAuth'; +export const usernameRe = /^[a-zA-Z0-9_]+$/; + export function getHomePath(user: LexAuthUser | null): string { return user?.isAdmin ? '/admin' : '/'; } @@ -81,9 +85,9 @@ export async function login(userId: string, password: string): Promise { - const response = await fetch('/api/User/registerAccount', { +export type RegisterResponse = { error?: { turnstile?: boolean, accountExists?: boolean, invalidInput?: boolean }, user?: LexAuthUser }; +export async function createUser(endpoint: string, password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string): Promise { + const response = await fetch(endpoint, { method: 'post', headers: { 'content-type': 'application/json', @@ -101,13 +105,55 @@ export async function register(password: string, passwordStrength: number, name: if (!response.ok) { const { errors } = await response.json() as RegisterResponseErrors; if (!errors) throw new Error('Missing error on non-ok response'); - return { error: { turnstile: 'TurnstileToken' in errors, accountExists: 'Email' in errors } }; + return { error: { turnstile: 'TurnstileToken' in errors, accountExists: 'Email' in errors, invalidInput: 'Required' in errors } }; } const responseJson = await response.json() as JwtTokenUser; const userJson: LexAuthUser = jwtToUser(responseJson); return { user: userJson }; } +export function register(password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string): Promise { + return createUser('/api/User/registerAccount', password, passwordStrength, name, email, locale, turnstileToken); +} +export function acceptInvitation(password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string): Promise { + return createUser('/api/User/acceptInvitation', password, passwordStrength, name, email, locale, turnstileToken); +} +export async function createGuestUserByAdmin(password: string, passwordStrength: number, name: string, email: string, locale: string, _turnstileToken: string): Promise { + const passwordHash = await hash(password); + const gqlInput: CreateGuestUserByAdminInput = { + passwordHash, + passwordStrength, + name, + locale, + }; + if (email.includes('@')) { + gqlInput.email = email; + } else { + gqlInput.username = email; + } + const gqlResponse = await _createGuestUserByAdmin(gqlInput); + if (gqlResponse.error?.byType('UniqueValueError')) { + return { error: { accountExists: true }}; + } + if (gqlResponse.error?.byType('RequiredError')) { + return { error: { invalidInput: true }}; + } + if (!gqlResponse.data?.createGuestUserByAdmin.lexAuthUser ) { + return { error: { invalidInput: true }}; + } + const responseUser = gqlResponse.data?.createGuestUserByAdmin.lexAuthUser; + const user: LexAuthUser = { + ...responseUser, + email: responseUser.email ?? undefined, + username: responseUser.username ?? undefined, + locked: responseUser.locked ?? false, + emailVerified: responseUser.emailVerificationRequired ?? false, + canCreateProjects: responseUser.canCreateProjects ?? false, + createdByAdmin: responseUser.createdByAdmin ?? false, + emailOrUsername: (responseUser.email ?? responseUser.username) as string, + } + return { user } +} export function getUser(cookies: Cookies): LexAuthUser | null { const token = getCookie(AUTH_COOKIE_NAME, cookies); diff --git a/frontend/src/routes/(authenticated)/admin/+page.svelte b/frontend/src/routes/(authenticated)/admin/+page.svelte index f5a02bc2c..5227fdc2b 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.svelte +++ b/frontend/src/routes/(authenticated)/admin/+page.svelte @@ -21,8 +21,9 @@ import { Button } from '$lib/forms'; import { PageBreadcrumb } from '$lib/layout'; import AdminTabs, { type AdminTabId } from './AdminTabs.svelte'; + import { createGuestUserByAdmin } from '$lib/user'; + import CreateUserModal from '$lib/components/Users/CreateUserModal.svelte'; import type { Confidentiality } from '$lib/components/Projects'; - import { helpLinks } from '$lib/components/help'; export let data: PageData; $: projects = data.projects; @@ -70,6 +71,7 @@ } let userModal: UserModal; + let createUserModal: CreateUserModal; let deleteUserModal: DeleteUserModal; let formModal: EditUserAccount; @@ -125,14 +127,13 @@
- +
@@ -246,4 +247,5 @@ + diff --git a/frontend/src/routes/(authenticated)/admin/+page.ts b/frontend/src/routes/(authenticated)/admin/+page.ts index e68f8c052..5a2ea2f7f 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.ts +++ b/frontend/src/routes/(authenticated)/admin/+page.ts @@ -9,6 +9,8 @@ import type { $OpResult, ChangeUserAccountByAdminInput, ChangeUserAccountByAdminMutation, + CreateGuestUserByAdminInput, + CreateGuestUserByAdminMutation, DraftProjectFilterInput, ProjectFilterInput, SetUserLockedInput, @@ -161,6 +163,44 @@ export async function _changeUserAccountByAdmin(input: ChangeUserAccountByAdminI return result; } +export async function _createGuestUserByAdmin(input: CreateGuestUserByAdminInput): $OpResult { + //language=GraphQL + const result = await getClient() + .mutation( + graphql(` + mutation CreateGuestUserByAdmin($input: CreateGuestUserByAdminInput!) { + createGuestUserByAdmin(input: $input) { + lexAuthUser { + id + name + email + username + role + isAdmin + locked + emailVerificationRequired + canCreateProjects + createdByAdmin + locale + projects { + projectId + role + } + } + errors { + __typename + ... on Error { + message + } + } + } + } + `), + { input: input } + ) + return result; +} + export async function _setUserLocked(input: SetUserLockedInput): $OpResult { //language=GraphQL const result = await getClient() diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte index e35f85906..3355eb3ac 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte @@ -13,6 +13,7 @@ import { distinct } from '$lib/util/array'; import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte'; import { SupHelp, helpLinks } from '$lib/components/help'; + import { usernameRe } from '$lib/user'; enum BulkAddSteps { Add, @@ -35,8 +36,6 @@ let existingMembers: BulkAddProjectMembersResult['existingMembers'] = []; $: addedCount = addedMembers.length + createdMembers.length; - const usernameRe = /^[a-zA-Z0-9_]+$/; - function validateBulkAddInput(usernames: string[]): FormSubmitReturn { if (usernames.length === 0) return { usernamesText: [$t('project_page.bulk_add_members.empty_user_field')] }; diff --git a/frontend/src/routes/(unauthenticated)/acceptInvitation/+page.svelte b/frontend/src/routes/(unauthenticated)/acceptInvitation/+page.svelte new file mode 100644 index 000000000..dd8af1c28 --- /dev/null +++ b/frontend/src/routes/(unauthenticated)/acceptInvitation/+page.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/routes/(unauthenticated)/register/+page.svelte b/frontend/src/routes/(unauthenticated)/register/+page.svelte index 2ec6ba42a..8213ed750 100644 --- a/frontend/src/routes/(unauthenticated)/register/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/register/+page.svelte @@ -1,89 +1,15 @@ - - - - - - - - {$t('register.button_register')} - + - - diff --git a/frontend/tests/emailWorkflow.test.ts b/frontend/tests/emailWorkflow.test.ts index 934aa4700..1661db416 100644 --- a/frontend/tests/emailWorkflow.test.ts +++ b/frontend/tests/emailWorkflow.test.ts @@ -4,7 +4,7 @@ import { deleteUser, getCurrentUserId, loginAs, logout } from './utils/authHelpe import { AdminDashboardPage } from './pages/adminDashboardPage'; import { EmailSubjects } from './pages/mailPages'; import { LoginPage } from './pages/loginPage'; -import { RegisterPage } from './pages/registerPage'; +import { AcceptInvitationPage } from './pages/acceptInvitationPage'; import { ResetPasswordPage } from './pages/resetPasswordPage'; import { UserAccountSettingsPage } from './pages/userAccountSettingsPage'; import { UserDashboardPage } from './pages/userDashboardPage'; @@ -134,7 +134,7 @@ test('register via new-user invitation email', async ({ page }) => { const emailPage = await inboxPage.openEmail(EmailSubjects.ProjectInvitation); const invitationUrl = await emailPage.getFirstLanguageDepotUrl(); expect(invitationUrl).not.toBeNull(); - expect(invitationUrl!).toContain('register'); + expect(invitationUrl!).toContain('acceptInvitation'); expect(invitationUrl!).toContain('returnTo='); expect(invitationUrl!).not.toContain('returnTo=http'); @@ -142,11 +142,11 @@ test('register via new-user invitation email', async ({ page }) => { const pagePromise = emailPage.page.context().waitForEvent('page'); await emailPage.clickFirstLanguageDepotUrl(); const newPage = await pagePromise; - const registerPage = await new RegisterPage(newPage).waitFor(); + const acceptPage = await new AcceptInvitationPage(newPage).waitFor(); await expect(newPage.getByLabel('Email')).toHaveValue(newEmail); - await registerPage.fillForm(`Test user ${uuid}`, newEmail, defaultPassword); + await acceptPage.fillForm(`Test user ${uuid}`, defaultPassword); - await registerPage.submit(); + await acceptPage.submit(); const userDashboardPage = await new UserDashboardPage(newPage).waitFor(); // Register current user ID to be cleaned up even if test fails later on diff --git a/frontend/tests/pages/acceptInvitationPage.ts b/frontend/tests/pages/acceptInvitationPage.ts new file mode 100644 index 000000000..f28f300bd --- /dev/null +++ b/frontend/tests/pages/acceptInvitationPage.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; +import { BasePage } from './basePage'; + +export class AcceptInvitationPage extends BasePage { + constructor(page: Page) { + super(page, page.getByRole('heading', {name: 'Accept Invitation'}), `/acceptInvitation`); + } + + async fillForm(name: string, password: string, email?: string): Promise { + await this.page.getByLabel('Name').fill(name); + await this.page.getByLabel('Password').fill(password); + if (email) await this.page.getByLabel('Email').fill(email); + } + + async submit(): Promise { + await this.page.getByRole('button', {name: 'Register'}).click(); + } +}