feat(sh-admin): introducing advanced SMTP configurations and invite links to dashboard (#4087)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Joel Jacob Stephen
2024-06-28 00:45:50 +05:30
committed by GitHub
parent b851d3003c
commit 1d1462df69
11 changed files with 407 additions and 99 deletions

View File

@@ -33,12 +33,21 @@
}, },
"load_error": "Unable to load server configurations", "load_error": "Unable to load server configurations",
"mail_configs": { "mail_configs": {
"description": " Configure the smtp configurations",
"enable": "Email based authentication",
"smtp_url": "MAILER SMTP URL",
"address_from": "MAILER FROM ADDRESS", "address_from": "MAILER FROM ADDRESS",
"custom_smtp_configs": "Use Custom SMTP Configurations",
"description": " Configure the smtp configurations",
"enable_email_auth": "Enable Email based authentication",
"enable_smtp": "Enable SMTP",
"host": "MAILER HOST",
"password": "MAILER PASSWORD",
"port": "MAILER PORT",
"secure": "MAILER SECURE",
"smtp_url": "MAILER SMTP URL",
"tls_reject_unauthorized": "TLS REJECT UNAUTHORIZED",
"title": "SMTP Configurations", "title": "SMTP Configurations",
"update_failure": "Failed to update smtp configurations!!" "toggle_failure": "Failed to toggle smtp!!",
"update_failure": "Failed to update smtp configurations!!",
"user": "MAILER USER"
}, },
"reset": { "reset": {
"confirm_reset": "Hoppscotch server must restart to reflect the new changes. Confirm the reset of server configurations?", "confirm_reset": "Hoppscotch server must restart to reflect the new changes. Confirm the reset of server configurations?",
@@ -210,6 +219,7 @@
"admin_id": "Admin ID", "admin_id": "Admin ID",
"cancel": "Cancel", "cancel": "Cancel",
"confirm_team_deletion": "Confirm deletion of the workspace?", "confirm_team_deletion": "Confirm deletion of the workspace?",
"copy": "Copy",
"create_team": "Create Workspace", "create_team": "Create Workspace",
"date": "Date", "date": "Date",
"delete_team": "Delete Workspace", "delete_team": "Delete Workspace",
@@ -262,6 +272,7 @@
"admin_id": "Admin ID", "admin_id": "Admin ID",
"cancel": "Cancel", "cancel": "Cancel",
"created_on": "Created On", "created_on": "Created On",
"copy_link": "Copy Link",
"date": "Date", "date": "Date",
"delete": "Delete", "delete": "Delete",
"delete_user": "Delete User", "delete_user": "Delete User",

View File

@@ -1,46 +1,45 @@
// generated by unplugin-vue-components // generated by unplugin-vue-components
// We suggest you to commit this file into source control // We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core' import '@vue/runtime-core';
export {} export {};
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AppHeader: typeof import('./components/app/Header.vue')['default'] AppHeader: typeof import('./components/app/Header.vue')['default'];
AppLogin: typeof import('./components/app/Login.vue')['default'] AppLogin: typeof import('./components/app/Login.vue')['default'];
AppLogout: typeof import('./components/app/Logout.vue')['default'] AppLogout: typeof import('./components/app/Logout.vue')['default'];
AppModal: typeof import('./components/app/Modal.vue')['default'] AppModal: typeof import('./components/app/Modal.vue')['default'];
AppSidebar: typeof import('./components/app/Sidebar.vue')['default'] AppSidebar: typeof import('./components/app/Sidebar.vue')['default'];
AppToast: typeof import('./components/app/Toast.vue')['default'] AppToast: typeof import('./components/app/Toast.vue')['default'];
DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'] DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'];
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'] HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'];
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'] HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'];
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'] HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'];
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'];
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'] HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'];
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'];
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'] HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'];
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'] HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'];
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'];
IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] IconLucideInbox: typeof import('~icons/lucide/inbox')['default'];
SettingsAuthProvider: typeof import('./components/settings/AuthProvider.vue')['default'] SettingsAuthProvider: typeof import('./components/settings/AuthProvider.vue')['default'];
SettingsConfigurations: typeof import('./components/settings/Configurations.vue')['default'] SettingsConfigurations: typeof import('./components/settings/Configurations.vue')['default'];
SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default'] SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default'];
SettingsReset: typeof import('./components/settings/Reset.vue')['default'] SettingsReset: typeof import('./components/settings/Reset.vue')['default'];
SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default'] SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default'];
SettingsSmtpConfiguration: typeof import('./components/settings/SmtpConfiguration.vue')['default'] SettingsSmtpConfiguration: typeof import('./components/settings/SmtpConfiguration.vue')['default'];
SetupDataSharingAndNewsletter: typeof import('./components/setup/DataSharingAndNewsletter.vue')['default'] SetupDataSharingAndNewsletter: typeof import('./components/setup/DataSharingAndNewsletter.vue')['default'];
TeamsAdd: typeof import('./components/teams/Add.vue')['default'] TeamsAdd: typeof import('./components/teams/Add.vue')['default'];
TeamsDetails: typeof import('./components/teams/Details.vue')['default'] TeamsDetails: typeof import('./components/teams/Details.vue')['default'];
TeamsInvite: typeof import('./components/teams/Invite.vue')['default'] TeamsInvite: typeof import('./components/teams/Invite.vue')['default'];
TeamsMembers: typeof import('./components/teams/Members.vue')['default'] TeamsMembers: typeof import('./components/teams/Members.vue')['default'];
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default'] TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default'];
Tippy: typeof import('vue-tippy')['Tippy'] Tippy: typeof import('vue-tippy')['Tippy'];
UiAutoResetIcon: typeof import('./components/ui/AutoResetIcon.vue')['default'] UiAutoResetIcon: typeof import('./components/ui/AutoResetIcon.vue')['default'];
UsersDetails: typeof import('./components/users/Details.vue')['default'] UsersDetails: typeof import('./components/users/Details.vue')['default'];
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default'] UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default'];
UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default'] UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default'];
} }
} }

View File

@@ -22,6 +22,7 @@ import {
EnableAndDisableSsoDocument, EnableAndDisableSsoDocument,
ResetInfraConfigsDocument, ResetInfraConfigsDocument,
ToggleAnalyticsCollectionDocument, ToggleAnalyticsCollectionDocument,
ToggleSmtpDocument,
UpdateInfraConfigsDocument, UpdateInfraConfigsDocument,
} from '~/helpers/backend/graphql'; } from '~/helpers/backend/graphql';
import { ServerConfigs } from '~/helpers/configs'; import { ServerConfigs } from '~/helpers/configs';
@@ -52,6 +53,7 @@ const updateAllowedAuthProviderMutation = useMutation(
const toggleDataSharingMutation = useMutation( const toggleDataSharingMutation = useMutation(
ToggleAnalyticsCollectionDocument ToggleAnalyticsCollectionDocument
); );
const toggleSMTPMutation = useMutation(ToggleSmtpDocument);
// Mutation handlers // Mutation handlers
const { const {
@@ -59,6 +61,7 @@ const {
updateAuthProvider, updateAuthProvider,
resetInfraConfigs, resetInfraConfigs,
updateDataSharingConfigs, updateDataSharingConfigs,
toggleSMTPConfigs,
} = useConfigHandler(props.workingConfigs); } = useConfigHandler(props.workingConfigs);
// Call relevant mutations on component mount and initiate server restart // Call relevant mutations on component mount and initiate server restart
@@ -111,6 +114,12 @@ onMounted(async () => {
if (!dataSharingResult) { if (!dataSharingResult) {
return triggerComponentUnMount(); return triggerComponentUnMount();
} }
const smtpResult = await toggleSMTPConfigs(toggleSMTPMutation);
if (!smtpResult) {
return triggerComponentUnMount();
}
} }
restart.value = true; restart.value = true;

View File

@@ -22,30 +22,66 @@
:on="smtpConfigs.enabled" :on="smtpConfigs.enabled"
@change="smtpConfigs.enabled = !smtpConfigs.enabled" @change="smtpConfigs.enabled = !smtpConfigs.enabled"
> >
{{ t('configs.mail_configs.enable') }} {{ t('configs.mail_configs.enable_smtp') }}
</HoppSmartToggle> </HoppSmartToggle>
</div> </div>
<div v-if="smtpConfigs.enabled" class="ml-12"> <div v-if="smtpConfigs.enabled" class="ml-12">
<div class="flex flex-col items-start gap-5">
<HoppSmartCheckbox
:on="smtpConfigs.fields.email_auth"
@change="
smtpConfigs.fields.email_auth = !smtpConfigs.fields.email_auth
"
>
{{ t('configs.mail_configs.enable_email_auth') }}
</HoppSmartCheckbox>
<HoppSmartCheckbox
:on="smtpConfigs.fields.mailer_use_custom_configs"
:title="t('configs.mail_configs.custom_smtp_configs')"
@change="
smtpConfigs.fields.mailer_use_custom_configs =
!smtpConfigs.fields.mailer_use_custom_configs
"
>
{{ t('configs.mail_configs.custom_smtp_configs') }}
</HoppSmartCheckbox>
</div>
<div <div
v-for="field in smtpConfigFields" v-for="field in smtpConfigFields"
:key="field.key" :key="field.key"
class="mt-5" class="mt-5 ml-12"
> >
<label>{{ field.name }}</label> <div v-if="fieldCondition(field)">
<span class="flex max-w-lg"> <div
<HoppSmartInput v-if="isCheckboxField(field)"
v-model="smtpConfigs.fields[field.key]" class="flex flex-col items-start gap-5"
:type="isMasked(field.key) ? 'password' : 'text'" >
:autofocus="false" <HoppSmartCheckbox
class="!my-2 !bg-primaryLight flex-1" :on="Boolean(smtpConfigs.fields[field.key])"
/> @change="toggleCheckbox(field)"
<HoppButtonSecondary >
:icon="isMasked(field.key) ? IconEye : IconEyeOff" {{ field.name }}
class="bg-primaryLight h-9 mt-2" </HoppSmartCheckbox>
@click="toggleMask(field.key)" </div>
/> <span v-else>
</span> <label>{{ field.name }}</label>
<span class="flex max-w-lg">
<HoppSmartInput
v-model="smtpConfigs.fields[field.key]"
:type="isMasked(field.key) ? 'password' : 'text'"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
/>
<HoppButtonSecondary
:icon="isMasked(field.key) ? IconEye : IconEyeOff"
class="bg-primaryLight h-9 mt-2"
@click="toggleMask(field.key)"
/>
</span>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -91,13 +127,47 @@ type Field = {
}; };
const smtpConfigFields = reactive<Field[]>([ const smtpConfigFields = reactive<Field[]>([
{ name: t('configs.mail_configs.smtp_url'), key: 'mailer_smtp_url' }, {
{ name: t('configs.mail_configs.address_from'), key: 'mailer_from_address' }, name: t('configs.mail_configs.smtp_url'),
key: 'mailer_smtp_url',
},
{
name: t('configs.mail_configs.address_from'),
key: 'mailer_from_address',
},
{
name: t('configs.mail_configs.host'),
key: 'mailer_smtp_host',
},
{
name: t('configs.mail_configs.port'),
key: 'mailer_smtp_port',
},
{
name: t('configs.mail_configs.user'),
key: 'mailer_smtp_user',
},
{
name: t('configs.mail_configs.password'),
key: 'mailer_smtp_password',
},
{
name: t('configs.mail_configs.secure'),
key: 'mailer_smtp_secure',
},
{
name: t('configs.mail_configs.tls_reject_unauthorized'),
key: 'mailer_tls_reject_unauthorized',
},
]); ]);
const maskState = reactive<Record<string, boolean>>({ const maskState = reactive<Record<string, boolean>>({
mailer_smtp_url: true, mailer_smtp_url: true,
mailer_from_address: true, mailer_from_address: true,
mailer_smtp_host: true,
mailer_smtp_port: true,
mailer_smtp_user: true,
mailer_smtp_password: true,
}); });
const toggleMask = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) => { const toggleMask = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) => {
@@ -106,4 +176,35 @@ const toggleMask = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) => {
const isMasked = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) => const isMasked = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) =>
maskState[fieldKey]; maskState[fieldKey];
const fieldCondition = (field: Field) => {
const advancedFields = [
'mailer_smtp_host',
'mailer_smtp_port',
'mailer_smtp_user',
'mailer_smtp_password',
'mailer_smtp_secure',
'mailer_tls_reject_unauthorized',
];
const basicFields = ['mailer_smtp_url'];
if (field.key === 'mailer_from_address') {
return true;
}
if (smtpConfigs.value.fields.mailer_use_custom_configs) {
return (
!basicFields.includes(field.key) && advancedFields.includes(field.key)
);
} else return basicFields.includes(field.key);
};
const isCheckboxField = (field: Field) => {
const checkboxKeys = ['mailer_smtp_secure', 'mailer_tls_reject_unauthorized'];
return checkboxKeys.includes(field.key);
};
const toggleCheckbox = (field: Field) =>
((smtpConfigs.value.fields[field.key] as boolean) =
!smtpConfigs.value.fields[field.key]);
</script> </script>

View File

@@ -1,5 +1,7 @@
<template> <template>
<div class="border rounded divide-y divide-dividerLight border-divider my-8"> <div
class="border rounded divide-y divide-dividerLight border-divider mx-4 my-8"
>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-if="team && pendingInvites?.length === 0" v-if="team && pendingInvites?.length === 0"
text="No pending invites" text="No pending invites"
@@ -26,16 +28,22 @@
:value="invitee.inviteeRole" :value="invitee.inviteeRole"
readonly readonly
/> />
<div class="flex"> <HoppButtonSecondary
<HoppButtonSecondary v-tippy="{ theme: 'tooltip' }"
v-tippy="{ theme: 'tooltip' }" :title="t('teams.copy')"
:title="t('teams.remove')" :icon="IconCopy"
:icon="IconTrash" color="white"
color="red" :loading="isLoadingIndex === index"
:loading="isLoadingIndex === index" @click="copyInviteLink(invitee.id)"
@click="removeInvitee(invitee.id, index)" />
/> <HoppButtonSecondary
</div> v-tippy="{ theme: 'tooltip' }"
:title="t('teams.remove')"
:icon="IconTrash"
color="red"
:loading="isLoadingIndex === index"
@click="removeInvitee(invitee.id, index)"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -51,6 +59,8 @@ import {
RevokeTeamInvitationDocument, RevokeTeamInvitationDocument,
TeamInfoQuery, TeamInfoQuery,
} from '~/helpers/backend/graphql'; } from '~/helpers/backend/graphql';
import { copyToClipboard } from '~/helpers/utils/clipboard';
import IconCopy from '~icons/lucide/copy';
import IconTrash from '~icons/lucide/trash'; import IconTrash from '~icons/lucide/trash';
const t = useI18n(); const t = useI18n();
@@ -96,4 +106,11 @@ const removeInvitee = async (id: string, index: number) => {
} }
isLoadingIndex.value = null; isLoadingIndex.value = null;
}; };
const baseURL = import.meta.env.VITE_BASE_URL ?? '';
const copyInviteLink = (inviteID: string) => {
copyToClipboard(`${baseURL}/join-team?id=${inviteID}`);
toast.success(t('state.copied_to_clipboard'));
};
</script> </script>

View File

@@ -9,14 +9,14 @@
v-model="email" v-model="email"
:label="t('users.email_address')" :label="t('users.email_address')"
input-styles="floating-input" input-styles="floating-input"
@submit="sendInvite" @submit="emit('send-invite', email)"
/> />
</template> </template>
<template #footer> <template #footer>
<span class="flex space-x-2"> <span class="flex space-x-2">
<HoppButtonPrimary <HoppButtonPrimary
:label="t('users.send_invite')" :label="t('users.send_invite')"
@click="sendInvite" @click="emit('send-invite', email)"
/> />
<HoppButtonSecondary <HoppButtonSecondary
:label="t('users.cancel')" :label="t('users.cancel')"
@@ -25,6 +25,12 @@
@click="hideModal" @click="hideModal"
/> />
</span> </span>
<HoppButtonSecondary
:label="t('users.copy_link')"
outline
filled
@click="emit('copy-invite-link', email)"
/>
</template> </template>
</HoppSmartModal> </HoppSmartModal>
</template> </template>
@@ -32,26 +38,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from '~/composables/i18n'; import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
const t = useI18n(); const t = useI18n();
const toast = useToast();
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'hide-modal'): void; (event: 'hide-modal'): void;
(event: 'send-invite', email: string): void; (event: 'send-invite', email: string): void;
(event: 'copy-invite-link', email: string): void;
}>(); }>();
const email = ref(''); const email = ref('');
const sendInvite = () => {
if (email.value.trim() === '') {
toast.error(t('users.valid_email'));
return;
}
emit('send-invite', email.value);
};
const hideModal = () => { const hideModal = () => {
emit('hide-modal'); emit('hide-modal');
}; };

View File

@@ -1,7 +1,6 @@
import { AnyVariables, UseMutationResponse } from '@urql/vue'; import { AnyVariables, UseMutationResponse } from '@urql/vue';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from '~/composables/i18n'; import { useI18n } from '~/composables/i18n';
import { import {
AllowedAuthProvidersDocument, AllowedAuthProvidersDocument,
@@ -14,10 +13,12 @@ import {
ResetInfraConfigsMutation, ResetInfraConfigsMutation,
ServiceStatus, ServiceStatus,
ToggleAnalyticsCollectionMutation, ToggleAnalyticsCollectionMutation,
ToggleSmtpMutation,
UpdateInfraConfigsMutation, UpdateInfraConfigsMutation,
} from '~/helpers/backend/graphql'; } from '~/helpers/backend/graphql';
import { import {
ALL_CONFIGS, ALL_CONFIGS,
CUSTOM_MAIL_CONFIGS,
ConfigSection, ConfigSection,
ConfigTransform, ConfigTransform,
GITHUB_CONFIGS, GITHUB_CONFIGS,
@@ -114,10 +115,24 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
}, },
mailConfigs: { mailConfigs: {
name: 'email', name: 'email',
enabled: allowedAuthProviders.value.includes(AuthProvider.Email), enabled: getFieldValue(InfraConfigEnum.MailerSmtpEnable) === 'true',
fields: { fields: {
email_auth: allowedAuthProviders.value.includes(AuthProvider.Email),
mailer_smtp_url: getFieldValue(InfraConfigEnum.MailerSmtpUrl), mailer_smtp_url: getFieldValue(InfraConfigEnum.MailerSmtpUrl),
mailer_from_address: getFieldValue(InfraConfigEnum.MailerAddressFrom), mailer_from_address: getFieldValue(InfraConfigEnum.MailerAddressFrom),
mailer_smtp_host: getFieldValue(InfraConfigEnum.MailerSmtpHost),
mailer_smtp_port: getFieldValue(InfraConfigEnum.MailerSmtpPort),
mailer_smtp_user: getFieldValue(InfraConfigEnum.MailerSmtpUser),
mailer_smtp_password: getFieldValue(
InfraConfigEnum.MailerSmtpPassword
),
mailer_smtp_secure:
getFieldValue(InfraConfigEnum.MailerSmtpSecure) === 'true',
mailer_tls_reject_unauthorized:
getFieldValue(InfraConfigEnum.MailerTlsRejectUnauthorized) ===
'true',
mailer_use_custom_configs:
getFieldValue(InfraConfigEnum.MailerUseCustomConfigs) === 'true',
}, },
}, },
dataSharingConfigs: { dataSharingConfigs: {
@@ -133,11 +148,19 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
workingConfigs.value = cloneDeep(currentConfigs.value); workingConfigs.value = cloneDeep(currentConfigs.value);
}); });
// Check if custom mail config is enabled
const isCustomMailConfigEnabled =
updatedConfigs?.mailConfigs.fields.mailer_use_custom_configs;
/* /*
Check if any of the config fields are empty Check if any of the config fields are empty
*/ */
const isFieldEmpty = (field: string | boolean) => {
const isFieldEmpty = (field: string) => field.trim() === ''; if (typeof field === 'boolean') {
return false;
}
return field.trim() === '';
};
const AreAnyConfigFieldsEmpty = (config: ServerConfigs): boolean => { const AreAnyConfigFieldsEmpty = (config: ServerConfigs): boolean => {
const sections: Array<ConfigSection> = [ const sections: Array<ConfigSection> = [
@@ -147,12 +170,56 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
config.mailConfigs, config.mailConfigs,
]; ];
return sections.some( const hasSectionWithEmptyFields = sections.some((section) => {
(section) => if (
section.name === 'email' &&
!section.fields.mailer_use_custom_configs
) {
return (
section.enabled &&
Object.entries(section.fields).some(
([key, value]) =>
isFieldEmpty(value) &&
key !== 'mailer_smtp_host' &&
key !== 'mailer_smtp_port' &&
key !== 'mailer_smtp_user' &&
key !== 'mailer_smtp_password'
)
);
}
return (
section.enabled && Object.values(section.fields).some(isFieldEmpty) section.enabled && Object.values(section.fields).some(isFieldEmpty)
); );
});
return hasSectionWithEmptyFields;
}; };
// Extract the mail config fields (excluding the custom mail config fields)
const mailConfigFields = Object.fromEntries(
Object.entries(updatedConfigs?.mailConfigs.fields ?? {}).filter(([key]) => {
if (isCustomMailConfigEnabled) {
return MAIL_CONFIGS.some(
(x) =>
x.key === key &&
key !== 'mailer_smtp_url' &&
key !== 'mailer_smtp_enabled'
);
} else
return MAIL_CONFIGS.some(
(x) => x.key === key && key !== 'mailer_smtp_enabled'
);
})
);
// Extract the custom mail config fields
const customMailConfigFields = Object.fromEntries(
Object.entries(updatedConfigs?.mailConfigs.fields ?? {}).filter(([key]) =>
CUSTOM_MAIL_CONFIGS.some((x) => x.key === key)
)
);
// Transforming the working configs back into the format required by the mutations // Transforming the working configs back into the format required by the mutations
const transformInfraConfigs = () => { const transformInfraConfigs = () => {
const updatedWorkingConfigs: ConfigTransform[] = [ const updatedWorkingConfigs: ConfigTransform[] = [
@@ -174,7 +241,12 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
{ {
config: MAIL_CONFIGS, config: MAIL_CONFIGS,
enabled: updatedConfigs?.mailConfigs.enabled, enabled: updatedConfigs?.mailConfigs.enabled,
fields: updatedConfigs?.mailConfigs.fields, fields: mailConfigFields,
},
{
config: CUSTOM_MAIL_CONFIGS,
enabled: isCustomMailConfigEnabled,
fields: customMailConfigFields,
}, },
]; ];
@@ -182,7 +254,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
updatedWorkingConfigs.forEach(({ config, enabled, fields }) => { updatedWorkingConfigs.forEach(({ config, enabled, fields }) => {
config.forEach(({ name, key }) => { config.forEach(({ name, key }) => {
if (enabled && fields) { if (name === 'MAILER_SMTP_ENABLE') return;
else if (isCustomMailConfigEnabled && name === 'MAILER_SMTP_URL')
return;
else if (enabled && fields) {
const value = const value =
typeof fields === 'string' ? fields : String(fields[key]); typeof fields === 'string' ? fields : String(fields[key]);
transformedConfigs.push({ name, value }); transformedConfigs.push({ name, value });
@@ -239,7 +314,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
}, },
{ {
provider: AuthProvider.Email, provider: AuthProvider.Email,
status: updatedConfigs?.mailConfigs.enabled status: updatedConfigs?.mailConfigs.fields.email_auth
? ServiceStatus.Enable ? ServiceStatus.Enable
: ServiceStatus.Disable, : ServiceStatus.Disable,
}, },
@@ -281,6 +356,7 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
'configs.reset.failure' 'configs.reset.failure'
); );
// Toggle Data Sharing
const updateDataSharingConfigs = ( const updateDataSharingConfigs = (
toggleDataSharingMutation: UseMutationResponse<ToggleAnalyticsCollectionMutation> toggleDataSharingMutation: UseMutationResponse<ToggleAnalyticsCollectionMutation>
) => ) =>
@@ -294,11 +370,26 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
'configs.data_sharing.update_failure' 'configs.data_sharing.update_failure'
); );
// Toggle SMTP
const toggleSMTPConfigs = (
toggleSMTP: UseMutationResponse<ToggleSmtpMutation>
) =>
executeMutation(
toggleSMTP,
{
status: updatedConfigs?.mailConfigs.enabled
? ServiceStatus.Enable
: ServiceStatus.Disable,
},
'configs.mail_configs.toggle_failure'
);
return { return {
currentConfigs, currentConfigs,
workingConfigs, workingConfigs,
updateAuthProvider, updateAuthProvider,
updateDataSharingConfigs, updateDataSharingConfigs,
toggleSMTPConfigs,
updateInfraConfigs, updateInfraConfigs,
resetInfraConfigs, resetInfraConfigs,
fetchingInfraConfigs, fetchingInfraConfigs,

View File

@@ -0,0 +1,3 @@
mutation ToggleSMTP($status: ServiceStatus!) {
toggleSMTP(status: $status)
}

View File

@@ -41,8 +41,16 @@ export type ServerConfigs = {
name: string; name: string;
enabled: boolean; enabled: boolean;
fields: { fields: {
email_auth: boolean;
mailer_smtp_url: string; mailer_smtp_url: string;
mailer_from_address: string; mailer_from_address: string;
mailer_smtp_host: string;
mailer_smtp_port: string;
mailer_smtp_user: string;
mailer_smtp_password: string;
mailer_smtp_secure: boolean;
mailer_tls_reject_unauthorized: boolean;
mailer_use_custom_configs: boolean;
}; };
}; };
@@ -64,8 +72,9 @@ export type ConfigTransform = {
}; };
export type ConfigSection = { export type ConfigSection = {
name: SsoAuthProviders | string;
enabled: boolean; enabled: boolean;
fields: Record<string, string>; fields: Record<string, string | boolean>;
}; };
export type Config = { export type Config = {
@@ -143,6 +152,41 @@ export const MAIL_CONFIGS: Config[] = [
name: InfraConfigEnum.MailerAddressFrom, name: InfraConfigEnum.MailerAddressFrom,
key: 'mailer_from_address', key: 'mailer_from_address',
}, },
{
name: InfraConfigEnum.MailerSmtpEnable,
key: 'mailer_smtp_enabled',
},
{
name: InfraConfigEnum.MailerUseCustomConfigs,
key: 'mailer_use_custom_configs',
},
];
export const CUSTOM_MAIL_CONFIGS: Config[] = [
{
name: InfraConfigEnum.MailerSmtpHost,
key: 'mailer_smtp_host',
},
{
name: InfraConfigEnum.MailerSmtpPort,
key: 'mailer_smtp_port',
},
{
name: InfraConfigEnum.MailerSmtpUser,
key: 'mailer_smtp_user',
},
{
name: InfraConfigEnum.MailerSmtpPassword,
key: 'mailer_smtp_password',
},
{
name: InfraConfigEnum.MailerSmtpSecure,
key: 'mailer_smtp_secure',
},
{
name: InfraConfigEnum.MailerTlsRejectUnauthorized,
key: 'mailer_tls_reject_unauthorized',
},
]; ];
const DATA_SHARING_CONFIGS: Omit<Config, 'key'>[] = [ const DATA_SHARING_CONFIGS: Omit<Config, 'key'>[] = [
@@ -156,5 +200,6 @@ export const ALL_CONFIGS = [
MICROSOFT_CONFIGS, MICROSOFT_CONFIGS,
GITHUB_CONFIGS, GITHUB_CONFIGS,
MAIL_CONFIGS, MAIL_CONFIGS,
CUSTOM_MAIL_CONFIGS,
DATA_SHARING_CONFIGS, DATA_SHARING_CONFIGS,
]; ];

View File

@@ -207,6 +207,7 @@
v-if="showInviteUserModal" v-if="showInviteUserModal"
@hide-modal="showInviteUserModal = false" @hide-modal="showInviteUserModal = false"
@send-invite="sendInvite" @send-invite="sendInvite"
@copy-invite-link="copyInviteLink"
/> />
<HoppSmartConfirmModal <HoppSmartConfirmModal
:show="confirmUsersToAdmin" :show="confirmUsersToAdmin"
@@ -262,6 +263,7 @@ import {
} from '~/helpers/backend/graphql'; } from '~/helpers/backend/graphql';
import { getCompiledErrorMessage } from '~/helpers/errors'; import { getCompiledErrorMessage } from '~/helpers/errors';
import { handleUserDeletion } from '~/helpers/userManagement'; import { handleUserDeletion } from '~/helpers/userManagement';
import { copyToClipboard } from '~/helpers/utils/clipboard';
import IconCheck from '~icons/lucide/check'; import IconCheck from '~icons/lucide/check';
import IconLeft from '~icons/lucide/chevron-left'; import IconLeft from '~icons/lucide/chevron-left';
import IconRight from '~icons/lucide/chevron-right'; import IconRight from '~icons/lucide/chevron-right';
@@ -451,11 +453,16 @@ const showInviteUserModal = ref(false);
const sendInvitation = useMutation(InviteNewUserDocument); const sendInvitation = useMutation(InviteNewUserDocument);
const sendInvite = async (email: string) => { const sendInvite = async (email: string) => {
if (!email.trim()) { const trimmedEmail = email.trim();
if (!trimmedEmail) {
toast.error(t('state.invalid_email')); toast.error(t('state.invalid_email'));
return; return false;
} else if (trimmedEmail === '') {
toast.error(t('users.valid_email'));
return false;
} }
const variables = { inviteeEmail: email.trim() };
const variables = { inviteeEmail: trimmedEmail };
const result = await sendInvitation.executeMutation(variables); const result = await sendInvitation.executeMutation(variables);
if (result.error) { if (result.error) {
const { message } = result.error; const { message } = result.error;
@@ -464,12 +471,23 @@ const sendInvite = async (email: string) => {
compiledErrorMessage compiledErrorMessage
? toast.error(t(compiledErrorMessage)) ? toast.error(t(compiledErrorMessage))
: toast.error(t('state.email_failure')); : toast.error(t('state.email_failure'));
return false;
} else { } else {
toast.success(t('state.email_success')); toast.success(t('state.email_success'));
showInviteUserModal.value = false; showInviteUserModal.value = false;
return true;
} }
}; };
const copyInviteLink = async (email: string) => {
const result = await sendInvite(email);
if (!result) return;
const baseURL = import.meta.env.VITE_BASE_URL ?? '';
copyToClipboard(baseURL);
toast.success(t('state.copied_to_clipboard'));
};
// Make Multiple Users Admin // Make Multiple Users Admin
const confirmUsersToAdmin = ref(false); const confirmUsersToAdmin = ref(false);
const usersToAdminUID = ref<string | null>(null); const usersToAdminUID = ref<string | null>(null);

View File

@@ -6,9 +6,18 @@
</button> </button>
</div> </div>
<h3 class="text-lg font-bold text-accentContrast pt-6 pb-4"> <div class="flex justify-between items-center">
{{ t('users.pending_invites') }} <h3 class="text-lg font-bold text-accentContrast pt-6 pb-4">
</h3> {{ t('users.pending_invites') }}
</h3>
<HoppButtonSecondary
:label="t('users.copy_link')"
outline
filled
@click="copyInviteLink"
/>
</div>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="relative py-2 overflow-x-auto"> <div class="relative py-2 overflow-x-auto">
@@ -107,6 +116,7 @@ import { computed, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from '~/composables/i18n'; import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast'; import { useToast } from '~/composables/toast';
import { copyToClipboard } from '~/helpers/utils/clipboard';
import IconTrash from '~icons/lucide/trash'; import IconTrash from '~icons/lucide/trash';
import { import {
InvitedUsersDocument, InvitedUsersDocument,
@@ -185,4 +195,11 @@ const deleteInvitation = async (email: string | null) => {
confirmDeletion.value = false; confirmDeletion.value = false;
inviteToBeDeleted.value = null; inviteToBeDeleted.value = null;
}; };
const baseURL = import.meta.env.VITE_BASE_URL ?? '';
const copyInviteLink = () => {
copyToClipboard(baseURL);
toast.success(t('state.copied_to_clipboard'));
};
</script> </script>