satellite/console: configure sending invites to unregistered emails

This change adds a flag to the satellite config indicating whether
unregistered email addresses should receive project invitation emails.

Change-Id: I0396f25574ddae3f9adaea32a6e7cd15b931bf12
This commit is contained in:
Jeremy Wharton 2023-10-17 05:08:19 -05:00
parent 24ae79345b
commit f8b59a50ff
13 changed files with 93 additions and 27 deletions

View File

@ -21,6 +21,7 @@ type Config struct {
LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"` LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"`
FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"` FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"`
ProjectInvitationExpiration time.Duration `help:"duration that project member invitations are valid for" default:"168h"` ProjectInvitationExpiration time.Duration `help:"duration that project member invitations are valid for" default:"168h"`
UnregisteredInviteEmailsEnabled bool `help:"indicates whether invitation emails can be sent to unregistered email addresses" default:"false"`
UserBalanceForUpgrade int64 `help:"amount of base units of US micro dollars needed to upgrade user's tier status" default:"10000000"` UserBalanceForUpgrade int64 `help:"amount of base units of US micro dollars needed to upgrade user's tier status" default:"10000000"`
PlacementEdgeURLOverrides PlacementEdgeURLOverrides `help:"placement-specific edge service URL overrides in the format {\"placementID\": {\"authService\": \"...\", \"publicLinksharing\": \"...\", \"internalLinksharing\": \"...\"}, \"placementID2\": ...}"` PlacementEdgeURLOverrides PlacementEdgeURLOverrides `help:"placement-specific edge service URL overrides in the format {\"placementID\": {\"authService\": \"...\", \"publicLinksharing\": \"...\", \"internalLinksharing\": \"...\"}, \"placementID2\": ...}"`
BlockExplorerURL string `help:"url of the transaction block explorer" default:"https://etherscan.io/"` BlockExplorerURL string `help:"url of the transaction block explorer" default:"https://etherscan.io/"`

View File

@ -51,6 +51,7 @@ type FrontendConfig struct {
NeededTransactionConfirmations int `json:"neededTransactionConfirmations"` NeededTransactionConfirmations int `json:"neededTransactionConfirmations"`
ObjectBrowserPaginationEnabled bool `json:"objectBrowserPaginationEnabled"` ObjectBrowserPaginationEnabled bool `json:"objectBrowserPaginationEnabled"`
BillingFeaturesEnabled bool `json:"billingFeaturesEnabled"` BillingFeaturesEnabled bool `json:"billingFeaturesEnabled"`
UnregisteredInviteEmailsEnabled bool `json:"unregisteredInviteEmailsEnabled"`
} }
// Satellites is a configuration value that contains a list of satellite names and addresses. // Satellites is a configuration value that contains a list of satellite names and addresses.

View File

@ -750,6 +750,7 @@ func (server *Server) frontendConfigHandler(w http.ResponseWriter, r *http.Reque
NeededTransactionConfirmations: server.neededTokenPaymentConfirmations, NeededTransactionConfirmations: server.neededTokenPaymentConfirmations,
ObjectBrowserPaginationEnabled: server.config.ObjectBrowserPaginationEnabled, ObjectBrowserPaginationEnabled: server.config.ObjectBrowserPaginationEnabled,
BillingFeaturesEnabled: server.config.BillingFeaturesEnabled, BillingFeaturesEnabled: server.config.BillingFeaturesEnabled,
UnregisteredInviteEmailsEnabled: server.config.UnregisteredInviteEmailsEnabled,
} }
err := json.NewEncoder(w).Encode(&cfg) err := json.NewEncoder(w).Encode(&cfg)

View File

@ -3821,7 +3821,7 @@ func (s *Service) inviteProjectMembers(ctx context.Context, sender *User, projec
} }
} }
unverifiedUsers = append(unverifiedUsers, oldest) unverifiedUsers = append(unverifiedUsers, oldest)
} else { } else if s.config.UnregisteredInviteEmailsEnabled {
newUserEmails = append(newUserEmails, email) newUserEmails = append(newUserEmails, email)
} }
} }

View File

@ -400,6 +400,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
# url link to terms and conditions page # url link to terms and conditions page
# console.terms-and-conditions-url: https://www.storj.io/terms-of-service/ # console.terms-and-conditions-url: https://www.storj.io/terms-of-service/
# indicates whether invitation emails can be sent to unregistered email addresses
# console.unregistered-invite-emails-enabled: false
# the default free-tier bandwidth usage limit # the default free-tier bandwidth usage limit
# console.usage-limits.bandwidth.free: 25.00 GB # console.usage-limits.bandwidth.free: 25.00 GB

View File

@ -61,7 +61,7 @@
</template> </template>
<script setup lang='ts'> <script setup lang='ts'>
import { computed, ref } from 'vue'; import { computed, h, ref } from 'vue';
import { Validator } from '@/utils/validation'; import { Validator } from '@/utils/validation';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames'; import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
@ -71,6 +71,7 @@ import { useProjectMembersStore } from '@/store/modules/projectMembersStore';
import { useAppStore } from '@/store/modules/appStore'; import { useAppStore } from '@/store/modules/appStore';
import { useProjectsStore } from '@/store/modules/projectsStore'; import { useProjectsStore } from '@/store/modules/projectsStore';
import { useAnalyticsStore } from '@/store/modules/analyticsStore'; import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { useConfigStore } from '@/store/modules/configStore';
import { useLoading } from '@/composables/useLoading'; import { useLoading } from '@/composables/useLoading';
import { MODALS } from '@/utils/constants/appStatePopUps'; import { MODALS } from '@/utils/constants/appStatePopUps';
@ -86,6 +87,8 @@ const appStore = useAppStore();
const pmStore = useProjectMembersStore(); const pmStore = useProjectMembersStore();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const configStore = useConfigStore();
const notify = useNotify(); const notify = useNotify();
const { isLoading, withLoading } = useLoading(); const { isLoading, withLoading } = useLoading();
@ -135,7 +138,18 @@ async function onPrimaryClick(): Promise<void> {
} }
analyticsStore.eventTriggered(AnalyticsEvent.PROJECT_MEMBERS_INVITE_SENT); analyticsStore.eventTriggered(AnalyticsEvent.PROJECT_MEMBERS_INVITE_SENT);
if (configStore.state.config.unregisteredInviteEmailsEnabled) {
notify.notify('Invite sent!'); notify.notify('Invite sent!');
} else {
notify.notify(() => [
h('p', { class: 'message-title' }, 'Invite sent!'),
h('p', { class: 'message-info' }, [
'An invitation will be sent to the email address if it belongs to a user on this satellite.',
]),
]);
}
pmStore.setSearchQuery(''); pmStore.setSearchQuery('');
try { try {

View File

@ -73,7 +73,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'; import { computed, onMounted, onBeforeUnmount, ref, h } from 'vue';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames'; import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { MODALS } from '@/utils/constants/appStatePopUps'; import { MODALS } from '@/utils/constants/appStatePopUps';
@ -168,7 +168,17 @@ async function resendInvites(): Promise<void> {
try { try {
await pmStore.reinviteMembers(pmStore.state.selectedProjectMembersEmails, projectsStore.state.selectedProject.id); await pmStore.reinviteMembers(pmStore.state.selectedProjectMembersEmails, projectsStore.state.selectedProject.id);
if (configStore.state.config.unregisteredInviteEmailsEnabled) {
notify.success('Invites re-sent!'); notify.success('Invites re-sent!');
} else {
notify.success(() => [
h('p', { class: 'message-title' }, 'Invites re-sent!'),
h('p', { class: 'message-info' }, [
'Invitations will be re-sent to the email addresses that belong to users on this satellite.',
]),
]);
}
} catch (error) { } catch (error) {
error.message = `Unable to resend project invitations. ${error.message}`; error.message = `Unable to resend project invitations. ${error.message}`;
notify.notifyError(error, AnalyticsErrorEventSource.PROJECT_MEMBERS_HEADER); notify.notifyError(error, AnalyticsErrorEventSource.PROJECT_MEMBERS_HEADER);

View File

@ -46,7 +46,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, h, onMounted, ref } from 'vue';
import { ProjectMemberItemModel } from '@/types/projectMembers'; import { ProjectMemberItemModel } from '@/types/projectMembers';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames'; import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
@ -57,6 +57,7 @@ import { useLoading } from '@/composables/useLoading';
import { MODALS } from '@/utils/constants/appStatePopUps'; import { MODALS } from '@/utils/constants/appStatePopUps';
import { useAppStore } from '@/store/modules/appStore'; import { useAppStore } from '@/store/modules/appStore';
import { useAnalyticsStore } from '@/store/modules/analyticsStore'; import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { useConfigStore } from '@/store/modules/configStore';
import VLoader from '@/components/common/VLoader.vue'; import VLoader from '@/components/common/VLoader.vue';
import HeaderArea from '@/components/team/HeaderArea.vue'; import HeaderArea from '@/components/team/HeaderArea.vue';
@ -69,6 +70,7 @@ const analyticsStore = useAnalyticsStore();
const appStore = useAppStore(); const appStore = useAppStore();
const pmStore = useProjectMembersStore(); const pmStore = useProjectMembersStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const configStore = useConfigStore();
const notify = useNotify(); const notify = useNotify();
const { withLoading } = useLoading(); const { withLoading } = useLoading();
@ -153,7 +155,18 @@ function onResendClick(member: ProjectMemberItemModel) {
analyticsStore.eventTriggered(AnalyticsEvent.RESEND_INVITE_CLICKED); analyticsStore.eventTriggered(AnalyticsEvent.RESEND_INVITE_CLICKED);
try { try {
await pmStore.reinviteMembers([member.getEmail()], projectsStore.state.selectedProject.id); await pmStore.reinviteMembers([member.getEmail()], projectsStore.state.selectedProject.id);
notify.notify('Invite resent!');
if (configStore.state.config.unregisteredInviteEmailsEnabled) {
notify.success('Invite re-sent!');
} else {
notify.success(() => [
h('p', { class: 'message-title' }, 'Invites re-sent!'),
h('p', { class: 'message-info' }, [
'The invitation will be re-sent to the email address if it belongs to a user on this satellite.',
]),
]);
}
pmStore.setSearchQuery(''); pmStore.setSearchQuery('');
} catch (error) { } catch (error) {
error.message = `Error resending invite. ${error.message}`; error.message = `Error resending invite. ${error.message}`;

View File

@ -57,11 +57,12 @@ export const useNotificationsStore = defineStore('notifications', () => {
addNotification(notification); addNotification(notification);
} }
function notifyInfo(message: NotificationMessage): void { function notifyInfo(message: NotificationMessage, title?: string): void {
const notification = new DelayedNotification( const notification = new DelayedNotification(
() => deleteNotification(notification.id), () => deleteNotification(notification.id),
NotificationType.Info, NotificationType.Info,
message, message,
title,
); );
addNotification(notification); addNotification(notification);

View File

@ -13,7 +13,7 @@ export class FrontendConfig {
satelliteName: string; satelliteName: string;
satelliteNodeURL: string; satelliteNodeURL: string;
stripePublicKey: string; stripePublicKey: string;
partneredSatellites: PartneredSatellite[]; partneredSatellites?: PartneredSatellite[];
defaultProjectLimit: number; defaultProjectLimit: number;
generalRequestURL: string; generalRequestURL: string;
projectLimitsIncreaseRequestURL: string; projectLimitsIncreaseRequestURL: string;
@ -48,6 +48,7 @@ export class FrontendConfig {
neededTransactionConfirmations: number; neededTransactionConfirmations: number;
objectBrowserPaginationEnabled: boolean; objectBrowserPaginationEnabled: boolean;
billingFeaturesEnabled: boolean; billingFeaturesEnabled: boolean;
unregisteredInviteEmailsEnabled: boolean;
} }
export class MultiCaptchaConfig { export class MultiCaptchaConfig {
@ -55,6 +56,7 @@ export class MultiCaptchaConfig {
hcaptcha: SingleCaptchaConfig; hcaptcha: SingleCaptchaConfig;
} }
// TODO: This class was added manually because TypeScript generation is broken.
export class PartneredSatellite { export class PartneredSatellite {
name: string; name: string;
address: string; address: string;

View File

@ -38,9 +38,9 @@ export class Notificator {
notificationsStore.notifyError(message, source); notificationsStore.notifyError(message, source);
} }
public notify(message: NotificationMessage): void { public notify(message: NotificationMessage, title?: string): void {
const notificationsStore = useNotificationsStore(); const notificationsStore = useNotificationsStore();
notificationsStore.notifyInfo(message); notificationsStore.notifyInfo(message, title);
} }
public warning(message: NotificationMessage): void { public warning(message: NotificationMessage): void {

View File

@ -157,6 +157,7 @@ import { useNotify } from '@/utils/hooks';
import { Project } from '@/types/projects'; import { Project } from '@/types/projects';
import { SortDirection, tableSizeOptions } from '@/types/common'; import { SortDirection, tableSizeOptions } from '@/types/common';
import { useUsersStore } from '@/store/modules/usersStore'; import { useUsersStore } from '@/store/modules/usersStore';
import { useConfigStore } from '@/store/modules/configStore';
import IconTrash from '@poc/components/icons/IconTrash.vue'; import IconTrash from '@poc/components/icons/IconTrash.vue';
import IconCopy from '@poc/components/icons/IconCopy.vue'; import IconCopy from '@poc/components/icons/IconCopy.vue';
@ -174,9 +175,9 @@ type RenderedItem = {
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const analyticsStore = useAnalyticsStore(); const analyticsStore = useAnalyticsStore();
const appStore = useAppStore();
const pmStore = useProjectMembersStore(); const pmStore = useProjectMembersStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const configStore = useConfigStore();
const router = useRouter(); const router = useRouter();
const notify = useNotify(); const notify = useNotify();
@ -304,7 +305,14 @@ async function resendInvite(email: string): Promise<void> {
analyticsStore.eventTriggered(AnalyticsEvent.RESEND_INVITE_CLICKED); analyticsStore.eventTriggered(AnalyticsEvent.RESEND_INVITE_CLICKED);
try { try {
await pmStore.reinviteMembers([email], selectedProject.value.id); await pmStore.reinviteMembers([email], selectedProject.value.id);
notify.notify('Invite resent!'); if (configStore.state.config.unregisteredInviteEmailsEnabled) {
notify.notify('Invite re-sent!');
} else {
notify.notify(
'The invitation will be re-sent to the email address if it belongs to a user on this satellite.',
'Invite re-sent!',
);
}
} catch (error) { } catch (error) {
error.message = `Error resending invite. ${error.message}`; error.message = `Error resending invite. ${error.message}`;
notify.notifyError(error, AnalyticsErrorEventSource.PROJECT_MEMBERS_PAGE); notify.notifyError(error, AnalyticsErrorEventSource.PROJECT_MEMBERS_PAGE);

View File

@ -131,6 +131,7 @@ import { useProjectMembersStore } from '@/store/modules/projectMembersStore';
import { useNotify } from '@/utils/hooks'; import { useNotify } from '@/utils/hooks';
import { useLoading } from '@/composables/useLoading'; import { useLoading } from '@/composables/useLoading';
import { useUsersStore } from '@/store/modules/usersStore'; import { useUsersStore } from '@/store/modules/usersStore';
import { useConfigStore } from '@/store/modules/configStore';
import UpgradeAccountDialog from '@poc/components/dialogs/upgradeAccountFlow/UpgradeAccountDialog.vue'; import UpgradeAccountDialog from '@poc/components/dialogs/upgradeAccountFlow/UpgradeAccountDialog.vue';
@ -151,6 +152,8 @@ const model = computed<boolean>({
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const analyticsStore = useAnalyticsStore(); const analyticsStore = useAnalyticsStore();
const pmStore = useProjectMembersStore(); const pmStore = useProjectMembersStore();
const configStore = useConfigStore();
const notify = useNotify(); const notify = useNotify();
const { isLoading, withLoading } = useLoading(); const { isLoading, withLoading } = useLoading();
@ -184,7 +187,16 @@ async function onPrimaryClick(): Promise<void> {
await withLoading(async () => { await withLoading(async () => {
try { try {
await pmStore.inviteMember(email.value, props.projectId); await pmStore.inviteMember(email.value, props.projectId);
if (configStore.state.config.unregisteredInviteEmailsEnabled) {
notify.success('Invite sent!'); notify.success('Invite sent!');
} else {
notify.success(
'An invitation will be sent to the email address if it belongs to a user on this satellite.',
'Invite sent!',
);
}
email.value = ''; email.value = '';
} catch (error) { } catch (error) {
error.message = `Error inviting project member. ${error.message}`; error.message = `Error inviting project member. ${error.message}`;