diff --git a/backend/LexBoxApi/GraphQL/UserMutations.cs b/backend/LexBoxApi/GraphQL/UserMutations.cs index bfd107dbfb..18616f340c 100644 --- a/backend/LexBoxApi/GraphQL/UserMutations.cs +++ b/backend/LexBoxApi/GraphQL/UserMutations.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Security.Cryptography; using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; @@ -36,6 +36,35 @@ public record CreateGuestUserByAdminInput( string PasswordHash, int PasswordStrength, Guid? OrgId); + public record SendFWLiteBetaRequestEmailInput(Guid UserId, string Name); + public enum SendFWLiteBetaRequestEmailResult + { + UserAlreadyInBeta, + BetaAccessRequestSent, + }; + + [Error] + [UseMutationConvention] + public async Task SendFWLiteBetaRequestEmail( + LoggedInContext loggedInContext, + SendFWLiteBetaRequestEmailInput input, + LexBoxDbContext dbContext, + LexAuthService lexAuthService, + IEmailService emailService + ) + { + if (loggedInContext.User.Id != input.UserId) throw new UnauthorizedAccessException(); + var user = await dbContext.Users.FindAsync(input.UserId); + NotFoundException.ThrowIfNull(user); + if (user.FeatureFlags.Contains(FeatureFlag.FwLiteBeta)) + { + if (!loggedInContext.User.FeatureFlags.Contains(FeatureFlag.FwLiteBeta)) + await lexAuthService.RefreshUser(); + return SendFWLiteBetaRequestEmailResult.UserAlreadyInBeta; + } + await emailService.SendJoinFwLiteBetaEmail(user); + return SendFWLiteBetaRequestEmailResult.BetaAccessRequestSent; + } [Error] [Error] diff --git a/backend/LexBoxApi/Services/Email/EmailTemplates.cs b/backend/LexBoxApi/Services/Email/EmailTemplates.cs index 8f58910a3c..b60ec1508f 100644 --- a/backend/LexBoxApi/Services/Email/EmailTemplates.cs +++ b/backend/LexBoxApi/Services/Email/EmailTemplates.cs @@ -23,6 +23,7 @@ public enum EmailTemplate CreateProjectRequest, ApproveProjectRequest, UserAdded, + JoinFwLiteBetaRequest, } public record ForgotPasswordEmail(string Name, string ResetUrl, TimeSpan lifetime) : EmailTemplateBase(EmailTemplate.ForgotPassword); @@ -41,3 +42,4 @@ 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); public record UserAddedEmail(string Name, string Email, string ProjectName, string ProjectCode) : EmailTemplateBase(EmailTemplate.UserAdded); +public record JoinFwLiteBetaEmail(string Name, string Email) : EmailTemplateBase(EmailTemplate.JoinFwLiteBetaRequest); diff --git a/backend/LexBoxApi/Services/Email/IEmailService.cs b/backend/LexBoxApi/Services/Email/IEmailService.cs index ba7942a87a..de26b3622e 100644 --- a/backend/LexBoxApi/Services/Email/IEmailService.cs +++ b/backend/LexBoxApi/Services/Email/IEmailService.cs @@ -57,5 +57,6 @@ public Task SendCreateAccountWithProjectEmail( public Task SendCreateProjectRequestEmail(LexAuthUser user, CreateProjectInput projectInput); public Task SendApproveProjectRequestEmail(User user, CreateProjectInput projectInput); public Task SendUserAddedEmail(User user, string projectName, string projectCode); + public Task SendJoinFwLiteBetaEmail(User user); public Task SendEmailAsync(MimeMessage message); } diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index 3fe3c0686a..2e9b954ebc 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -215,6 +215,14 @@ public async Task SendUserAddedEmail(User user, string projectName, string proje await RenderEmail(email, new UserAddedEmail(user.Name, user.Email!, projectName, projectCode), user.LocalizationCode); await SendEmailWithRetriesAsync(email); } + + public async Task SendJoinFwLiteBetaEmail(User user) + { + var email = StartUserEmail("Lexbox Support", "lexbox_support@groups.sil.org"); // TODO: Get from environment + await RenderEmail(email, new JoinFwLiteBetaEmail(user.Name, user.Email ?? ""), user.LocalizationCode); + await SendEmailWithRetriesAsync(email); + } + public async Task SendEmailAsync(MimeMessage message) { message.From.Add(MailboxAddress.Parse(_emailConfig.From)); diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 85a5ca1443..eb050a867c 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -275,6 +275,7 @@ type Mutation { removeProjectMember(input: RemoveProjectMemberInput!): RemoveProjectMemberPayload! @cost(weight: "10") deleteDraftProject(input: DeleteDraftProjectInput!): DeleteDraftProjectPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") softDeleteProject(input: SoftDeleteProjectInput!): SoftDeleteProjectPayload! @cost(weight: "10") + sendFWLiteBetaRequestEmail(input: SendFWLiteBetaRequestEmailInput!): SendFWLiteBetaRequestEmailPayload! @cost(weight: "10") changeUserAccountBySelf(input: ChangeUserAccountBySelfInput!): ChangeUserAccountBySelfPayload! @cost(weight: "10") changeUserAccountByAdmin(input: ChangeUserAccountByAdminInput!): ChangeUserAccountByAdminPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") sendNewVerificationEmailByAdmin(input: SendNewVerificationEmailByAdminInput!): SendNewVerificationEmailByAdminPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") @@ -493,6 +494,11 @@ type RequiredError implements Error { message: String! } +type SendFWLiteBetaRequestEmailPayload { + sendFWLiteBetaRequestEmailResult: SendFWLiteBetaRequestEmailResult + errors: [SendFWLiteBetaRequestEmailError!] +} + type SendNewVerificationEmailByAdminPayload { user: User errors: [SendNewVerificationEmailByAdminError!] @@ -645,6 +651,8 @@ union LeaveProjectError = NotFoundError | LastMemberCantLeaveError union RemoveProjectFromOrgError = DbError | NotFoundError +union SendFWLiteBetaRequestEmailError = NotFoundError + union SendNewVerificationEmailByAdminError = NotFoundError | DbError | InvalidOperationError union SetOrgMemberRoleError = DbError | NotFoundError | OrgMemberInvitedByEmail | OrgMembersMustBeVerified | OrgMembersMustBeVerifiedForRole @@ -1094,6 +1102,11 @@ input RetentionPolicyOperationFilterInput { nin: [RetentionPolicy!] @cost(weight: "10") } +input SendFWLiteBetaRequestEmailInput { + userId: UUID! + name: String! +} + input SendNewVerificationEmailByAdminInput { userId: UUID! } @@ -1282,6 +1295,11 @@ enum RetentionPolicy { TRAINING } +enum SendFWLiteBetaRequestEmailResult { + USER_ALREADY_IN_BETA + BETA_ACCESS_REQUEST_SENT +} + enum SortEnumType { ASC DESC diff --git a/frontend/src/lib/email/Email.svelte b/frontend/src/lib/email/Email.svelte index e11b1e0608..ae0ef4f24b 100644 --- a/frontend/src/lib/email/Email.svelte +++ b/frontend/src/lib/email/Email.svelte @@ -5,7 +5,7 @@ const lexboxLogo = 'https://lexbox.org/images/logo-dark.png'; interface Props { subject: string; - name: string; + name?: string; children?: Snippet; } diff --git a/frontend/src/lib/email/JoinFwLiteBetaRequest.svelte b/frontend/src/lib/email/JoinFwLiteBetaRequest.svelte new file mode 100644 index 0000000000..91492c65aa --- /dev/null +++ b/frontend/src/lib/email/JoinFwLiteBetaRequest.svelte @@ -0,0 +1,14 @@ + + + + {$t('emails.join_fw_lite_beta_request_email.body', { name })} + {$t('emails.join_fw_lite_beta_request_email.approve_button')} + diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index c2b32d5437..7d43ef0c6c 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -668,6 +668,18 @@ If you don't see a dialog or already closed it, click the button below:", "admin": "Admin", "user": "User" }, + "where_is_my_project": { + "title": "FieldWorks Lite - Where's my project?", + "body": "If you don't see your project in FieldWorks Lite, navigate to the project here on Lexbox and look for the \"Try FieldWorks Lite?\" button\n\ +near the top of the page. Use that button to make your project available in FieldWorks Lite. This process may take a few minutes. When\n\ +it's done, you should see your project in FieldWorks Lite and be able to use it.", + "user_not_in_beta": "FieldWorks Lite is currently in a beta testing phase. \n\ +Only selected early access users can download Lexbox projects in FieldWorks Lite. \n\ +Click the button below to request to join the early access program. It may take a few days for us to respond.", + "request_beta_access": "Request early access to FieldWorks Lite", + "access_request_sent": "Your request to join the early access program has been submitted", + "already_in_beta": "You're already in the early access program", + }, "errors": { "apology": "Woops, something went wrong on our end. Sorry!", "mail_us_at": "If you're stuck, let us know about this at", @@ -736,6 +748,11 @@ If you don't see a dialog or already closed it, click the button below:", "heading": "The project you requested, {projectName}, has been approved and created.", "view_button": "View Project" }, + "join_fw_lite_beta_request_email": { + "subject": "FW Lite Beta join request: {name}", + "body": "User {name} requested to join the FW Lite beta. Click below to approve this request.", + "approve_button": "Approve Request" + }, "user_added": { "subject": "You joined project: {projectName}!", "body": "You have been added to the project: {projectName}.", diff --git a/frontend/src/lib/layout/FeatureFlagAlternateContent.svelte b/frontend/src/lib/layout/FeatureFlagAlternateContent.svelte new file mode 100644 index 0000000000..eb30742c2f --- /dev/null +++ b/frontend/src/lib/layout/FeatureFlagAlternateContent.svelte @@ -0,0 +1,22 @@ + + + +{#if hasFeatureFlag(page.data.user, flag)} + {@render (hasFlagContent ?? children)?.()} +{:else} + {@render missingFlagContent?.()} +{/if} diff --git a/frontend/src/routes/(authenticated)/wheresMyProject/+page.svelte b/frontend/src/routes/(authenticated)/wheresMyProject/+page.svelte new file mode 100644 index 0000000000..89236df13b --- /dev/null +++ b/frontend/src/routes/(authenticated)/wheresMyProject/+page.svelte @@ -0,0 +1,53 @@ + + + +
+ + {#snippet hasFlagContent()} + + {/snippet} + {#snippet missingFlagContent()} + +
+ +
+ {/snippet} +
+
+
diff --git a/frontend/src/routes/(authenticated)/wheresMyProject/+page.ts b/frontend/src/routes/(authenticated)/wheresMyProject/+page.ts new file mode 100644 index 0000000000..541b8fed9f --- /dev/null +++ b/frontend/src/routes/(authenticated)/wheresMyProject/+page.ts @@ -0,0 +1,26 @@ +import type {$OpResult, SendFwLiteBetaRequestEmailMutation} from '$lib/gql/types'; +import {getClient, graphql} from '$lib/gql'; + +import type {UUID} from 'crypto'; + +export async function _sendFWLiteBetaRequestEmail(userId: UUID, name: string): $OpResult { + //language=GraphQL + const result = await getClient() + .mutation( + graphql(` + mutation SendFWLiteBetaRequestEmail($input: SendFWLiteBetaRequestEmailInput!) { + sendFWLiteBetaRequestEmail(input: $input) { + sendFWLiteBetaRequestEmailResult + errors { + __typename + ... on Error { + message + } + } + } + } + `), + { input: { userId, name } } + ) + return result; +} diff --git a/frontend/src/routes/email/emails.ts b/frontend/src/routes/email/emails.ts index 0e846ecfb5..6a4f8ea3f5 100644 --- a/frontend/src/routes/email/emails.ts +++ b/frontend/src/routes/email/emails.ts @@ -3,6 +3,7 @@ import NewAdmin from '$lib/email/NewAdmin.svelte'; import VerifyEmailAddress from '$lib/email/VerifyEmailAddress.svelte'; import PasswordChanged from '$lib/email/PasswordChanged.svelte'; import JoinProjectRequest from '$lib/email/JoinProjectRequest.svelte'; +import JoinFwLiteBetaRequest from '$lib/email/JoinFwLiteBetaRequest.svelte'; import CreateProjectRequest from '$lib/email/CreateProjectRequest.svelte'; import type {CreateProjectInput} from '$lib/gql/generated/graphql'; import ApproveProjectRequest from '$lib/email/ApproveProjectRequest.svelte'; @@ -21,6 +22,7 @@ export const enum EmailTemplate { JoinProjectRequest = 'JOIN_PROJECT_REQUEST', CreateProjectRequest = 'CREATE_PROJECT_REQUEST', ApproveProjectRequest = 'APPROVE_PROJECT_REQUEST', + JoinFwLiteBetaRequest = 'JOIN_FW_LITE_BETA_REQUEST', UserAdded = 'USER_ADDED', } @@ -34,6 +36,7 @@ export const componentMap = { [EmailTemplate.JoinProjectRequest]: JoinProjectRequest, [EmailTemplate.CreateProjectRequest]: CreateProjectRequest, [EmailTemplate.ApproveProjectRequest]: ApproveProjectRequest, + [EmailTemplate.JoinFwLiteBetaRequest]: JoinFwLiteBetaRequest, [EmailTemplate.UserAdded]: UserAdded, } satisfies Record>; // Note: Foo means "Foo but I don't care what T is" and is apparently preferred over Foo in modern Typescript @@ -97,6 +100,11 @@ interface UserAddedProps extends EmailTemplatePropsBase projectCode: string; } +interface JoinFwLiteBetaRequestProps extends EmailTemplatePropsBase { + name: string; + email: string; +} + export type EmailTemplateProps = ForgotPasswordProps | NewAdminProps @@ -107,4 +115,5 @@ export type EmailTemplateProps = | CreateProjectProps | ApproveProjectProps | UserAddedProps + | JoinFwLiteBetaRequestProps | EmailTemplatePropsBase; diff --git a/frontend/src/routes/email/tester/+page@.svelte b/frontend/src/routes/email/tester/+page@.svelte index 29ab017a72..0319b1cc4f 100644 --- a/frontend/src/routes/email/tester/+page@.svelte +++ b/frontend/src/routes/email/tester/+page@.svelte @@ -15,6 +15,13 @@ resetUrl: absoluteUrl('resetPassword'), lifetime: '3.00:00:00', // 3 days }, + { + template: EmailTemplate.JoinFwLiteBetaRequest, + label: 'Join FW Lite Beta', + baseUrl: location.origin, + name: 'Test Editor', + email: 'editor@test.com', + }, { template: EmailTemplate.VerifyEmailAddress, name: 'Bob', diff --git a/frontend/viewer/src/home/Server.svelte b/frontend/viewer/src/home/Server.svelte index 6927c13542..0f34070a99 100644 --- a/frontend/viewer/src/home/Server.svelte +++ b/frontend/viewer/src/home/Server.svelte @@ -11,6 +11,7 @@ import ProjectTitle from './ProjectTitle.svelte'; import {cn} from '$lib/utils'; import {Button} from '$lib/components/ui/button'; + import {Icon} from '$lib/components/ui/icon'; const projectsService = useProjectsService(); @@ -79,9 +80,12 @@ {:else if !projects.length} -

+

{#if status.loggedIn} - {$t`No projects`} + {:else} dispatch('refreshAll')}/> {/if} @@ -119,6 +123,12 @@ {/if} {/each} +

+ +
{/if}