feat(sh-admin): introducing data analytics and newsletter configurations (#3845)

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
Joel Jacob Stephen
2024-03-06 20:06:48 +05:30
committed by GitHub
parent 4798d7bbbd
commit 919579b1da
18 changed files with 575 additions and 156 deletions

View File

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

View File

@@ -2,6 +2,7 @@
<div>
<SettingsAuthProvider v-model:config="workingConfigs" />
<SettingsSmtpConfiguration v-model:config="workingConfigs" />
<SettingsDataSharing v-model:config="workingConfigs" />
<SettingsReset />
</div>
</template>

View File

@@ -0,0 +1,66 @@
<template>
<div class="md:grid md:grid-cols-3 md:gap-4 border-divider border-b py-8">
<div class="px-8 md:col-span-1">
<h3 class="heading">{{ t('configs.data_sharing.title') }}</h3>
<p class="my-1 text-secondaryLight">
{{ t('configs.data_sharing.description') }}
</p>
</div>
<div class="mx-8 md:col-span-2">
<h4 class="font-semibold text-secondaryDark">
{{ t('configs.data_sharing.title') }}
</h4>
<div class="flex items-center space-y-4 py-4">
<HoppSmartToggle
:on="dataSharingConfigs.enabled"
@change="dataSharingConfigs.enabled = !dataSharingConfigs.enabled"
>
{{ t('configs.data_sharing.toggle_description') }}
</HoppSmartToggle>
</div>
<!-- TODO: Update the link below -->
<HoppButtonSecondary
outline
filled
:icon="IconShieldQuestion"
:label="t('configs.data_sharing.see_shared')"
to="http://docs.hoppscotch.io"
blank
class="w-min my-2"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core';
import { computed } from 'vue';
import { useI18n } from '~/composables/i18n';
import { Config } from '~/composables/useConfigHandler';
import IconShieldQuestion from '~icons/lucide/shield-question';
const t = useI18n();
const props = defineProps<{
config: Config;
}>();
const emit = defineEmits<{
(e: 'update:config', v: Config): void;
}>();
const workingConfigs = useVModel(props, 'config', emit);
// Data Sharing Configs
const dataSharingConfigs = computed({
get() {
return workingConfigs.value?.dataSharingConfigs;
},
set(value) {
workingConfigs.value.dataSharingConfigs = value;
},
});
</script>

View File

@@ -22,6 +22,7 @@ import {
EnableAndDisableSsoDocument,
ResetInfraConfigsDocument,
UpdateInfraConfigsDocument,
ToggleAnalyticsCollectionDocument,
} from '~/helpers/backend/graphql';
const t = useI18n();
@@ -43,10 +44,17 @@ const updateInfraConfigsMutation = useMutation(UpdateInfraConfigsDocument);
const updateAllowedAuthProviderMutation = useMutation(
EnableAndDisableSsoDocument
);
const toggleDataSharingMutation = useMutation(
ToggleAnalyticsCollectionDocument
);
// Mutation handlers
const { updateInfraConfigs, updateAuthProvider, resetInfraConfigs } =
useConfigHandler(props.workingConfigs);
const {
updateInfraConfigs,
updateAuthProvider,
resetInfraConfigs,
updateDataSharingConfigs,
} = useConfigHandler(props.workingConfigs);
// Call relevant mutations on component mount and initiate server restart
const duration = ref(30);
@@ -80,6 +88,12 @@ onMounted(async () => {
updateAllowedAuthProviderMutation
);
if (!authResult) return;
const dataSharingResult = await updateDataSharingConfigs(
toggleDataSharingMutation
);
if (!dataSharingResult) return;
}
restart.value = true;

View File

@@ -0,0 +1,141 @@
<template>
<div
class="flex flex-col items-center justify-center min-h-screen space-y-10"
>
<div class="flex items-center justify-center flex-col space-y-2">
<h2 class="text-lg">{{ t('data_sharing.welcome') }}</h2>
<img
src="/assets/images/hoppscotch-title.svg"
alt="hoppscotch-title"
class="w-52"
/>
</div>
<div
class="bg-primaryLight p-10 border-2 border-dividerLight rounded-lg flex flex-col space-y-8"
>
<div class="flex flex-col space-y-5 items-start">
<div>
<p class="text-lg font-bold text-white">
{{ t('data_sharing.title') }}
</p>
<p class="font-light">
{{ t('data_sharing.description') }}
</p>
</div>
<HoppSmartToggle
:on="dataSharingToggle"
@change="dataSharingToggle = !dataSharingToggle"
>
{{ t('data_sharing.toggle_description') }}
</HoppSmartToggle>
<!-- TODO: Update link -->
<HoppSmartAnchor
blank
to="http://docs.hoppscotch.io"
:label="t('data_sharing.see_shared')"
class="underline"
/>
</div>
<div class="flex flex-col items-start space-y-5">
<div>
<p class="text-lg font-bold text-white">
{{ t('newsletter.title') }}
</p>
<p class="font-light">{{ t('newsletter.description') }}</p>
</div>
<HoppSmartToggle
:on="newsletterToggle"
@change="newsletterToggle = !newsletterToggle"
>
{{ t('newsletter.toggle_description') }}
</HoppSmartToggle>
</div>
<div class="flex flex-col items-center space-y-5">
<HoppButtonPrimary
:icon="IconLogIn"
:label="t('app.continue_to_dashboard')"
class="mx-10"
@click="submitSelection"
/>
<HoppSmartAnchor
blank
to="http://docs.hoppscotch.io"
:icon="IconBookOpenText"
:label="t('app.read_documentation')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMutation } from '@urql/vue';
import { ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { auth } from '~/helpers/auth';
import { listmonkApi } from '~/helpers/axiosConfig';
import {
ServiceStatus,
ToggleAnalyticsCollectionDocument,
} from '~/helpers/backend/graphql';
import IconBookOpenText from '~icons/lucide/book-open-text';
import IconLogIn from '~icons/lucide/log-in';
const t = useI18n();
const toast = useToast();
const user = auth.getCurrentUser();
const emit = defineEmits<{
(event: 'setupComplete', status: boolean): void;
}>();
const dataSharingToggle = ref(true);
const newsletterToggle = ref(true);
// Toggle data sharing
const dataSharingMutation = useMutation(ToggleAnalyticsCollectionDocument);
const toggleDataSharing = async () => {
const status = dataSharingToggle.value
? ServiceStatus.Enable
: ServiceStatus.Disable;
const result = await dataSharingMutation.executeMutation({ status });
if (result.error) {
toast.error(t('state.data_sharing_failure'));
return false;
}
return true;
};
// Toggle subscription to newsletter
const toggleNewsletter = async () => {
try {
await listmonkApi.post('/subscription', {
email: user?.email,
name: user?.displayName,
list_uuids: ['f5f0b457-44d0-4aa1-b6f9-165dc1efa56a'],
});
return true;
} catch (e) {
console.error(e);
toast.error(t('state.newsletter_failure'));
return false;
}
};
// Submit selections made
const submitSelection = async () => {
const dataSharingResult =
dataSharingToggle.value && (await toggleDataSharing());
const newsletterResult = newsletterToggle.value && (await toggleNewsletter());
const setupDataComplete = !dataSharingToggle.value || dataSharingResult;
const setupNewsletterComplete = !newsletterToggle.value || newsletterResult;
emit('setupComplete', setupDataComplete && setupNewsletterComplete);
};
</script>

View File

@@ -25,22 +25,26 @@ export function useClientHandler<
const fetchData = async () => {
fetching.value = true;
try {
const result = await client
.query(query, {
...variables,
})
.toPromise();
if (getList) {
const resultList = getList(result.data!);
dataAsList.value.push(...resultList);
} else {
data.value = result.data;
}
} catch (e) {
const result = await client
.query(query, {
...variables,
})
.toPromise();
if (result.error) {
error.value = true;
fetching.value = false;
return;
}
if (getList) {
const resultList = getList(result.data!);
dataAsList.value.push(...resultList);
} else {
data.value = result.data;
}
fetching.value = false;
};

View File

@@ -1,19 +1,20 @@
import { computed, onMounted, ref } from 'vue';
import { AnyVariables, UseMutationResponse } from '@urql/vue';
import { cloneDeep } from 'lodash-es';
import { UseMutationResponse } from '@urql/vue';
import { useClientHandler } from './useClientHandler';
import { useToast } from './toast';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import {
AllowedAuthProvidersDocument,
EnableAndDisableSsoArgs,
EnableAndDisableSsoMutation,
InfraConfigArgs,
InfraConfigEnum,
InfraConfigsDocument,
AllowedAuthProvidersDocument,
EnableAndDisableSsoMutation,
UpdateInfraConfigsMutation,
ResetInfraConfigsMutation,
EnableAndDisableSsoArgs,
InfraConfigArgs,
ToggleAnalyticsCollectionMutation,
UpdateInfraConfigsMutation,
} from '~/helpers/backend/graphql';
import { useToast } from './toast';
import { useClientHandler } from './useClientHandler';
// Types
export type SsoAuthProviders = 'google' | 'microsoft' | 'github';
@@ -54,6 +55,11 @@ export type Config = {
mailer_from_address: string;
};
};
dataSharingConfigs: {
name: string;
enabled: boolean;
};
};
type UpdatedConfigs = {
@@ -86,6 +92,7 @@ export function useConfigHandler(updatedConfigs?: Config) {
'GITHUB_CLIENT_SECRET',
'MAILER_SMTP_URL',
'MAILER_ADDRESS_FROM',
'ALLOW_ANALYTICS_COLLECTION',
] as InfraConfigEnum[],
},
(x) => x.infraConfigs
@@ -164,6 +171,12 @@ export function useConfigHandler(updatedConfigs?: Config) {
?.value ?? '',
},
},
dataSharingConfigs: {
name: 'data_sharing',
enabled: !!infraConfigs.value.find(
(x) => x.name === 'ALLOW_ANALYTICS_COLLECTION' && x.value === 'true'
),
},
};
// Cloning the current configs to working configs
@@ -262,15 +275,23 @@ export function useConfigHandler(updatedConfigs?: Config) {
// Checking if any of the config fields are empty
const isFieldEmpty = (field: string) => field.trim() === '';
const AreAnyConfigFieldsEmpty = (config: Config): boolean => {
const providerFieldsEmpty = Object.values(config.providers).some(
(provider) => Object.values(provider.fields).some(isFieldEmpty)
);
const mailFieldsEmpty = Object.values(config.mailConfigs.fields).some(
isFieldEmpty
);
type ConfigSection = {
enabled: boolean;
fields: Record<string, string>;
};
return providerFieldsEmpty || mailFieldsEmpty;
const AreAnyConfigFieldsEmpty = (config: Config): boolean => {
const sections: Array<ConfigSection> = [
config.providers.github,
config.providers.google,
config.providers.microsoft,
config.mailConfigs,
];
return sections.some(
(section) =>
section.enabled && Object.values(section.fields).some(isFieldEmpty)
);
};
// Transforming the working configs back into the format required by the mutations
@@ -297,55 +318,70 @@ export function useConfigHandler(updatedConfigs?: Config) {
];
});
// Updating the auth provider configurations
const updateAuthProvider = async (
updateProviderStatus: UseMutationResponse<EnableAndDisableSsoMutation>
) => {
const variables = {
providerInfo:
updatedAllowedAuthProviders.value as EnableAndDisableSsoArgs[],
};
const result = await updateProviderStatus.executeMutation(variables);
// Generic function to handle mutation execution and error handling
const executeMutation = async <T, V>(
mutation: UseMutationResponse<T>,
variables: AnyVariables = undefined,
errorMessage: string
): Promise<boolean> => {
const result = await mutation.executeMutation(variables);
if (result.error) {
toast.error(t('configs.auth_providers.update_failure'));
toast.error(t(errorMessage));
return false;
}
return true;
};
// Updating the auth provider configurations
const updateAuthProvider = (
updateProviderStatus: UseMutationResponse<EnableAndDisableSsoMutation>
) =>
executeMutation(
updateProviderStatus,
{
providerInfo:
updatedAllowedAuthProviders.value as EnableAndDisableSsoArgs[],
},
'configs.auth_providers.update_failure'
);
// Updating the infra configurations
const updateInfraConfigs = async (
const updateInfraConfigs = (
updateInfraConfigsMutation: UseMutationResponse<UpdateInfraConfigsMutation>
) => {
const variables = {
infraConfigs: updatedInfraConfigs.value as InfraConfigArgs[],
};
const result = await updateInfraConfigsMutation.executeMutation(variables);
if (result.error) {
toast.error(t('configs.mail_configs.update_failure'));
return false;
}
return true;
};
) =>
executeMutation(
updateInfraConfigsMutation,
{
infraConfigs: updatedInfraConfigs.value as InfraConfigArgs[],
},
'configs.mail_configs.update_failure'
);
// Resetting the infra configurations
const resetInfraConfigs = async (
const resetInfraConfigs = (
resetInfraConfigsMutation: UseMutationResponse<ResetInfraConfigsMutation>
) => {
const result = await resetInfraConfigsMutation.executeMutation();
) =>
executeMutation(
resetInfraConfigsMutation,
undefined,
'configs.reset.failure'
);
if (result.error) {
toast.error(t('configs.reset.failure'));
return false;
}
return true;
};
// Updating the data sharing configurations
const updateDataSharingConfigs = (
toggleDataSharingMutation: UseMutationResponse<ToggleAnalyticsCollectionMutation>
) =>
executeMutation(
toggleDataSharingMutation,
{
status: updatedConfigs?.dataSharingConfigs.enabled
? 'ENABLE'
: 'DISABLE',
},
'configs.data_sharing.update_failure'
);
return {
currentConfigs,
@@ -353,6 +389,7 @@ export function useConfigHandler(updatedConfigs?: Config) {
updatedInfraConfigs,
updatedAllowedAuthProviders,
updateAuthProvider,
updateDataSharingConfigs,
updateInfraConfigs,
resetInfraConfigs,
fetchingInfraConfigs,

View File

@@ -1,4 +1,4 @@
import { onMounted, ref } from 'vue';
import { Ref, onMounted, ref } from 'vue';
import { DocumentNode } from 'graphql';
import { TypedDocumentNode, useClientHandle } from '@urql/vue';
@@ -16,38 +16,41 @@ export function usePagedQuery<
const { client } = useClientHandle();
const fetching = ref(true);
const error = ref(false);
const list = ref<ListItem[]>([]);
const list: Ref<ListItem[]> = ref([]);
const currentPage = ref(0);
const hasNextPage = ref(true);
const fetchNextPage = async () => {
fetching.value = true;
try {
const cursor =
list.value.length > 0 ? getCursor(list.value.at(-1)) : undefined;
const variablesForPagination = {
...variables,
take: itemsPerPage,
cursor,
};
const cursor =
list.value.length > 0 ? getCursor(list.value.at(-1)!) : undefined;
const variablesForPagination = {
...variables,
take: itemsPerPage,
cursor,
};
const result = await client
.query(query, variablesForPagination)
.toPromise();
const resultList = getList(result.data!);
const result = await client
.query(query, variablesForPagination)
.toPromise();
if (resultList.length < itemsPerPage) {
hasNextPage.value = false;
}
list.value.push(...resultList);
currentPage.value++;
} catch (e) {
if (result.error) {
error.value = true;
} finally {
fetching.value = false;
return;
}
const resultList = getList(result.data!);
if (resultList.length < itemsPerPage) {
hasNextPage.value = false;
}
list.value.push(...resultList);
currentPage.value++;
fetching.value = false;
};
onMounted(async () => {

View File

@@ -67,7 +67,7 @@ const signOut = async (reloadWindow = false) => {
});
};
const getInitialUserDetails = async () => {
const getUserDetails = async () => {
const res = await authQuery.getUserDetails();
return res.data;
};
@@ -80,7 +80,7 @@ const setUser = (user: HoppUser | null) => {
const setInitialUser = async () => {
isGettingInitialUser.value = true;
const res = await getInitialUserDetails();
const res = await getUserDetails();
if (res.errors?.[0]) {
const [error] = res.errors;
@@ -154,7 +154,7 @@ export const auth = {
getCurrentUserStream: () => currentUser$,
getAuthEventsStream: () => authEvents$,
getCurrentUser: () => currentUser$.value,
getUserDetails,
performAuthInit: () => {
const currentUser = JSON.parse(getLocalConfig('login_state') ?? 'null');
currentUser$.next(currentUser);
@@ -232,4 +232,24 @@ export const auth = {
const res = await authQuery.getProviders();
return res.data?.providers;
},
getFirstTimeInfraSetupStatus: async (): Promise<boolean> => {
try {
const res = await authQuery.getFirstTimeInfraSetupStatus();
return res.data?.value === 'true';
} catch (err) {
// Setup is not done
return true;
}
},
updateFirstTimeInfraSetupStatus: async () => {
try {
await authQuery.updateFirstTimeInfraSetupStatus();
return true;
} catch (err) {
console.error(err);
return false;
}
},
};

View File

@@ -17,4 +17,10 @@ const restApi = axios.create({
baseURL: import.meta.env.VITE_BACKEND_API_URL,
});
export { gqlApi, restApi };
const listmonkApi = axios.create({
...baseConfig,
withCredentials: false,
baseURL: 'https://listmonk.hoppscotch.com/api/public',
});
export { gqlApi, restApi, listmonkApi };

View File

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

View File

@@ -29,5 +29,7 @@ export default {
token,
deviceIdentifier,
}),
getFirstTimeInfraSetupStatus: () => restApi.get('/site/setup'),
updateFirstTimeInfraSetupStatus: () => restApi.put('/site/setup'),
logout: () => restApi.get('/auth/logout'),
};

View File

@@ -1,15 +1,16 @@
import { auth } from '~/helpers/auth';
import { UNAUTHORIZED } from '~/helpers/errors';
import { HoppModule } from '.';
const isAdmin = () => {
const user = auth.getCurrentUser();
return user ? user.isAdmin : false;
const isSetupRoute = (to: unknown) => to === 'setup';
const isGuestRoute = (to: unknown) => ['index', 'enter'].includes(to as string);
const getFirstTimeInfraSetupStatus = async () => {
const isInfraNotSetup = await auth.getFirstTimeInfraSetupStatus();
return isInfraNotSetup;
};
const GUEST_ROUTES = ['index', 'enter'];
const isGuestRoute = (to: unknown) => GUEST_ROUTES.includes(to as string);
/**
* @module routers
*/
@@ -24,13 +25,53 @@ const isGuestRoute = (to: unknown) => GUEST_ROUTES.includes(to as string);
*/
export default <HoppModule>{
onBeforeRouteChange(to, from, next) {
if (!isGuestRoute(to.name) && !isAdmin()) {
next({ name: 'index' });
} else if (isGuestRoute(to.name) && isAdmin()) {
next({ name: 'dashboard' });
} else {
next();
async onBeforeRouteChange(to, _from, next) {
const res = await auth.getUserDetails();
// Allow performing the silent refresh flow for an invalid access token state
if (res.errors?.[0].message === UNAUTHORIZED) {
return next();
}
const isAdmin = res.data?.me.isAdmin;
// Route Guards
if (!isGuestRoute(to.name) && !isAdmin) {
/**
* Reroutes the user to the login page if user is not logged in
* and is not an admin
*/
return next({ name: 'index' });
}
if (isAdmin) {
// These route guards applies to the case where the user is logged in successfully and validated as an admin
const isInfraNotSetup = await getFirstTimeInfraSetupStatus();
/**
* Reroutes the user to the dashboard homepage if they have setup the infra already
* Else, the Setup page
*/
if (isGuestRoute(to.name)) {
const name = isInfraNotSetup ? 'setup' : 'dashboard';
return next({ name });
}
/**
* Reroutes the user to the dashboard homepage if they have setup the infra already
* and are trying to access the setup page
*/
if (isSetupRoute(to.name) && !isInfraNotSetup) {
return next({ name: 'dashboard' });
}
/**
* Reroutes the user to the setup page if they have not setup the infra yet
* and tries to access a valid route which is not a guest route
*/
if (isInfraNotSetup && !isSetupRoute(to.name)) {
return next({ name: 'setup' });
}
}
next();
},
};

View File

@@ -0,0 +1,40 @@
<template>
<SetupDataSharingAndNewsletter
@setup-complete="(status: boolean) => (isDataSharingAndNewsletterSetup = status)"
/>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { auth } from '~/helpers/auth';
const t = useI18n();
const toast = useToast();
const router = useRouter();
const isDataSharingAndNewsletterSetup = ref(false);
// Watcher is added for future proofing as we can have multiple setup steps in future
watch(
() => isDataSharingAndNewsletterSetup.value,
async (status) => {
if (status) {
const result = await auth.updateFirstTimeInfraSetupStatus();
if (result) {
toast.success(t('state.setup_success'));
router.push('/dashboard');
} else {
toast.error(t('state.setup_failure'));
}
}
}
);
</script>
<route lang="yaml">
meta:
layout: empty
</route>

View File

@@ -4,7 +4,9 @@
<HoppSmartSpinner />
</div>
<div v-else-if="error">{{ t('teams.load_info_error') }}</div>
<div v-else-if="error" class="text-lg">
{{ t('teams.load_info_error') }}
</div>
<div v-else-if="team" class="flex flex-col">
<div class="flex items-center space-x-4">

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="fetching" class="flex justify-center"><HoppSmartSpinner /></div>
<div v-else-if="error">{{ t('users.load_info_error') }}</div>
<div v-else-if="error" class="text-lg">{{ t('users.load_info_error') }}</div>
<div v-else-if="user" class="flex flex-col space-y-4">
<div class="flex gap-x-3">
<button