Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 30 additions & 1 deletion backend/LexBoxApi/GraphQL/UserMutations.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
Expand Down Expand Up @@ -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<NotFoundException>]
[UseMutationConvention]
public async Task<SendFWLiteBetaRequestEmailResult> 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<NotFoundException>]
[Error<DbError>]
Expand Down
2 changes: 2 additions & 0 deletions backend/LexBoxApi/Services/Email/EmailTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public enum EmailTemplate
CreateProjectRequest,
ApproveProjectRequest,
UserAdded,
JoinFwLiteBetaRequest,
}

public record ForgotPasswordEmail(string Name, string ResetUrl, TimeSpan lifetime) : EmailTemplateBase(EmailTemplate.ForgotPassword);
Expand All @@ -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);
1 change: 1 addition & 0 deletions backend/LexBoxApi/Services/Email/IEmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
8 changes: 8 additions & 0 deletions backend/LexBoxApi/Services/EmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,14 @@
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));
Expand All @@ -234,7 +242,7 @@
}
catch (Exception e)
{
activity?.RecordException(e);

Check warning on line 245 in backend/LexBoxApi/Services/EmailService.cs

View workflow job for this annotation

GitHub Actions / Build API / publish-api

'ActivityExtensions.RecordException(Activity?, Exception?)' is obsolete: 'Call Activity.AddException instead this method will be removed in a future version.'

Check warning on line 245 in backend/LexBoxApi/Services/EmailService.cs

View workflow job for this annotation

GitHub Actions / GHA integration tests / dotnet

'ActivityExtensions.RecordException(Activity?, Exception?)' is obsolete: 'Call Activity.AddException instead this method will be removed in a future version.'
activity?.SetStatus(ActivityStatusCode.Error);
throw;
}
Expand Down
18 changes: 18 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -493,6 +494,11 @@ type RequiredError implements Error {
message: String!
}

type SendFWLiteBetaRequestEmailPayload {
sendFWLiteBetaRequestEmailResult: SendFWLiteBetaRequestEmailResult
errors: [SendFWLiteBetaRequestEmailError!]
}

type SendNewVerificationEmailByAdminPayload {
user: User
errors: [SendNewVerificationEmailByAdminError!]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1094,6 +1102,11 @@ input RetentionPolicyOperationFilterInput {
nin: [RetentionPolicy!] @cost(weight: "10")
}

input SendFWLiteBetaRequestEmailInput {
userId: UUID!
name: String!
}

input SendNewVerificationEmailByAdminInput {
userId: UUID!
}
Expand Down Expand Up @@ -1282,6 +1295,11 @@ enum RetentionPolicy {
TRAINING
}

enum SendFWLiteBetaRequestEmailResult {
USER_ALREADY_IN_BETA
BETA_ACCESS_REQUEST_SENT
}

enum SortEnumType {
ASC
DESC
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/email/Email.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
const lexboxLogo = 'https://lexbox.org/images/logo-dark.png';
interface Props {
subject: string;
name: string;
name?: string;
children?: Snippet;
}

Expand Down
14 changes: 14 additions & 0 deletions frontend/src/lib/email/JoinFwLiteBetaRequest.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script lang="ts">
import Email from '$lib/email/Email.svelte';
import t from '$lib/i18n';

export let name: string;
export let email: string;
export let baseUrl: string;
let approveUrl = new URL(`/admin/?userSearch=${encodeURIComponent(email)}`, baseUrl);
</script>

<Email subject={$t('emails.join_fw_lite_beta_request_email.subject', { name })}>
<mj-text>{$t('emails.join_fw_lite_beta_request_email.body', { name })}</mj-text>
<mj-button href={approveUrl}>{$t('emails.join_fw_lite_beta_request_email.approve_button')}</mj-button>
</Email>
17 changes: 17 additions & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}.",
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/lib/layout/FeatureFlagAlternateContent.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import type { FeatureFlag } from '$lib/gql/types';
import { hasFeatureFlag } from '$lib/user';

interface Props {
flag: FeatureFlag | keyof typeof FeatureFlag;
children?: Snippet;
hasFlagContent?: Snippet;
missingFlagContent?: Snippet;
}

let { flag, missingFlagContent, hasFlagContent, children }: Props = $props();
</script>

<!-- eslint-disable-next-line @typescript-eslint/no-unsafe-argument -->
{#if hasFeatureFlag(page.data.user, flag)}
{@render (hasFlagContent ?? children)?.()}
{:else}
{@render missingFlagContent?.()}
{/if}
53 changes: 53 additions & 0 deletions frontend/src/routes/(authenticated)/wheresMyProject/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script lang="ts">
import { TitlePage } from '$lib/layout';
import t from '$lib/i18n';
import Markdown from 'svelte-exmarkdown';
import FeatureFlagAlternateContent from '$lib/layout/FeatureFlagAlternateContent.svelte';
import Button from '$lib/forms/Button.svelte';
import { page } from '$app/state';
import { _sendFWLiteBetaRequestEmail } from './+page';
import type { UUID } from 'crypto';
import { SendFwLiteBetaRequestEmailResult } from '$lib/gql/generated/graphql';
import { useNotifications } from '$lib/notify';

const { notifySuccess } = useNotifications();

let requesting = $state(false);

async function requestBetaAccess(): Promise<void> {
requesting = true;
try {
const gqlResult = await _sendFWLiteBetaRequestEmail(page.data.user.id as UUID, page.data.user.name);
if (gqlResult.error) {
if (gqlResult.error.byType('NotFoundError')) {
console.log('User not found, no dialog shown');
}
}
const result = gqlResult.data?.sendFWLiteBetaRequestEmail.sendFWLiteBetaRequestEmailResult;
if (result === SendFwLiteBetaRequestEmailResult.BetaAccessRequestSent) {
notifySuccess($t('where_is_my_project.access_request_sent'));
}
if (result === SendFwLiteBetaRequestEmailResult.UserAlreadyInBeta) {
notifySuccess($t('where_is_my_project.already_in_beta'));
}
} finally {
requesting = false;
}
}
</script>

<TitlePage title={$t('where_is_my_project.title')}>
<div class="prose text-lg">
<FeatureFlagAlternateContent flag="FwLiteBeta">
{#snippet hasFlagContent()}
<Markdown md={$t('where_is_my_project.body')} />
{/snippet}
{#snippet missingFlagContent()}
<Markdown md={$t('where_is_my_project.user_not_in_beta')} />
<div class="text-center">
<Button loading={requesting} variant="btn-primary" onclick={requestBetaAccess}>{$t('where_is_my_project.request_beta_access')}</Button>
</div>
{/snippet}
</FeatureFlagAlternateContent>
</div>
</TitlePage>
26 changes: 26 additions & 0 deletions frontend/src/routes/(authenticated)/wheresMyProject/+page.ts
Original file line number Diff line number Diff line change
@@ -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<SendFwLiteBetaRequestEmailMutation> {
//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;
}
9 changes: 9 additions & 0 deletions frontend/src/routes/email/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
}

Expand All @@ -34,6 +36,7 @@ export const componentMap = {
[EmailTemplate.JoinProjectRequest]: JoinProjectRequest,
[EmailTemplate.CreateProjectRequest]: CreateProjectRequest,
[EmailTemplate.ApproveProjectRequest]: ApproveProjectRequest,
[EmailTemplate.JoinFwLiteBetaRequest]: JoinFwLiteBetaRequest,
[EmailTemplate.UserAdded]: UserAdded,
} satisfies Record<EmailTemplate, Component<never>>;
// Note: Foo<never> means "Foo<T> but I don't care what T is" and is apparently preferred over Foo<any> in modern Typescript
Expand Down Expand Up @@ -97,6 +100,11 @@ interface UserAddedProps extends EmailTemplatePropsBase<EmailTemplate.UserAdded>
projectCode: string;
}

interface JoinFwLiteBetaRequestProps extends EmailTemplatePropsBase<EmailTemplate.JoinFwLiteBetaRequest> {
name: string;
email: string;
}

export type EmailTemplateProps =
ForgotPasswordProps
| NewAdminProps
Expand All @@ -107,4 +115,5 @@ export type EmailTemplateProps =
| CreateProjectProps
| ApproveProjectProps
| UserAddedProps
| JoinFwLiteBetaRequestProps
| EmailTemplatePropsBase<EmailTemplate>;
7 changes: 7 additions & 0 deletions frontend/src/routes/email/tester/+page@.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 12 additions & 2 deletions frontend/viewer/src/home/Server.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -79,9 +80,12 @@
</div>
</ListItem>
{:else if !projects.length}
<p class="text-center elevation-1 md:rounded p-4">
<p class="text-center md:rounded p-4">
{#if status.loggedIn}
{$t`No projects`}
<Button class="border border-primary" variant="link" target="_blank" href="{server?.authority}/wheresMyProject">
{$t`Where are my projects?`}
<Icon icon="i-mdi-open-in-new" class="size-4" />
</Button>
{:else}
<LoginButton {status} on:status={() => dispatch('refreshAll')}/>
{/if}
Expand Down Expand Up @@ -119,6 +123,12 @@
</ButtonListItem>
{/if}
{/each}
<div class="text-center py-2">
<Button variant="link" target="_blank" href="{server?.authority}/wheresMyProject">
{$t`I don't see my project`}
<Icon icon="i-mdi-open-in-new" class="size-4" />
</Button>
</div>
</div>
{/if}
</div>
Expand Down
Loading