diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs index c9fe6b54d..102064c78 100644 --- a/backend/LexBoxApi/GraphQL/LexQueries.cs +++ b/backend/LexBoxApi/GraphQL/LexQueries.cs @@ -54,6 +54,50 @@ public IQueryable DraftProjects(LexBoxDbContext context) return context.DraftProjects; } + public record ProjectsByLangCodeAndOrgInput(Guid OrgId, string LangCode); + [UseProjection] + [UseSorting] + public IQueryable ProjectsByLangCodeAndOrg(LoggedInContext loggedInContext, LexBoxDbContext context, IPermissionService permissionService, ProjectsByLangCodeAndOrgInput input) + { + if (!loggedInContext.User.IsAdmin && !permissionService.IsOrgMember(input.OrgId)) throw new UnauthorizedAccessException(); + // Convert 3-letter code to 2-letter code if relevant, otherwise leave as-is + var langCode = Services.LangTagConstants.ThreeToTwo.GetValueOrDefault(input.LangCode, input.LangCode); + var query = context.Projects.Where(p => + p.Organizations.Any(o => o.Id == input.OrgId) && + p.FlexProjectMetadata != null && + p.FlexProjectMetadata.WritingSystems != null && + p.FlexProjectMetadata.WritingSystems.VernacularWss.Any(ws => + ws.IsActive && ( + ws.Tag == langCode || + ws.Tag == $"qaa-x-{langCode}" || + ws.Tag.StartsWith($"{langCode}-") + ) + ) + ); + // Org admins can see all projects, everyone else can only see non-confidential + if (!permissionService.CanEditOrg(input.OrgId)) + { + query = query.Where(p => p.IsConfidential == false); + } + return query; + } + + public record ProjectsInMyOrgInput(Guid OrgId); + [UseProjection] + [UseFiltering] + [UseSorting] + public IQueryable ProjectsInMyOrg(LoggedInContext loggedInContext, LexBoxDbContext context, IPermissionService permissionService, ProjectsInMyOrgInput input) + { + if (!loggedInContext.User.IsAdmin && !permissionService.IsOrgMember(input.OrgId)) throw new UnauthorizedAccessException(); + var query = context.Projects.Where(p => p.Organizations.Any(o => o.Id == input.OrgId)); + // Org admins can see all projects, everyone else can only see non-confidential + if (!permissionService.CanEditOrg(input.OrgId)) + { + query = query.Where(p => p.IsConfidential == false); + } + return query; + } + [UseSingleOrDefault] [UseProjection] public async Task> ProjectById(LexBoxDbContext context, IPermissionService permissionService, Guid projectId) diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index f3e007c8d..3fadb8bfe 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -236,6 +236,42 @@ await dbContext.ProjectUsers return dbContext.ProjectUsers.Where(u => u.Id == projectUser.Id); } + [Error] + [Error] + [Error] + [Error] + [UseMutationConvention] + [UseFirstOrDefault] + [UseProjection] + public async Task> AskToJoinProject( + IPermissionService permissionService, + LoggedInContext loggedInContext, + Guid projectId, + LexBoxDbContext dbContext, + [Service] IEmailService emailService) + { + await permissionService.AssertCanAskToJoinProject(projectId); + + var user = await dbContext.Users.FindAsync(loggedInContext.User.Id); + if (user is null) throw new UnauthorizedAccessException(); + user.AssertHasVerifiedEmailForRole(ProjectRole.Editor); + + var project = await dbContext.Projects + .Include(p => p.Users) + .ThenInclude(u => u.User) + .Where(p => p.Id == projectId) + .FirstOrDefaultAsync(); + NotFoundException.ThrowIfNull(project); + + var managers = project.Users.Where(u => u.Role == ProjectRole.Manager); + foreach (var manager in managers) + { + if (manager.User is null) continue; + await emailService.SendJoinProjectRequestEmail(manager.User, user, project); + } + return dbContext.Projects.Where(p => p.Id == projectId); + } + [Error] [Error] [Error] diff --git a/backend/LexBoxApi/Services/Email/EmailTemplates.cs b/backend/LexBoxApi/Services/Email/EmailTemplates.cs index ea06c66a3..8f58910a3 100644 --- a/backend/LexBoxApi/Services/Email/EmailTemplates.cs +++ b/backend/LexBoxApi/Services/Email/EmailTemplates.cs @@ -19,6 +19,7 @@ public enum EmailTemplate PasswordChanged, CreateAccountRequestProject, CreateAccountRequestOrg, + JoinProjectRequest, CreateProjectRequest, ApproveProjectRequest, UserAdded, @@ -35,6 +36,7 @@ public record OrgInviteEmail(string Email, string ManagerName, string OrgName, s public record PasswordChangedEmail(string Name) : EmailTemplateBase(EmailTemplate.PasswordChanged); +public record JoinProjectRequestEmail(string ManagerName, string RequestingUserName, Guid requestingUserId, string ProjectCode, string ProjectName) : EmailTemplateBase(EmailTemplate.JoinProjectRequest); public record CreateProjectRequestUser(string Name, string Email); public record CreateProjectRequestEmail(string Name, CreateProjectRequestUser User, CreateProjectInput Project) : EmailTemplateBase(EmailTemplate.CreateProjectRequest); public record ApproveProjectRequestEmail(string Name, CreateProjectRequestUser User, CreateProjectInput Project) : EmailTemplateBase(EmailTemplate.ApproveProjectRequest); diff --git a/backend/LexBoxApi/Services/Email/IEmailService.cs b/backend/LexBoxApi/Services/Email/IEmailService.cs index 670555c8b..ba7942a87 100644 --- a/backend/LexBoxApi/Services/Email/IEmailService.cs +++ b/backend/LexBoxApi/Services/Email/IEmailService.cs @@ -53,6 +53,7 @@ public Task SendCreateAccountWithProjectEmail( public Task SendPasswordChangedEmail(User user); + public Task SendJoinProjectRequestEmail(User projectManagers, User requestingUser, Project project); public Task SendCreateProjectRequestEmail(LexAuthUser user, CreateProjectInput projectInput); public Task SendApproveProjectRequestEmail(User user, CreateProjectInput projectInput); public Task SendUserAddedEmail(User user, string projectName, string projectCode); diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index 98e9d92d0..b1a11a77c 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -192,6 +192,13 @@ public async Task SendPasswordChangedEmail(User user) await SendEmailWithRetriesAsync(email); } + public async Task SendJoinProjectRequestEmail(User projectManager, User requestingUser, Project project) + { + var email = StartUserEmail(projectManager) ?? throw new ArgumentNullException("emailAddress"); + await RenderEmail(email, new JoinProjectRequestEmail(projectManager.Name, requestingUser.Name, requestingUser.Id, project.Code, project.Name), projectManager.LocalizationCode); + await SendEmailWithRetriesAsync(email); + } + public async Task SendCreateProjectRequestEmail(LexAuthUser user, CreateProjectInput projectInput) { var email = new MimeMessage(); diff --git a/backend/LexBoxApi/Services/LangTagConstants.cs b/backend/LexBoxApi/Services/LangTagConstants.cs new file mode 100644 index 000000000..ccc1085bd --- /dev/null +++ b/backend/LexBoxApi/Services/LangTagConstants.cs @@ -0,0 +1,378 @@ +namespace LexBoxApi.Services; + +public static class LangTagConstants +{ + public static readonly Dictionary TwoToThree = new() + { + // Extracted from langtags.json + { "aa", "aar" }, + { "ab", "abk" }, + { "ae", "ave" }, + { "af", "afr" }, + { "ak", "aka" }, + { "am", "amh" }, + { "an", "arg" }, + { "ar", "ara" }, + { "as", "asm" }, + { "av", "ava" }, + { "ay", "aym" }, + { "az", "aze" }, + { "ba", "bak" }, + { "be", "bel" }, + { "bg", "bul" }, + { "bi", "bis" }, + { "bm", "bam" }, + { "bn", "ben" }, + { "bo", "bod" }, + { "br", "bre" }, + { "bs", "bos" }, + { "ca", "cat" }, + { "ce", "che" }, + { "ch", "cha" }, + { "co", "cos" }, + { "cr", "cre" }, + { "cs", "ces" }, + { "cu", "chu" }, + { "cv", "chv" }, + { "cy", "cym" }, + { "da", "dan" }, + { "de", "deu" }, + { "dv", "div" }, + { "dz", "dzo" }, + { "ee", "ewe" }, + { "el", "ell" }, + { "en", "eng" }, + { "eo", "epo" }, + { "es", "spa" }, + { "et", "est" }, + { "eu", "eus" }, + { "fa", "fas" }, + { "ff", "ful" }, + { "fi", "fin" }, + { "fj", "fij" }, + { "fo", "fao" }, + { "fr", "fra" }, + { "fy", "fry" }, + { "ga", "gle" }, + { "gd", "gla" }, + { "gl", "glg" }, + { "gn", "grn" }, + { "gu", "guj" }, + { "gv", "glv" }, + { "ha", "hau" }, + { "he", "heb" }, + { "hi", "hin" }, + { "ho", "hmo" }, + { "hr", "hrv" }, + { "ht", "hat" }, + { "hu", "hun" }, + { "hy", "hye" }, + { "hz", "her" }, + { "ia", "ina" }, + { "id", "ind" }, + { "ie", "ile" }, + { "ig", "ibo" }, + { "ii", "iii" }, + { "ik", "ipk" }, + { "io", "ido" }, + { "is", "isl" }, + { "it", "ita" }, + { "iu", "iku" }, + { "ja", "jpn" }, + { "jv", "jav" }, + { "ka", "kat" }, + { "kg", "kon" }, + { "ki", "kik" }, + { "kj", "kua" }, + { "kk", "kaz" }, + { "kl", "kal" }, + { "km", "khm" }, + { "kn", "kan" }, + { "ko", "kor" }, + { "kr", "kau" }, + { "ks", "kas" }, + { "ku", "kur" }, + { "kv", "kom" }, + { "kw", "cor" }, + { "ky", "kir" }, + { "la", "lat" }, + { "lb", "ltz" }, + { "lg", "lug" }, + { "li", "lim" }, + { "ln", "lin" }, + { "lo", "lao" }, + { "lt", "lit" }, + { "lu", "lub" }, + { "lv", "lav" }, + { "mg", "mlg" }, + { "mh", "mah" }, + { "mi", "mri" }, + { "mk", "mkd" }, + { "ml", "mal" }, + { "mn", "mon" }, + { "mr", "mar" }, + { "ms", "msa" }, + { "mt", "mlt" }, + { "my", "mya" }, + { "na", "nau" }, + { "nb", "nob" }, + { "nd", "nde" }, + { "ne", "nep" }, + { "ng", "ndo" }, + { "nl", "nld" }, + { "nn", "nno" }, + { "no", "nor" }, + { "nr", "nbl" }, + { "nv", "nav" }, + { "ny", "nya" }, + { "oc", "oci" }, + { "oj", "oji" }, + { "om", "orm" }, + { "or", "ori" }, + { "os", "oss" }, + { "pa", "pan" }, + { "pi", "pli" }, + { "pl", "pol" }, + { "ps", "pus" }, + { "pt", "por" }, + { "qu", "que" }, + { "rm", "roh" }, + { "rn", "run" }, + { "ro", "ron" }, + { "ru", "rus" }, + { "rw", "kin" }, + { "sa", "san" }, + { "sc", "srd" }, + { "sd", "snd" }, + { "se", "sme" }, + { "sg", "sag" }, + { "sh", "hbs" }, + { "si", "sin" }, + { "sk", "slk" }, + { "sl", "slv" }, + { "sm", "smo" }, + { "sn", "sna" }, + { "so", "som" }, + { "sq", "sqi" }, + { "sr", "srp" }, + { "ss", "ssw" }, + { "st", "sot" }, + { "su", "sun" }, + { "sv", "swe" }, + { "sw", "swa" }, + { "ta", "tam" }, + { "te", "tel" }, + { "tg", "tgk" }, + { "th", "tha" }, + { "ti", "tir" }, + { "tk", "tuk" }, + { "tl", "tgl" }, + { "tn", "tsn" }, + { "to", "ton" }, + { "tr", "tur" }, + { "ts", "tso" }, + { "tt", "tat" }, + { "ty", "tah" }, + { "ug", "uig" }, + { "uk", "ukr" }, + { "ur", "urd" }, + { "uz", "uzb" }, + { "ve", "ven" }, + { "vi", "vie" }, + { "vo", "vol" }, + { "wa", "wln" }, + { "wo", "wol" }, + { "xh", "xho" }, + { "yi", "yid" }, + { "yo", "yor" }, + { "za", "zha" }, + { "zu", "zul" }, + }; + + public static readonly Dictionary ThreeToTwo = new() + { + // Extracted from langtags.json + { "aar", "aa" }, + { "abk", "ab" }, + { "ave", "ae" }, + { "afr", "af" }, + { "aka", "ak" }, + { "amh", "am" }, + { "arg", "an" }, + { "ara", "ar" }, + { "asm", "as" }, + { "ava", "av" }, + { "aym", "ay" }, + { "aze", "az" }, + { "bak", "ba" }, + { "bel", "be" }, + { "bul", "bg" }, + { "bis", "bi" }, + { "bam", "bm" }, + { "ben", "bn" }, + { "bod", "bo" }, + { "bre", "br" }, + { "bos", "bs" }, + { "cat", "ca" }, + { "che", "ce" }, + { "cha", "ch" }, + { "cos", "co" }, + { "cre", "cr" }, + { "ces", "cs" }, + { "chu", "cu" }, + { "chv", "cv" }, + { "cym", "cy" }, + { "dan", "da" }, + { "deu", "de" }, + { "div", "dv" }, + { "dzo", "dz" }, + { "ewe", "ee" }, + { "ell", "el" }, + { "eng", "en" }, + { "epo", "eo" }, + { "spa", "es" }, + { "est", "et" }, + { "eus", "eu" }, + { "fas", "fa" }, + { "ful", "ff" }, + { "fin", "fi" }, + { "fij", "fj" }, + { "fao", "fo" }, + { "fra", "fr" }, + { "fry", "fy" }, + { "gle", "ga" }, + { "gla", "gd" }, + { "glg", "gl" }, + { "grn", "gn" }, + { "guj", "gu" }, + { "glv", "gv" }, + { "hau", "ha" }, + { "heb", "he" }, + { "hin", "hi" }, + { "hmo", "ho" }, + { "hrv", "hr" }, + { "hat", "ht" }, + { "hun", "hu" }, + { "hye", "hy" }, + { "her", "hz" }, + { "ina", "ia" }, + { "ind", "id" }, + { "ile", "ie" }, + { "ibo", "ig" }, + { "iii", "ii" }, + { "ipk", "ik" }, + { "ido", "io" }, + { "isl", "is" }, + { "ita", "it" }, + { "iku", "iu" }, + { "jpn", "ja" }, + { "jav", "jv" }, + { "kat", "ka" }, + { "kon", "kg" }, + { "kik", "ki" }, + { "kua", "kj" }, + { "kaz", "kk" }, + { "kal", "kl" }, + { "khm", "km" }, + { "kan", "kn" }, + { "kor", "ko" }, + { "kau", "kr" }, + { "kas", "ks" }, + { "kur", "ku" }, + { "kom", "kv" }, + { "cor", "kw" }, + { "kir", "ky" }, + { "lat", "la" }, + { "ltz", "lb" }, + { "lug", "lg" }, + { "lim", "li" }, + { "lin", "ln" }, + { "lao", "lo" }, + { "lit", "lt" }, + { "lub", "lu" }, + { "lav", "lv" }, + { "mlg", "mg" }, + { "mah", "mh" }, + { "mri", "mi" }, + { "mkd", "mk" }, + { "mal", "ml" }, + { "mon", "mn" }, + { "mar", "mr" }, + { "msa", "ms" }, + { "mlt", "mt" }, + { "mya", "my" }, + { "nau", "na" }, + { "nob", "nb" }, + { "nde", "nd" }, + { "nep", "ne" }, + { "ndo", "ng" }, + { "nld", "nl" }, + { "nno", "nn" }, + { "nor", "no" }, + { "nbl", "nr" }, + { "nav", "nv" }, + { "nya", "ny" }, + { "oci", "oc" }, + { "oji", "oj" }, + { "orm", "om" }, + { "ori", "or" }, + { "oss", "os" }, + { "pan", "pa" }, + { "pli", "pi" }, + { "pol", "pl" }, + { "pus", "ps" }, + { "por", "pt" }, + { "que", "qu" }, + { "roh", "rm" }, + { "run", "rn" }, + { "ron", "ro" }, + { "rus", "ru" }, + { "kin", "rw" }, + { "san", "sa" }, + { "srd", "sc" }, + { "snd", "sd" }, + { "sme", "se" }, + { "sag", "sg" }, + { "hbs", "sh" }, + { "sin", "si" }, + { "slk", "sk" }, + { "slv", "sl" }, + { "smo", "sm" }, + { "sna", "sn" }, + { "som", "so" }, + { "sqi", "sq" }, + { "srp", "sr" }, + { "ssw", "ss" }, + { "sot", "st" }, + { "sun", "su" }, + { "swe", "sv" }, + { "swa", "sw" }, + { "tam", "ta" }, + { "tel", "te" }, + { "tgk", "tg" }, + { "tha", "th" }, + { "tir", "ti" }, + { "tuk", "tk" }, + { "tgl", "tl" }, + { "tsn", "tn" }, + { "ton", "to" }, + { "tur", "tr" }, + { "tso", "ts" }, + { "tat", "tt" }, + { "tah", "ty" }, + { "uig", "ug" }, + { "ukr", "uk" }, + { "urd", "ur" }, + { "uzb", "uz" }, + { "ven", "ve" }, + { "vie", "vi" }, + { "vol", "vo" }, + { "wln", "wa" }, + { "wol", "wo" }, + { "xho", "xh" }, + { "yid", "yi" }, + { "yor", "yo" }, + { "zha", "za" }, + { "zul", "zu" }, + }; +} diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs index 661e30fbd..f244f45fa 100644 --- a/backend/LexBoxApi/Services/PermissionService.cs +++ b/backend/LexBoxApi/Services/PermissionService.cs @@ -24,6 +24,17 @@ private async ValueTask ManagesOrgThatOwnsProject(Guid projectId) return false; } + private async ValueTask IsMemberOfOrgThatOwnsProject(Guid projectId) + { + if (User is not null && User.Orgs.Any()) + { + var memberOfOrgIds = User.Orgs.Select(o => o.OrgId).ToHashSet(); + var projectOrgIds = await projectService.LookupProjectOrgIds(projectId); + if (projectOrgIds.Any(oId => memberOfOrgIds.Contains(oId))) return true; + } + return false; + } + public async ValueTask CanSyncProject(string projectCode) { if (User is null) return false; @@ -96,6 +107,16 @@ public async ValueTask AssertCanManageProject(Guid projectId) if (!await CanManageProject(projectId)) throw new UnauthorizedAccessException(); } + public async ValueTask CanManageProject(string projectCode) + { + return await CanManageProject(await projectService.LookupProjectId(projectCode)); + } + + public async ValueTask AssertCanManageProject(string projectCode) + { + if (!await CanManageProject(projectCode)) throw new UnauthorizedAccessException(); + } + public async ValueTask AssertCanManageProjectMemberRole(Guid projectId, Guid userId) { if (User is null) throw new UnauthorizedAccessException(); @@ -104,6 +125,28 @@ public async ValueTask AssertCanManageProjectMemberRole(Guid projectId, Guid use throw new UnauthorizedAccessException("Not allowed to change own project role."); } + public async ValueTask CanAskToJoinProject(Guid projectId) + { + if (User is null) return false; + if (User.IsAdmin) return true; + return await IsMemberOfOrgThatOwnsProject(projectId); + } + + public async ValueTask AssertCanAskToJoinProject(Guid projectId) + { + if (!await CanAskToJoinProject(projectId)) throw new UnauthorizedAccessException(); + } + + public async ValueTask CanAskToJoinProject(string projectCode) + { + return await CanAskToJoinProject(await projectService.LookupProjectId(projectCode)); + } + + public async ValueTask AssertCanAskToJoinProject(string projectCode) + { + if (!await CanAskToJoinProject(projectCode)) throw new UnauthorizedAccessException(); + } + public void AssertCanLockOrUnlockUser(Guid userId) { AssertIsAdmin(); diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs index 7a626ddb1..06be6f1ed 100644 --- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs +++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs @@ -21,8 +21,14 @@ public interface IPermissionService ValueTask CanViewProject(string projectCode); ValueTask AssertCanViewProject(string projectCode); ValueTask CanManageProject(Guid projectId); + ValueTask CanManageProject(string projectCode); ValueTask AssertCanManageProject(Guid projectId); + ValueTask AssertCanManageProject(string projectCode); ValueTask AssertCanManageProjectMemberRole(Guid projectId, Guid userId); + ValueTask CanAskToJoinProject(Guid projectId); + ValueTask CanAskToJoinProject(string projectCode); + ValueTask AssertCanAskToJoinProject(Guid projectId); + ValueTask AssertCanAskToJoinProject(string projectCode); void AssertIsAdmin(); void AssertCanDeleteAccount(Guid userId); bool HasProjectCreatePermission(); diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 6e3accb8b..652eff98a 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -21,6 +21,11 @@ type AlreadyExistsError implements Error { message: String! } +type AskToJoinProjectPayload { + project: Project + errors: [AskToJoinProjectError!] +} + type AuthUserOrg { role: OrgRole! orgId: UUID! @@ -233,6 +238,7 @@ type Mutation { addProjectMember(input: AddProjectMemberInput!): AddProjectMemberPayload! bulkAddProjectMembers(input: BulkAddProjectMembersInput!): BulkAddProjectMembersPayload! @authorize(policy: "AdminRequiredPolicy") changeProjectMemberRole(input: ChangeProjectMemberRoleInput!): ChangeProjectMemberRolePayload! + askToJoinProject(input: AskToJoinProjectInput!): AskToJoinProjectPayload! changeProjectName(input: ChangeProjectNameInput!): ChangeProjectNamePayload! changeProjectDescription(input: ChangeProjectDescriptionInput!): ChangeProjectDescriptionPayload! setProjectConfidentiality(input: SetProjectConfidentialityInput!): SetProjectConfidentialityPayload! @@ -409,6 +415,8 @@ type Query { projects(withDeleted: Boolean! = false where: ProjectFilterInput orderBy: [ProjectSortInput!]): [Project!]! @authorize(policy: "AdminRequiredPolicy") myDraftProjects(orderBy: [DraftProjectSortInput!]): [DraftProject!]! draftProjects(where: DraftProjectFilterInput orderBy: [DraftProjectSortInput!]): [DraftProject!]! @authorize(policy: "AdminRequiredPolicy") + projectsByLangCodeAndOrg(input: ProjectsByLangCodeAndOrgInput! orderBy: [ProjectSortInput!]): [Project!]! + projectsInMyOrg(input: ProjectsInMyOrgInput! where: ProjectFilterInput orderBy: [ProjectSortInput!]): [Project!]! projectById(projectId: UUID!): Project projectByCode(code: String!): Project draftProjectByCode(code: String!): DraftProject @authorize(policy: "AdminRequiredPolicy") @@ -534,6 +542,8 @@ union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVeri union AddProjectToOrgError = DbError | NotFoundError +union AskToJoinProjectError = NotFoundError | DbError | ProjectMembersMustBeVerified | ProjectMembersMustBeVerifiedForRole + union BulkAddOrgMembersError = NotFoundError | DbError | UnauthorizedAccessError union BulkAddProjectMembersError = NotFoundError | InvalidEmailError | DbError @@ -586,7 +596,7 @@ union UpdateProjectLexEntryCountError = NotFoundError | DbError | UnauthorizedAc input AddProjectMemberInput { projectId: UUID! - usernameOrEmail: String! + usernameOrEmail: String userId: UUID role: ProjectRole! canInvite: Boolean! @@ -597,6 +607,10 @@ input AddProjectToOrgInput { projectId: UUID! } +input AskToJoinProjectInput { + projectId: UUID! +} + input BooleanOperationFilterInput { eq: Boolean neq: Boolean @@ -951,6 +965,15 @@ input ProjectWritingSystemsFilterInput { analysisWss: ListFilterInputTypeOfFLExWsIdFilterInput } +input ProjectsByLangCodeAndOrgInput { + orgId: UUID! + langCode: String! +} + +input ProjectsInMyOrgInput { + orgId: UUID! +} + input RemoveProjectFromOrgInput { orgId: UUID! projectId: UUID! diff --git a/frontend/src/lib/email/JoinProjectRequest.svelte b/frontend/src/lib/email/JoinProjectRequest.svelte new file mode 100644 index 000000000..3e749b9bb --- /dev/null +++ b/frontend/src/lib/email/JoinProjectRequest.svelte @@ -0,0 +1,18 @@ + + + + {$t('emails.join_project_request_email.body', {requestingUserName, projectName})} + {$t('emails.join_project_request_email.approve_button')} + diff --git a/frontend/src/lib/forms/RadioButtonGroup.svelte b/frontend/src/lib/forms/RadioButtonGroup.svelte new file mode 100644 index 000000000..dfc1a1a2f --- /dev/null +++ b/frontend/src/lib/forms/RadioButtonGroup.svelte @@ -0,0 +1,51 @@ + + + + +
+
+ {label} +
+ {#each buttons as button} +
+ +
+ {/each} + {#if description} + + {/if} + +
diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 809052515..44c05a769 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -195,7 +195,14 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "language_code_invalid": "Language code can only include lowercase a-z, digits and '-', and cannot start with '-'", "name": "Name", "name_description": "E.g. the name of the language you're working on", + "maybe_related": "Possibly related projects:", + "maybe_related_description": "Perhaps you want to join one of these instead?", + "ask_to_join": "Ask to join", + "no_thanks": "No thanks, create a new project", + "click_to_view_related_projects": "Found {count, plural, one {# related project} other {# related projects}}, click here to see {count, plural, one {it} other {them}}", + "join_request_sent": "Your request to join the {projectName} project has been sent to the project manager(s)", "description": "Description", + "no_description": "This project does not have a description", "name_missing": "Project name required", "retention_policy": "Purpose", "submit": "Create Project", @@ -638,6 +645,11 @@ If you don't see a dialog or already closed it, click the button below:", "subject": "Project request: {projectName}", "heading": "User {name} ({email}) requested that a project be created for them. Details below:" }, + "join_project_request_email": { + "subject": "Project join request: {requestingUserName} wants to join {projectName}", + "body": "User {requestingUserName} requested to join the project {projectName}. Click below to approve this request.", + "approve_button": "Approve request" + }, "approve_project_request_email": { "subject": "Project approved: {projectName}", "heading": "The project you requested, {projectName}, has been approved and created.", diff --git a/frontend/src/lib/util/time.ts b/frontend/src/lib/util/time.ts index ac68f3920..77e244baa 100644 --- a/frontend/src/lib/util/time.ts +++ b/frontend/src/lib/util/time.ts @@ -78,3 +78,30 @@ export function deriveAsync( }, debounceTime); }, initialValue); } + +/** + * @param fn A function that maps the store value to an async result, filtering out undefined values + * @returns A store that contains the result of the async function + */ +export function deriveAsyncIfDefined( + store: Readable, + fn: (value: T) => Promise, + initialValue?: D, + debounce: number | boolean = false): Readable { + + const debounceTime = pickDebounceTime(debounce); + let timeout: ReturnType | undefined; + + return derived(store, (value, set) => { + if (value) { + clearTimeout(timeout); + timeout = setTimeout(() => { + const myTimeout = timeout; + void fn(value).then((result) => { + if (myTimeout !== timeout) return; // discard outdated results + set(result); + }); + }, debounceTime); + } + }, initialValue); +} diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte index 5ce7846ec..d12a1b808 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte @@ -1,5 +1,5 @@ - - {$t('project_page.add_user.add_button')} - - {$t('project_page.add_user.modal_title')} diff --git a/frontend/src/routes/(authenticated)/project/create/+page.svelte b/frontend/src/routes/(authenticated)/project/create/+page.svelte index 52121c449..b1d2c9612 100644 --- a/frontend/src/routes/(authenticated)/project/create/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/create/+page.svelte @@ -5,26 +5,30 @@ import t from '$lib/i18n'; import { TitlePage } from '$lib/layout'; import { z } from 'zod'; - import { _createProject, _projectCodeAvailable } from './+page'; + import { _askToJoinProject, _createProject, _projectCodeAvailable } from './+page'; import AdminContent from '$lib/layout/AdminContent.svelte'; import { useNotifications } from '$lib/notify'; - import { Duration, deriveAsync } from '$lib/util/time'; + import { Duration, deriveAsync, deriveAsyncIfDefined } from '$lib/util/time'; import { getSearchParamValues } from '$lib/util/query-params'; import { onMount } from 'svelte'; import MemberBadge from '$lib/components/Badges/MemberBadge.svelte'; - import { derived, writable } from 'svelte/store'; + import { derived, writable, type Readable } from 'svelte/store'; import { concatAll } from '$lib/util/array'; import { browser } from '$app/environment'; import { ProjectConfidentialityCombobox } from '$lib/components/Projects'; import DevContent from '$lib/layout/DevContent.svelte'; import { isDev } from '$lib/layout/DevContent.svelte'; + import { _getProjectsByLangCodeAndOrg, _getProjectsByNameAndOrg } from './+page'; + import Markdown from 'svelte-exmarkdown'; + import { NewTabLinkRenderer } from '$lib/components/Markdown'; + import Button from '$lib/forms/Button.svelte'; export let data; $: user = data.user; let requestingUser : typeof data.requestingUser; $: myOrgs = data.myOrgs ?? []; - const { notifyWarning } = useNotifications(); + const { notifySuccess, notifyWarning } = useNotifications(); const formSchema = z.object({ name: z.string().trim().min(1, $t('project.create.name_missing')), @@ -47,7 +51,7 @@ //random guid let projectId:string = crypto.randomUUID(); - let { form, errors, message, enhance, submitting } = lexSuperForm(formSchema, async () => { + let { form, errors, message, enhance, submitting, tainted } = lexSuperForm(formSchema, async () => { const result = await _createProject({ id: projectId, name: $form.name, @@ -77,7 +81,7 @@ }); const asyncCodeError = writable(); - const codeStore = derived(form, ($form) => $form.code); + const codeStore = derived(form, f => f.code); const codeIsAvailable = deriveAsync(codeStore, async (code) => { if (!browser || !code || !user.canCreateProjects) return true; return _projectCodeAvailable(code); @@ -85,6 +89,30 @@ $: $asyncCodeError = $codeIsAvailable ? undefined : $t('project.create.code_exists'); const codeErrors = derived([errors, asyncCodeError], () => [...new Set(concatAll($errors.code, $asyncCodeError))]); + const projectNameStore = derived(form, f => f.name); + const langCodeStore = derived(form, f => f.languageCode); + const orgIdStore = derived(form, f => f.orgId); + const langCodeAndOrgIdStore: Readable<{langCode: string, orgId: string}> = derived([langCodeStore, orgIdStore], ([langCode, orgId], set) => { + if (langCode && orgId && (langCode.length == 2 || langCode.length == 3)) { + set({ langCode, orgId }); + } + }); + + const projectNameAndOrgIdStore: Readable<{projectName: string, orgId: string}> = derived([projectNameStore, orgIdStore], ([projectName, orgId], set) => { + if (projectName && orgId && projectName.length >= 3) { + set({ projectName, orgId }); + } + }); + + const relatedProjectsByLangCode = deriveAsyncIfDefined(langCodeAndOrgIdStore, _getProjectsByLangCodeAndOrg, []); + const relatedProjectsByName = deriveAsyncIfDefined(projectNameAndOrgIdStore, _getProjectsByNameAndOrg, []); + + const relatedProjects = derived([relatedProjectsByName, relatedProjectsByLangCode], ([byName, byCode]) => { + // Put projects related by language code first as they're more likely to be real matches + var uniqueByName = byName.filter(n => byCode.findIndex(c => c.id == n.id) == -1); + return [...byCode, ...uniqueByName]; + }); + const typeCodeMap: Partial> = { [ProjectType.FlEx]: 'flex', [ProjectType.WeSay]: 'dictionary', @@ -150,6 +178,23 @@ { taint: false } ); } + + let selectedProject: { name: string, id: string } | undefined = undefined; + let showRelatedProjects = true; + + // When the related-projects list changes, keep selectedProject up-to-date + relatedProjects.subscribe(projects => { + if (selectedProject) selectedProject = projects.find(p => selectedProject?.id === p.id); + }); + + async function askToJoinProject(projectId: string, projectName: string): Promise { + const joinResult = await _askToJoinProject(projectId); + if (!joinResult.error) { + notifySuccess($t('project.create.join_request_sent', { projectName }), Duration.Persistent); + $tainted = undefined; // Prevent "are you sure you want to leave?" warning + await goto('/'); + } + } @@ -212,6 +257,7 @@ bind:value={$form.languageCode} error={$errors.languageCode} /> + @@ -222,25 +268,80 @@ readonly={!$form.customCode} /> -