fix(fe2): accept invite before onboarding after sign up (#2491)
* explicitly ordering global middlewares * various subscription fixes & WIP project invite middleware * SSR invite accept & toast notifs seem to work * backend support for mixpanel * mixpanel be logic -> shared * minor fix * finissh * lint fix * minor comment adjustments * better adblock handling
This commit is contained in:
committed by
GitHub
parent
790d97383c
commit
ee5ae8af62
@@ -12,7 +12,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '~~/lib/core/composables/theme'
|
||||
import { useAuthManager } from '~~/lib/auth/composables/auth'
|
||||
import { useMixpanelInitialization } from '~~/lib/core/composables/mp'
|
||||
|
||||
const { isDarkTheme } = useTheme()
|
||||
|
||||
@@ -31,9 +30,6 @@ useHead({
|
||||
|
||||
const { watchAuthQueryString } = useAuthManager()
|
||||
watchAuthQueryString()
|
||||
|
||||
// Awaiting to block the app from continuing until mixpanel tracking is fully initialized
|
||||
await useMixpanelInitialization()
|
||||
</script>
|
||||
<style>
|
||||
.page-enter-active,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<div class="flex flex-col space-y-8 mt-12">
|
||||
<div class="flex flex-col justify-center sm:flex-row sm:space-x-2 items-center">
|
||||
<LockClosedIcon class="w-12 h-12 text-primary shrink-0" />
|
||||
<h1 class="h3 font-bold">You are not authorized to access this project.</h1>
|
||||
<h1 class="h3 font-bold">
|
||||
You are not authorized to access this {{ resourceType }}.
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:space-x-2 items-center"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
v-if="invite"
|
||||
:invite="invite"
|
||||
:show-stream-name="false"
|
||||
:auto-accept="shouldAutoAcceptInvite"
|
||||
block
|
||||
@processed="onProcessed"
|
||||
/>
|
||||
@@ -12,7 +11,7 @@
|
||||
</NuxtErrorBoundary>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { waitForever, type Optional } from '@speckle/shared'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { projectRoute, useNavigateToHome } from '~/lib/common/helpers/route'
|
||||
import { projectInviteQuery } from '~~/lib/projects/graphql/queries'
|
||||
@@ -23,7 +22,6 @@ const goHome = useNavigateToHome()
|
||||
|
||||
const token = computed(() => route.query.token as Optional<string>)
|
||||
const projectId = computed(() => route.params.id as Optional<string>)
|
||||
const shouldAutoAcceptInvite = computed(() => route.query.accept === 'true')
|
||||
|
||||
const { result } = useQuery(
|
||||
projectInviteQuery,
|
||||
@@ -48,6 +46,7 @@ const onProcessed = async (val: { accepted: boolean }) => {
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
await waitForever() // to prevent UI changes while reload is happening
|
||||
} else {
|
||||
await goHome()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
v-if="invite"
|
||||
:invite="invite"
|
||||
:show-project-name="false"
|
||||
:auto-accept="shouldAutoAcceptInvite"
|
||||
@processed="onProcessed"
|
||||
/>
|
||||
</NuxtErrorBoundary>
|
||||
@@ -20,7 +19,6 @@ const logger = useLogger()
|
||||
|
||||
const token = computed(() => route.query.token as Optional<string>)
|
||||
const projectId = computed(() => route.params.id as Optional<string>)
|
||||
const shouldAutoAcceptInvite = computed(() => route.query.accept === 'true')
|
||||
|
||||
const { result } = useQuery(
|
||||
projectInviteQuery,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
color="danger"
|
||||
text
|
||||
:full-width="block"
|
||||
@click="useInvite(false)"
|
||||
@click="processInvite(false)"
|
||||
>
|
||||
Decline
|
||||
</FormButton>
|
||||
@@ -27,7 +27,7 @@
|
||||
:size="buttonSize"
|
||||
class="px-4"
|
||||
:icon-left="CheckIcon"
|
||||
@click="useInvite(true)"
|
||||
@click="processInvite(true)"
|
||||
>
|
||||
Accept
|
||||
</FormButton>
|
||||
@@ -53,12 +53,10 @@ import {
|
||||
useNavigateToLogin,
|
||||
useNavigateToRegistration
|
||||
} from '~~/lib/common/helpers/route'
|
||||
import { useProcessProjectInvite } from '~~/lib/projects/composables/projectManagement'
|
||||
import { usePostAuthRedirect } from '~~/lib/auth/composables/postAuthRedirect'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { CheckIcon } from '@heroicons/vue/24/solid'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { ToastNotificationType, useGlobalToast } from '~/lib/common/composables/toast'
|
||||
import { useProjectInviteManager } from '~/lib/projects/composables/invites'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectsInviteBanner on PendingStreamCollaborator {
|
||||
@@ -83,7 +81,6 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
invite?: ProjectsInviteBannerFragment
|
||||
showProjectName?: boolean
|
||||
autoAccept?: boolean
|
||||
/**
|
||||
* Render this as a big block, instead of a small row. Used in full-page project access error pages.
|
||||
*/
|
||||
@@ -94,14 +91,12 @@ const props = withDefaults(
|
||||
|
||||
const route = useRoute()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const processInvite = useProcessProjectInvite()
|
||||
const { useInvite } = useProjectInviteManager()
|
||||
const postAuthRedirect = usePostAuthRedirect()
|
||||
const goToLogin = useNavigateToLogin()
|
||||
const goToSignUp = useNavigateToRegistration()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const mp = useMixpanel()
|
||||
const token = computed(
|
||||
() => props.invite?.token || (route.query.token as Optional<string>)
|
||||
)
|
||||
@@ -133,34 +128,19 @@ const mainInfoBlockClasses = computed(() => {
|
||||
const buttonSize = computed(() => (props.block ? 'lg' : 'sm'))
|
||||
const avatarSize = computed(() => (props.block ? 'xxl' : 'base'))
|
||||
|
||||
const useInvite = async (accept: boolean) => {
|
||||
const processInvite = async (accept: boolean) => {
|
||||
if (!token.value || !props.invite) return
|
||||
|
||||
loading.value = true
|
||||
const success = await processInvite(
|
||||
{
|
||||
projectId: props.invite.projectId,
|
||||
accept,
|
||||
token: token.value
|
||||
},
|
||||
{ inviteId: props.invite.id }
|
||||
)
|
||||
loading.value = false
|
||||
|
||||
if (!success) return
|
||||
|
||||
emit('processed', { accepted: accept })
|
||||
if (accept) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: "You've joined the project!"
|
||||
})
|
||||
}
|
||||
|
||||
mp.track('Invite Action', {
|
||||
type: 'project invite',
|
||||
accepted: accept
|
||||
const success = await useInvite({
|
||||
projectId: props.invite.projectId,
|
||||
accept,
|
||||
token: token.value,
|
||||
inviteId: props.invite.id
|
||||
})
|
||||
loading.value = false
|
||||
if (!success) return
|
||||
emit('processed', { accepted: accept })
|
||||
}
|
||||
|
||||
const onLoginSignupClick = async () => {
|
||||
@@ -177,16 +157,4 @@ const onLoginSignupClick = async () => {
|
||||
await goToSignUp({ query })
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
watch(
|
||||
() => props.autoAccept,
|
||||
async (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
await useInvite(true)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
<!-- Breakout div from main container -->
|
||||
|
||||
<div class="flex flex-col">
|
||||
<ProjectsInviteBanner v-for="item in items" :key="item.id" :invite="item" />
|
||||
<ProjectsInviteBanner
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:invite="item"
|
||||
@processed="$emit('processed', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -17,6 +22,10 @@ graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'processed', val: { accepted: boolean }): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
invites: ProjectsInviteBannersFragment
|
||||
}>()
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
* IMPORTANT: Don't use this directly in Vue templates that may render in SSR, cause this may cause the backend API origin to be rendered instead of the clientside one,
|
||||
* at least until the app finishes hydrating. If people click on links based on this too early, they may end up in the wrong place.
|
||||
*/
|
||||
export const useApiOrigin = () => {
|
||||
export const useApiOrigin = (
|
||||
options?: Partial<{
|
||||
forcePublic: boolean
|
||||
}>
|
||||
) => {
|
||||
const {
|
||||
public: { apiOrigin, backendApiOrigin }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
if (import.meta.server && backendApiOrigin.length > 1) {
|
||||
if (import.meta.server && backendApiOrigin.length > 1 && !options?.forcePublic) {
|
||||
return backendApiOrigin
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Roles, type MaybeNullOrUndefined, md5 } from '@speckle/shared'
|
||||
import {
|
||||
Roles,
|
||||
type MaybeNullOrUndefined,
|
||||
resolveMixpanelUserId
|
||||
} from '@speckle/shared'
|
||||
import { useApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
|
||||
@@ -38,7 +42,7 @@ export function useResolveUserDistinctId() {
|
||||
if (!user) return user // null or undefined
|
||||
if (!user.email) return null
|
||||
|
||||
return '@' + md5(user.email.toLowerCase()).toUpperCase()
|
||||
return resolveMixpanelUserId(user.email)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { useScopedState } from '~/lib/common/composables/scopedState'
|
||||
import type { Ref } from 'vue'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import type { ToastNotification } from '@speckle/ui-components'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
|
||||
|
||||
/**
|
||||
* Persisting toast state between reqs and between CSR & SSR loads so that we can trigger
|
||||
* toasts anywhere and anytime
|
||||
*/
|
||||
const useGlobalToastState = () =>
|
||||
useScopedState<Ref<Nullable<ToastNotification>>>('global-toast-state', () =>
|
||||
ref(null)
|
||||
)
|
||||
useSynchronizedCookie<Optional<ToastNotification>>('global-toast-state')
|
||||
|
||||
/**
|
||||
* Set up a new global toast manager/renderer (don't use this in multiple components that live at the same time)
|
||||
@@ -19,6 +20,11 @@ export function useGlobalToastManager() {
|
||||
const currentNotification = ref(stateNotification.value)
|
||||
const readOnlyNotification = computed(() => currentNotification.value)
|
||||
|
||||
const dismiss = () => {
|
||||
currentNotification.value = undefined
|
||||
stateNotification.value = undefined
|
||||
}
|
||||
|
||||
const { start, stop } = useTimeoutFn(() => {
|
||||
dismiss()
|
||||
}, 4000)
|
||||
@@ -27,6 +33,10 @@ export function useGlobalToastManager() {
|
||||
stateNotification,
|
||||
(newVal) => {
|
||||
if (!newVal) return
|
||||
if (import.meta.server) {
|
||||
currentNotification.value = newVal
|
||||
return
|
||||
}
|
||||
|
||||
// First dismiss old notification, then set a new one on next tick
|
||||
// this is so that the old one actually disappears from the screen for the user,
|
||||
@@ -41,14 +51,9 @@ export function useGlobalToastManager() {
|
||||
if (newVal.autoClose !== false) start()
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
const dismiss = () => {
|
||||
currentNotification.value = null
|
||||
stateNotification.value = null
|
||||
}
|
||||
|
||||
return { currentNotification: readOnlyNotification, dismiss }
|
||||
}
|
||||
|
||||
@@ -57,12 +62,17 @@ export function useGlobalToastManager() {
|
||||
*/
|
||||
export function useGlobalToast() {
|
||||
const stateNotification = useGlobalToastState()
|
||||
const logger = useLogger()
|
||||
|
||||
/**
|
||||
* Trigger a new toast notification
|
||||
*/
|
||||
const triggerNotification = (notification: ToastNotification) => {
|
||||
stateNotification.value = notification
|
||||
|
||||
if (import.meta.server) {
|
||||
logger.info('Queued SSR toast notification', notification)
|
||||
}
|
||||
}
|
||||
|
||||
return { triggerNotification }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable camelcase */
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import type { Merge } from 'type-fest'
|
||||
|
||||
export type MixpanelClient = Merge<
|
||||
Pick<
|
||||
OverridedMixpanel,
|
||||
'track' | 'init' | 'reset' | 'register' | 'identify' | 'people' | 'add_group'
|
||||
>,
|
||||
{
|
||||
people: Pick<OverridedMixpanel['people'], 'set' | 'set_once'>
|
||||
}
|
||||
>
|
||||
|
||||
export const HOST_APP = 'web-2'
|
||||
export const HOST_APP_DISPLAY_NAME = 'Web 2.0 App'
|
||||
|
||||
export const fakeMixpanelClient = (): MixpanelClient => ({
|
||||
init: noop as MixpanelClient['init'],
|
||||
track: noop,
|
||||
reset: noop,
|
||||
register: noop,
|
||||
identify: noop,
|
||||
people: {
|
||||
set: noop,
|
||||
set_once: noop
|
||||
},
|
||||
add_group: noop
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
/* eslint-disable camelcase */
|
||||
import { type Nullable, resolveMixpanelServerId } from '@speckle/shared'
|
||||
import mixpanel from 'mixpanel-browser'
|
||||
import { useOnAuthStateChange } from '~/lib/auth/composables/auth'
|
||||
import {
|
||||
HOST_APP,
|
||||
HOST_APP_DISPLAY_NAME,
|
||||
type MixpanelClient
|
||||
} from '~/lib/common/helpers/mp'
|
||||
import { useTheme } from '~/lib/core/composables/theme'
|
||||
|
||||
/**
|
||||
* Get mixpanel server identifier
|
||||
*/
|
||||
function getMixpanelServerId(): string {
|
||||
return resolveMixpanelServerId(window.location.hostname)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that builds the user (re-)identification function. Needs to be invoked on app
|
||||
* init and when the active user changes (e.g. after signing out/in)
|
||||
*/
|
||||
function useMixpanelUserIdentification() {
|
||||
if (import.meta.server) return { reidentify: () => void 0 }
|
||||
|
||||
const { distinctId } = useActiveUser()
|
||||
const { isDarkTheme } = useTheme()
|
||||
const serverId = getMixpanelServerId()
|
||||
const {
|
||||
public: { speckleServerVersion }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
return {
|
||||
reidentify: (mp: MixpanelClient) => {
|
||||
// Reset previous user data, if any
|
||||
mp.reset()
|
||||
|
||||
// Register session
|
||||
mp.register({
|
||||
server_id: serverId,
|
||||
hostApp: HOST_APP,
|
||||
speckleVersion: speckleServerVersion
|
||||
})
|
||||
|
||||
// Identify user, if any
|
||||
if (distinctId.value) {
|
||||
mp.identify(distinctId.value)
|
||||
mp.people.set('Identified', true)
|
||||
mp.people.set('Theme Web', isDarkTheme.value ? 'dark' : 'light')
|
||||
mp.add_group('server_id', serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useClientsideMixpanelClientBuilder = () => {
|
||||
const {
|
||||
public: { mixpanelApiHost, mixpanelTokenId, logCsrEmitProps }
|
||||
} = useRuntimeConfig()
|
||||
const { reidentify } = useMixpanelUserIdentification()
|
||||
const onAuthStateChange = useOnAuthStateChange()
|
||||
|
||||
return async (): Promise<Nullable<MixpanelClient>> => {
|
||||
if (!mixpanel || !mixpanelTokenId.length || !mixpanelApiHost.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Init
|
||||
mixpanel.init(mixpanelTokenId, {
|
||||
api_host: mixpanelApiHost,
|
||||
debug: !!import.meta.dev && logCsrEmitProps
|
||||
})
|
||||
|
||||
// Reidentify on auth change
|
||||
await onAuthStateChange(() => reidentify(mixpanel), { immediate: true })
|
||||
|
||||
// Track app visit
|
||||
mixpanel.track(`Visit ${HOST_APP_DISPLAY_NAME}`)
|
||||
|
||||
return mixpanel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
fakeMixpanelClient,
|
||||
HOST_APP,
|
||||
type MixpanelClient
|
||||
} from '~/lib/common/helpers/mp'
|
||||
import type Mixpanel from 'mixpanel'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import * as ServerMixpanelUtils from '@speckle/shared/dist/esm/observability/mixpanel.js'
|
||||
import { useApiOrigin } from '~/composables/env'
|
||||
import { useActiveUser } from '~/composables/globals'
|
||||
import { isFunction } from 'lodash-es'
|
||||
import { useWaitForActiveUser } from '~/lib/auth/composables/activeUser'
|
||||
|
||||
/**
|
||||
* IMPORTANT: Do not import this on client-side, the code is only supposed to run in SSR
|
||||
*/
|
||||
|
||||
let cachedInternalClient: Nullable<Mixpanel.Mixpanel> = null
|
||||
|
||||
/**
|
||||
* Composable for building the SSR mixpanel client
|
||||
*/
|
||||
export const useServersideMixpanelClientBuilder = () => {
|
||||
const {
|
||||
public: { mixpanelApiHost, mixpanelTokenId, logCsrEmitProps, speckleServerVersion }
|
||||
} = useRuntimeConfig()
|
||||
const nuxtApp = useNuxtApp()
|
||||
const route = useRoute()
|
||||
const apiOrigin = useApiOrigin({ forcePublic: true })
|
||||
const { distinctId } = useActiveUser()
|
||||
const logger = useLogger()
|
||||
const ssrContext = nuxtApp.ssrContext
|
||||
const waitForUser = useWaitForActiveUser()
|
||||
|
||||
const baseTrackingProperties = ServerMixpanelUtils.buildBasePropertiesPayload({
|
||||
hostApp: HOST_APP,
|
||||
serverOrigin: apiOrigin,
|
||||
speckleVersion: speckleServerVersion
|
||||
})
|
||||
|
||||
return async (): Promise<Nullable<MixpanelClient>> => {
|
||||
if (!mixpanelTokenId.length || !mixpanelApiHost.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Init or retrieve the cached client
|
||||
const internalClient =
|
||||
cachedInternalClient ||
|
||||
ServerMixpanelUtils.buildServerMixpanelClient({
|
||||
tokenId: mixpanelTokenId,
|
||||
apiHostname: new URL(mixpanelApiHost).hostname,
|
||||
debug: !!import.meta.dev && logCsrEmitProps
|
||||
})
|
||||
if (!cachedInternalClient) cachedInternalClient = internalClient
|
||||
|
||||
await waitForUser()
|
||||
|
||||
const coreTrackingProperties = () => {
|
||||
return {
|
||||
...baseTrackingProperties,
|
||||
...ServerMixpanelUtils.buildPropertiesPayload({
|
||||
distinctId: distinctId.value || undefined,
|
||||
headers: ssrContext?.event.node.req.headers,
|
||||
query: route.query,
|
||||
remoteAddress: ssrContext?.event.node.req.socket.remoteAddress
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const track: MixpanelClient['track'] = (eventName, properties, optsOrCallback) => {
|
||||
const payload = { ...coreTrackingProperties(), ...properties }
|
||||
internalClient.track(eventName, payload, (err) => {
|
||||
if (isFunction(optsOrCallback)) {
|
||||
optsOrCallback(
|
||||
err ? { error: err.message, status: 0 } : { error: null, status: 1 }
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
eventName,
|
||||
payload,
|
||||
...(err ? { err } : {})
|
||||
},
|
||||
'SSR Mixpanel track() invoked'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...fakeMixpanelClient(),
|
||||
track,
|
||||
identify: () => {
|
||||
logger.info(
|
||||
'SSR Mixpanel identify() invoked, but skipped due to identification being automatic'
|
||||
)
|
||||
},
|
||||
register: () => {
|
||||
logger.info(
|
||||
'SSR Mixpanel register() invoked, but skipped due to registration being automatic'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,3 @@
|
||||
import { useOnAuthStateChange } from '~/lib/auth/composables/auth'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { md5 } from '~/lib/common/helpers/encodeDecode'
|
||||
import { useTheme } from '~~/lib/core/composables/theme'
|
||||
|
||||
const HOST_APP = 'web-2'
|
||||
const HOST_APP_DISPLAY_NAME = 'Web 2.0 App'
|
||||
|
||||
/**
|
||||
* Get mixpanel server identifier
|
||||
*/
|
||||
function getMixpanelServerId(): string {
|
||||
return md5(window.location.hostname.toLowerCase()).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Mixpanel instance
|
||||
* Note: Mixpanel is not available during SSR because mixpanel-browser only works in the browser!
|
||||
@@ -22,61 +7,3 @@ export function useMixpanel() {
|
||||
const $mixpanel = nuxt.$mixpanel
|
||||
return $mixpanel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that builds the user (re-)identification function. Needs to be invoked on app
|
||||
* init and when the active user changes (e.g. after signing out/in)
|
||||
* Note: The returned function will only work on the client-side
|
||||
*/
|
||||
export function useMixpanelUserIdentification() {
|
||||
if (import.meta.server) return { reidentify: () => void 0 }
|
||||
|
||||
const mp = useMixpanel()
|
||||
const { distinctId } = useActiveUser()
|
||||
const { isDarkTheme } = useTheme()
|
||||
const serverId = getMixpanelServerId()
|
||||
const {
|
||||
public: { speckleServerVersion }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
return {
|
||||
reidentify: () => {
|
||||
// Reset previous user data, if any
|
||||
mp.reset()
|
||||
|
||||
// Register session
|
||||
mp.register({
|
||||
// eslint-disable-next-line camelcase
|
||||
server_id: serverId,
|
||||
hostApp: HOST_APP,
|
||||
speckleVersion: speckleServerVersion
|
||||
})
|
||||
|
||||
// Identify user, if any
|
||||
if (distinctId.value) {
|
||||
mp.identify(distinctId.value)
|
||||
mp.people.set('Identified', true)
|
||||
mp.people.set('Theme Web', isDarkTheme.value ? 'dark' : 'light')
|
||||
mp.add_group('server_id', serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that builds the mixpanel initialization function
|
||||
* Note: The returned function will only initialize mixpanel on the client-side
|
||||
*/
|
||||
export async function useMixpanelInitialization() {
|
||||
if (import.meta.server) return
|
||||
|
||||
const mp = useMixpanel()
|
||||
const { reidentify } = useMixpanelUserIdentification()
|
||||
const onAuthStateChange = useOnAuthStateChange()
|
||||
|
||||
// Reidentify on auth change
|
||||
await onAuthStateChange(() => reidentify(), { immediate: true })
|
||||
|
||||
// Track app visit
|
||||
mp.track(`Visit ${HOST_APP_DISPLAY_NAME}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { useProcessProjectInvite } from '~/lib/projects/composables/projectManagement'
|
||||
|
||||
export const useProjectInviteManager = () => {
|
||||
const processInvite = useProcessProjectInvite()
|
||||
const mp = useMixpanel()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const useInvite = async (params: {
|
||||
accept: boolean
|
||||
token: string
|
||||
projectId: string
|
||||
inviteId?: string
|
||||
}) => {
|
||||
const { token, accept, projectId, inviteId } = params
|
||||
if (!token?.length || !projectId?.length) return false
|
||||
|
||||
loading.value = true
|
||||
const success = await processInvite(
|
||||
{
|
||||
projectId,
|
||||
accept,
|
||||
token
|
||||
},
|
||||
{ inviteId }
|
||||
)
|
||||
loading.value = false
|
||||
|
||||
if (!success) return false
|
||||
|
||||
mp.track('Invite Action', {
|
||||
type: 'project invite',
|
||||
accepted: accept
|
||||
})
|
||||
|
||||
return !!success
|
||||
}
|
||||
|
||||
return {
|
||||
useInvite,
|
||||
loading: computed(() => loading.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ export function useProcessProjectInvite() {
|
||||
|
||||
return async (
|
||||
input: ProjectInviteUseInput,
|
||||
options?: Partial<{ inviteId: string }>
|
||||
options?: Partial<{ inviteId: string; skipToast: boolean }>
|
||||
) => {
|
||||
if (!activeUser.value) return
|
||||
|
||||
@@ -365,28 +365,34 @@ export function useProcessProjectInvite() {
|
||||
mutation: useProjectInviteMutation,
|
||||
variables: { input },
|
||||
update: (cache, { data }) => {
|
||||
if (!data?.projectMutations.invites.use || !options?.inviteId) return
|
||||
if (!data?.projectMutations.invites.use) return
|
||||
|
||||
// Evict PendingStreamCollaborator
|
||||
cache.evict({
|
||||
id: getCacheId('PendingStreamCollaborator', options.inviteId)
|
||||
})
|
||||
if (options?.inviteId) {
|
||||
// Evict PendingStreamCollaborator
|
||||
cache.evict({
|
||||
id: getCacheId('PendingStreamCollaborator', options.inviteId)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (data?.projectMutations.invites.use) {
|
||||
triggerNotification({
|
||||
type: input.accept ? ToastNotificationType.Success : ToastNotificationType.Info,
|
||||
title: input.accept ? 'Invite accepted' : 'Invite dismissed'
|
||||
})
|
||||
} else {
|
||||
const errMsg = getFirstErrorMessage(errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: "Couldn't process invite",
|
||||
description: errMsg
|
||||
})
|
||||
if (!options?.skipToast) {
|
||||
if (data?.projectMutations.invites.use) {
|
||||
triggerNotification({
|
||||
type: input.accept
|
||||
? ToastNotificationType.Success
|
||||
: ToastNotificationType.Info,
|
||||
title: input.accept ? 'Invite accepted' : 'Invite dismissed'
|
||||
})
|
||||
} else {
|
||||
const errMsg = getFirstErrorMessage(errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: "Couldn't process invite",
|
||||
description: errMsg
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return data?.projectMutations.invites.use
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
// Add response header that shows this is a FE2 request
|
||||
const { ssrContext } = useNuxtApp()
|
||||
if (ssrContext) {
|
||||
ssrContext.event.node.res.setHeader('x-speckle-frontend-2', 'true')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type Optional } from '@speckle/shared'
|
||||
import { omit } from 'lodash-es'
|
||||
import { activeUserQuery } from '~/lib/auth/composables/activeUser'
|
||||
import { useApolloClientFromNuxt } from '~/lib/common/composables/graphql'
|
||||
import { useProjectInviteManager } from '~/lib/projects/composables/invites'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { useInvite } = useProjectInviteManager()
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { data } = await client
|
||||
.query({
|
||||
query: activeUserQuery
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
// Ignore if not logged in
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
const token = to.query.token as Optional<string>
|
||||
const accept = to.query.accept === 'true'
|
||||
|
||||
if (!token || !accept) {
|
||||
return
|
||||
}
|
||||
if (!to.path.startsWith('/projects/')) return
|
||||
|
||||
const projectId = to.params.id as Optional<string>
|
||||
if (!projectId) return
|
||||
|
||||
const success = await useInvite({ token, accept, projectId })
|
||||
|
||||
if (success) {
|
||||
return navigateTo(
|
||||
{
|
||||
query: omit(to.query, ['token', 'accept'])
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
}
|
||||
})
|
||||
+3
-3
@@ -19,12 +19,12 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
|
||||
const isOnboardingFinished = data?.activeUser?.isOnboardingFinished
|
||||
const isGoingToOnboarding = to.path === onboardingRoute
|
||||
|
||||
if (
|
||||
const shouldRedirectToOnboarding =
|
||||
!isOnboardingFinished &&
|
||||
!isGoingToOnboarding &&
|
||||
to.query['skiponboarding'] !== 'true'
|
||||
) {
|
||||
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return navigateTo(onboardingRoute)
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
if (import.meta.server) return
|
||||
const mp = useMixpanel()
|
||||
const pathDefinition = getRouteDefinition(to)
|
||||
const path = to.path
|
||||
mp.track('Route Visited', {
|
||||
path,
|
||||
pathDefinition
|
||||
})
|
||||
})
|
||||
@@ -75,6 +75,7 @@
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"tweetnacl-sealedbox-js": "^1.2.0",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"vee-validate": "^4.7.0",
|
||||
"vue-advanced-cropper": "^2.8.8",
|
||||
"vue-tippy": "^6.0.0",
|
||||
@@ -108,6 +109,7 @@
|
||||
"@types/mixpanel-browser": "^2.38.0",
|
||||
"@types/node": "^18.17.5",
|
||||
"@types/pino-http": "^5.8.1",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@typescript-eslint/eslint-plugin": "^7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@vitejs/plugin-legacy": "^5.4.1",
|
||||
@@ -118,6 +120,7 @@
|
||||
"eslint": "^9.4.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.3.0",
|
||||
"mixpanel": "^0.18.0",
|
||||
"nuxt": "^3.12.2",
|
||||
"pino-pretty": "^10.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<ProjectsInviteBanner
|
||||
:invite="invite"
|
||||
:show-project-name="false"
|
||||
:auto-accept="shouldAutoAcceptInvite"
|
||||
@processed="onInviteAccepted"
|
||||
/>
|
||||
<div
|
||||
@@ -71,7 +70,6 @@ definePageMeta({
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
const shouldAutoAcceptInvite = computed(() => route.query.accept === 'true')
|
||||
const token = computed(() => route.query.token as Optional<string>)
|
||||
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { LogicError } from '@speckle/ui-components'
|
||||
import { fakeMixpanelClient, type MixpanelClient } from '~/lib/common/helpers/mp'
|
||||
|
||||
/**
|
||||
* mixpanel-browser only supports being ran on the client-side (hence the name)! So it's only going to be accessible
|
||||
* in client-side execution branches
|
||||
*/
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const logger = useLogger()
|
||||
|
||||
let mixpanel: MixpanelClient | undefined = undefined
|
||||
|
||||
try {
|
||||
// Dynamic import to allow suppressing loading errors that happen because of adblock
|
||||
const builder = (await import('~/lib/core/clients/mp'))
|
||||
.useClientsideMixpanelClientBuilder
|
||||
const build = builder()
|
||||
mixpanel = (await build()) || undefined
|
||||
} catch (e) {
|
||||
logger.warn(e, 'Failed to load mixpanel in CSR')
|
||||
}
|
||||
|
||||
if (!mixpanel) {
|
||||
// Implement mocked version
|
||||
mixpanel = fakeMixpanelClient()
|
||||
}
|
||||
|
||||
return {
|
||||
provide: {
|
||||
mixpanel: () => {
|
||||
if (!mixpanel) throw new LogicError('Mixpanel unexpectedly not defined')
|
||||
return mixpanel
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { LogicError } from '@speckle/ui-components'
|
||||
import { fakeMixpanelClient, type MixpanelClient } from '~/lib/common/helpers/mp'
|
||||
import { useServersideMixpanelClientBuilder } from '~/lib/core/clients/mpServer'
|
||||
|
||||
/**
|
||||
* mixpanel only supports being ran on the server-side! So it's only going to be accessible
|
||||
* in SSR execution branches
|
||||
*/
|
||||
|
||||
type LimitedMixpanel = MixpanelClient
|
||||
|
||||
const fakeLimitedMixpanel = fakeMixpanelClient
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const logger = useLogger()
|
||||
|
||||
let mixpanel: LimitedMixpanel | undefined = undefined
|
||||
|
||||
try {
|
||||
const build = useServersideMixpanelClientBuilder()
|
||||
mixpanel = (await build()) || undefined
|
||||
} catch (e) {
|
||||
logger.warn(e, 'Failed to load mixpanel in SSR')
|
||||
}
|
||||
|
||||
if (!mixpanel) {
|
||||
// Implement mocked version
|
||||
mixpanel = fakeLimitedMixpanel()
|
||||
}
|
||||
|
||||
return {
|
||||
provide: {
|
||||
mixpanel: () => {
|
||||
if (!mixpanel) throw new LogicError('Mixpanel unexpectedly not defined')
|
||||
return mixpanel
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const mp = useMixpanel()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const track = (to: RouteLocationNormalized) => {
|
||||
const pathDefinition = getRouteDefinition(to)
|
||||
const path = to.path
|
||||
mp.track('Route Visited', {
|
||||
path,
|
||||
pathDefinition
|
||||
})
|
||||
}
|
||||
|
||||
// Track init page view
|
||||
track(route)
|
||||
|
||||
// Track page view after navigations
|
||||
router.afterEach((to) => {
|
||||
track(to)
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
/* eslint-disable camelcase */
|
||||
import { LogicError } from '@speckle/ui-components'
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import type { Merge } from 'type-fest'
|
||||
|
||||
/**
|
||||
* mixpanel-browser only supports being ran on the client-side (hence the name)! So it's only going to be accessible
|
||||
* in client-side execution branches
|
||||
*/
|
||||
|
||||
type LimitedMixpanel = Merge<
|
||||
Pick<
|
||||
OverridedMixpanel,
|
||||
'track' | 'init' | 'reset' | 'register' | 'identify' | 'people' | 'add_group'
|
||||
>,
|
||||
{
|
||||
people: Pick<OverridedMixpanel['people'], 'set' | 'set_once'>
|
||||
}
|
||||
>
|
||||
|
||||
const fakeLimitedMixpanel = (): LimitedMixpanel => ({
|
||||
init: noop as LimitedMixpanel['init'],
|
||||
track: noop,
|
||||
reset: noop,
|
||||
register: noop,
|
||||
identify: noop,
|
||||
people: {
|
||||
set: noop,
|
||||
set_once: noop
|
||||
},
|
||||
add_group: noop
|
||||
})
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const {
|
||||
public: { mixpanelApiHost, mixpanelTokenId, logCsrEmitProps }
|
||||
} = useRuntimeConfig()
|
||||
const logger = useLogger()
|
||||
|
||||
let mixpanel: LimitedMixpanel | undefined = undefined
|
||||
|
||||
try {
|
||||
mixpanel = import.meta.client
|
||||
? (await import('mixpanel-browser')).default
|
||||
: undefined
|
||||
if (import.meta.server) {
|
||||
mixpanel = {
|
||||
...fakeLimitedMixpanel(),
|
||||
track: () => {
|
||||
throw new Error('mixpanel is not available on the server-side')
|
||||
},
|
||||
identify: () => {
|
||||
throw new Error('mixpanel is not available on the server-side')
|
||||
},
|
||||
register: () => {
|
||||
throw new Error('mixpanel is not available on the server-side')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(e, 'Failed to load mixpanel')
|
||||
}
|
||||
|
||||
if (!mixpanel) {
|
||||
// Implement mocked version
|
||||
mixpanel = fakeLimitedMixpanel()
|
||||
}
|
||||
|
||||
// Init
|
||||
mixpanel.init(mixpanelTokenId, {
|
||||
api_host: mixpanelApiHost,
|
||||
debug: !!import.meta.dev && logCsrEmitProps
|
||||
})
|
||||
|
||||
return {
|
||||
provide: {
|
||||
mixpanel: () => {
|
||||
if (!mixpanel) throw new LogicError('Mixpanel unexpectedly not defined')
|
||||
return mixpanel
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -238,6 +238,13 @@ export async function addStreamPermissionsAddedActivity(params: {
|
||||
project: stream
|
||||
},
|
||||
ownerId: targetUserId
|
||||
}),
|
||||
publish(ProjectSubscriptions.ProjectUpdated, {
|
||||
projectUpdated: {
|
||||
id: streamId,
|
||||
type: ProjectUpdatedMessageType.Updated,
|
||||
project: stream
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
@@ -277,6 +284,13 @@ export async function addStreamInviteAcceptedActivity(params: {
|
||||
project: stream
|
||||
},
|
||||
ownerId: inviteTargetId
|
||||
}),
|
||||
publish(ProjectSubscriptions.ProjectUpdated, {
|
||||
projectUpdated: {
|
||||
id: streamId,
|
||||
type: ProjectUpdatedMessageType.Updated,
|
||||
project: stream
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
@@ -288,8 +302,9 @@ export async function addStreamPermissionsRevokedActivity(params: {
|
||||
streamId: string
|
||||
activityUserId: string
|
||||
removedUserId: string
|
||||
stream: StreamRecord
|
||||
}) {
|
||||
const { streamId, activityUserId, removedUserId } = params
|
||||
const { streamId, activityUserId, removedUserId, stream } = params
|
||||
const isVoluntaryLeave = activityUserId === removedUserId
|
||||
|
||||
await Promise.all([
|
||||
@@ -318,6 +333,13 @@ export async function addStreamPermissionsRevokedActivity(params: {
|
||||
project: null
|
||||
},
|
||||
ownerId: removedUserId
|
||||
}),
|
||||
publish(ProjectSubscriptions.ProjectUpdated, {
|
||||
projectUpdated: {
|
||||
id: streamId,
|
||||
type: ProjectUpdatedMessageType.Updated,
|
||||
project: stream
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
@@ -330,19 +352,29 @@ export async function addStreamInviteSentOutActivity(params: {
|
||||
inviteTargetId: string
|
||||
inviterId: string
|
||||
inviteTargetEmail: string
|
||||
stream: StreamRecord
|
||||
}) {
|
||||
const { streamId, inviteTargetId, inviterId, inviteTargetEmail } = params
|
||||
const { streamId, inviteTargetId, inviterId, inviteTargetEmail, stream } = params
|
||||
const targetDisplay = inviteTargetId || inviteTargetEmail
|
||||
|
||||
await saveActivity({
|
||||
streamId,
|
||||
resourceType: ResourceTypes.Stream,
|
||||
resourceId: streamId,
|
||||
actionType: ActionTypes.Stream.InviteSent,
|
||||
userId: inviterId,
|
||||
message: `User ${inviterId} has invited ${targetDisplay} to stream ${streamId}`,
|
||||
info: { targetId: inviteTargetId || null, targetEmail: inviteTargetEmail || null }
|
||||
})
|
||||
await Promise.all([
|
||||
saveActivity({
|
||||
streamId,
|
||||
resourceType: ResourceTypes.Stream,
|
||||
resourceId: streamId,
|
||||
actionType: ActionTypes.Stream.InviteSent,
|
||||
userId: inviterId,
|
||||
message: `User ${inviterId} has invited ${targetDisplay} to stream ${streamId}`,
|
||||
info: { targetId: inviteTargetId || null, targetEmail: inviteTargetEmail || null }
|
||||
}),
|
||||
publish(ProjectSubscriptions.ProjectUpdated, {
|
||||
projectUpdated: {
|
||||
id: streamId,
|
||||
type: ProjectUpdatedMessageType.Updated,
|
||||
project: stream
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,17 +384,27 @@ export async function addStreamInviteDeclinedActivity(params: {
|
||||
streamId: string
|
||||
inviteTargetId: string
|
||||
inviterId: string
|
||||
stream: StreamRecord
|
||||
}) {
|
||||
const { streamId, inviteTargetId, inviterId } = params
|
||||
await saveActivity({
|
||||
streamId,
|
||||
resourceType: ResourceTypes.Stream,
|
||||
resourceId: streamId,
|
||||
actionType: ActionTypes.Stream.InviteDeclined,
|
||||
userId: inviteTargetId,
|
||||
message: `User ${inviteTargetId} declined to join the stream ${streamId}`,
|
||||
info: { targetId: inviteTargetId, inviterId }
|
||||
})
|
||||
const { streamId, inviteTargetId, inviterId, stream } = params
|
||||
await Promise.all([
|
||||
saveActivity({
|
||||
streamId,
|
||||
resourceType: ResourceTypes.Stream,
|
||||
resourceId: streamId,
|
||||
actionType: ActionTypes.Stream.InviteDeclined,
|
||||
userId: inviteTargetId,
|
||||
message: `User ${inviteTargetId} declined to join the stream ${streamId}`,
|
||||
info: { targetId: inviteTargetId, inviterId }
|
||||
}),
|
||||
publish(ProjectSubscriptions.ProjectUpdated, {
|
||||
projectUpdated: {
|
||||
id: streamId,
|
||||
type: ProjectUpdatedMessageType.Updated,
|
||||
project: stream
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,7 +95,7 @@ module.exports = async (app) => {
|
||||
const userEmail = req.user.email
|
||||
const isInvite = !!req.user.isInvite
|
||||
if (userEmail) {
|
||||
await mixpanel({ userEmail }).track('Sign Up', {
|
||||
await mixpanel({ userEmail, req }).track('Sign Up', {
|
||||
isInvite
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ const onAutomationRunStatusUpdated =
|
||||
automationWithRevision.projectId
|
||||
)
|
||||
|
||||
const mp = mixpanel({ userEmail })
|
||||
const mp = mixpanel({ userEmail, req: undefined })
|
||||
await mp.track('Automate Function Run Finished', {
|
||||
automationId,
|
||||
automationRevisionId: automationWithRevision.id,
|
||||
@@ -133,7 +133,7 @@ const onRunCreated =
|
||||
automationRun,
|
||||
automation.projectId
|
||||
)
|
||||
const mp = mixpanel({ userEmail })
|
||||
const mp = mixpanel({ userEmail, req: undefined })
|
||||
await mp.track('Automation Run Triggered', {
|
||||
automationId: automation.id,
|
||||
automationName: automation.name,
|
||||
|
||||
@@ -227,7 +227,8 @@ export = {
|
||||
await useStreamInviteAndNotifyFactory({
|
||||
finalizeStreamInvite: finalizeStreamInviteFactory({
|
||||
findStreamInvite: findStreamInviteFactory({ db }),
|
||||
deleteInvitesByTarget: deleteInvitesByTargetFactory({ db })
|
||||
deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }),
|
||||
findResource: findResourceFactory()
|
||||
})
|
||||
})(args.input, ctx.userId!, ctx.resourceAccessRules)
|
||||
return true
|
||||
|
||||
@@ -119,7 +119,8 @@ async function removeStreamCollaborator(
|
||||
await addStreamPermissionsRevokedActivity({
|
||||
streamId,
|
||||
activityUserId: removedById,
|
||||
removedUserId: userId
|
||||
removedUserId: userId,
|
||||
stream
|
||||
})
|
||||
|
||||
return stream
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResourceTargets } from '@/modules/serverinvites/helpers/inviteHelper'
|
||||
import { Nullable } from '@/modules/shared/helpers/typeHelper'
|
||||
import { SetNonNullable } from 'type-fest'
|
||||
|
||||
@@ -8,7 +9,7 @@ export type ServerInviteRecord = {
|
||||
createdAt: Date
|
||||
used: boolean
|
||||
message: Nullable<string>
|
||||
resourceTarget: Nullable<string>
|
||||
resourceTarget: typeof ResourceTargets.Streams | null
|
||||
resourceId: Nullable<string>
|
||||
role: Nullable<string>
|
||||
token: string
|
||||
|
||||
@@ -205,7 +205,8 @@ export = {
|
||||
await useStreamInviteAndNotifyFactory({
|
||||
finalizeStreamInvite: finalizeStreamInviteFactory({
|
||||
findStreamInvite: findStreamInviteFactory({ db }),
|
||||
deleteInvitesByTarget: deleteInvitesByTargetFactory({ db })
|
||||
deleteInvitesByTarget: deleteInvitesByTargetFactory({ db }),
|
||||
findResource: findResourceFactory()
|
||||
})
|
||||
})(args, ctx.userId!, ctx.resourceAccessRules)
|
||||
return true
|
||||
|
||||
@@ -110,13 +110,14 @@ export const createAndSendInviteFactory =
|
||||
// send email and create activity stream item, if stream invite
|
||||
await Promise.all([
|
||||
emailsModule.sendEmail(emailParams),
|
||||
...(resourceTarget === ResourceTargets.Streams
|
||||
...(resourceTarget === ResourceTargets.Streams && resource
|
||||
? [
|
||||
addStreamInviteSentOutActivity({
|
||||
streamId: resourceId!, // TODO: check null
|
||||
inviterId,
|
||||
inviteTargetEmail: userEmail!, // TODO: this should be properly typed
|
||||
inviteTargetId: userId! // TODO: this should be properly typed
|
||||
inviteTargetId: userId!, // TODO: this should be properly typed
|
||||
stream: resource
|
||||
})
|
||||
]
|
||||
: [])
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DeleteServerOnlyInvites,
|
||||
DeleteStreamInvite,
|
||||
FindInvite,
|
||||
FindResource,
|
||||
FindServerInvite,
|
||||
FindStreamInvite,
|
||||
UpdateAllInviteTargets
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
FinalizeStreamInvite,
|
||||
ResendInviteEmail
|
||||
} from '@/modules/serverinvites/services/operations'
|
||||
import { StreamNotFoundError } from '@/modules/core/errors/stream'
|
||||
|
||||
/**
|
||||
* Resolve the relative auth redirect path, after registering with an invite
|
||||
@@ -100,10 +102,12 @@ export const finalizeInvitedServerRegistrationFactory =
|
||||
export const finalizeStreamInviteFactory =
|
||||
({
|
||||
findStreamInvite,
|
||||
deleteInvitesByTarget
|
||||
deleteInvitesByTarget,
|
||||
findResource
|
||||
}: {
|
||||
findStreamInvite: FindStreamInvite
|
||||
deleteInvitesByTarget: DeleteInvitesByTarget
|
||||
findResource: FindResource
|
||||
}): FinalizeStreamInvite =>
|
||||
async (accept, streamId, token, userId) => {
|
||||
const invite = await findStreamInvite(streamId, {
|
||||
@@ -120,6 +124,26 @@ export const finalizeStreamInviteFactory =
|
||||
})
|
||||
}
|
||||
|
||||
const stream = await findResource(invite)
|
||||
if (!stream) {
|
||||
throw new StreamNotFoundError('Stream not found for invite', {
|
||||
info: {
|
||||
streamId,
|
||||
token,
|
||||
userId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Delete all invites to this stream
|
||||
// We're doing this before processing the invite, to prevent a PROJECT UPDATED event
|
||||
// from being fired with the invites still attached
|
||||
await deleteInvitesByTarget(
|
||||
buildUserTarget(userId)!,
|
||||
ResourceTargets.Streams,
|
||||
streamId
|
||||
)
|
||||
|
||||
// Invite found - accept or decline
|
||||
if (accept) {
|
||||
// Add access for user
|
||||
@@ -128,27 +152,14 @@ export const finalizeStreamInviteFactory =
|
||||
await addOrUpdateStreamCollaborator(streamId, userId, role!, inviterId, null, {
|
||||
fromInvite: true
|
||||
})
|
||||
|
||||
// Delete all invites to this stream
|
||||
await deleteInvitesByTarget(
|
||||
buildUserTarget(userId)!,
|
||||
ResourceTargets.Streams,
|
||||
streamId
|
||||
)
|
||||
} else {
|
||||
await addStreamInviteDeclinedActivity({
|
||||
streamId,
|
||||
inviteTargetId: userId,
|
||||
inviterId: invite.inviterId
|
||||
inviterId: invite.inviterId,
|
||||
stream
|
||||
})
|
||||
}
|
||||
|
||||
// Delete all invites to this stream
|
||||
await deleteInvitesByTarget(
|
||||
buildUserTarget(userId)!,
|
||||
ResourceTargets.Streams,
|
||||
streamId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -188,7 +188,7 @@ export async function mixpanelTrackerHelperMiddleware(
|
||||
) {
|
||||
const ctx = req.context
|
||||
const user = ctx.userId ? await getUser(ctx.userId) : null
|
||||
const mp = mixpanel({ userEmail: user?.email })
|
||||
const mp = mixpanel({ userEmail: user?.email, req })
|
||||
|
||||
req.mixpanel = mp
|
||||
next()
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/* eslint-disable camelcase */
|
||||
import {
|
||||
Optional,
|
||||
resolveMixpanelUserId,
|
||||
resolveMixpanelServerId
|
||||
} from '@speckle/shared'
|
||||
import { Optional, resolveMixpanelUserId } from '@speckle/shared'
|
||||
import * as MixpanelUtils from '@speckle/shared/dist/commonjs/observability/mixpanel.js'
|
||||
import {
|
||||
enableMixpanel,
|
||||
getServerOrigin,
|
||||
@@ -11,23 +7,18 @@ import {
|
||||
} from '@/modules/shared/helpers/envHelper'
|
||||
import Mixpanel from 'mixpanel'
|
||||
import { mixpanelLogger } from '@/logging/logging'
|
||||
import type express from 'express'
|
||||
|
||||
let client: Optional<Mixpanel.Mixpanel> = undefined
|
||||
let baseTrackingProperties: Optional<Record<string, string>> = undefined
|
||||
|
||||
function getMixpanelServerId(debug = false): string {
|
||||
const canonicalUrl = getServerOrigin()
|
||||
const url = new URL(canonicalUrl)
|
||||
return debug ? url.hostname : resolveMixpanelServerId(url.hostname)
|
||||
}
|
||||
|
||||
function getBaseTrackingProperties() {
|
||||
if (baseTrackingProperties) return baseTrackingProperties
|
||||
baseTrackingProperties = {
|
||||
server_id: getMixpanelServerId(),
|
||||
baseTrackingProperties = MixpanelUtils.buildBasePropertiesPayload({
|
||||
hostApp: 'serverside',
|
||||
serverOrigin: getServerOrigin(),
|
||||
speckleVersion: getServerVersion()
|
||||
}
|
||||
})
|
||||
|
||||
return baseTrackingProperties
|
||||
}
|
||||
@@ -35,8 +26,9 @@ function getBaseTrackingProperties() {
|
||||
export function initialize() {
|
||||
if (client || !enableMixpanel()) return
|
||||
|
||||
client = Mixpanel.init('acd87c5a50b56df91a795e999812a3a4', {
|
||||
host: 'analytics.speckle.systems'
|
||||
client = MixpanelUtils.buildServerMixpanelClient({
|
||||
tokenId: 'acd87c5a50b56df91a795e999812a3a4',
|
||||
apiHostname: 'analytics.speckle.systems'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,24 +43,24 @@ export function getClient() {
|
||||
/**
|
||||
* Mixpanel tracking helper. An abstraction layer over the client that makes it a bit nicer to work with.
|
||||
*/
|
||||
export function mixpanel(params: { userEmail: Optional<string> }) {
|
||||
const { userEmail } = params
|
||||
export function mixpanel(params: {
|
||||
userEmail: Optional<string>
|
||||
req: Optional<express.Request>
|
||||
}) {
|
||||
const { userEmail, req } = params
|
||||
const mixpanelUserId = userEmail?.length
|
||||
? resolveMixpanelUserId(userEmail)
|
||||
: undefined
|
||||
|
||||
const getUserIdentificationProperties = () => ({
|
||||
...(mixpanelUserId
|
||||
? {
|
||||
distinct_id: mixpanelUserId
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
return {
|
||||
track: async (eventName: string, extraProperties?: Record<string, unknown>) => {
|
||||
const payload = {
|
||||
...getUserIdentificationProperties(),
|
||||
...MixpanelUtils.buildPropertiesPayload({
|
||||
distinctId: mixpanelUserId,
|
||||
query: req?.query || {},
|
||||
headers: req?.headers || {},
|
||||
remoteAddress: req?.socket?.remoteAddress
|
||||
}),
|
||||
...getBaseTrackingProperties(),
|
||||
...(extraProperties || {})
|
||||
}
|
||||
@@ -81,11 +73,7 @@ export function mixpanel(params: { userEmail: Optional<string> }) {
|
||||
{
|
||||
eventName,
|
||||
payload,
|
||||
err: err || false,
|
||||
debug: {
|
||||
userEmail,
|
||||
serverHostname: getMixpanelServerId(true)
|
||||
}
|
||||
...(err ? { err } : {})
|
||||
},
|
||||
'Mixpanel track() invoked'
|
||||
)
|
||||
@@ -100,4 +88,6 @@ export function mixpanel(params: { userEmail: Optional<string> }) {
|
||||
}
|
||||
}
|
||||
|
||||
export type MixpanelClient = ReturnType<typeof mixpanel>
|
||||
|
||||
export { resolveMixpanelUserId }
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"sharp": "^0.32.6",
|
||||
"string-pixel-width": "^1.10.0",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"undici": "^5.28.4",
|
||||
"verror": "^1.10.1",
|
||||
"xml-escape": "^1.1.0",
|
||||
@@ -146,6 +147,7 @@
|
||||
"@types/pg": "^8.6.6",
|
||||
"@types/sanitize-html": "^2.6.2",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@types/verror": "^1.10.6",
|
||||
"@types/yargs": "^17.0.10",
|
||||
"@types/zxcvbn": "^4.4.1",
|
||||
|
||||
@@ -38,9 +38,11 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.0.0-beta.176",
|
||||
"mixpanel": "^0.17.0",
|
||||
"pino": "^8.7.0",
|
||||
"pino-http": "^8.0.0",
|
||||
"pino-pretty": ">=8.0.0",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"znv": "^0.4.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
@@ -48,14 +50,17 @@
|
||||
"@tiptap/core": "^2.0.0-beta.176",
|
||||
"@types/lodash": "^4.14.184",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@typescript-eslint/eslint-plugin": "^7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"eslint": "^9.4.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"mixpanel": "^0.17.0",
|
||||
"pino": "^8.7.0",
|
||||
"pino-http": "^8.0.0",
|
||||
"tshy": "^1.14.0",
|
||||
"typescript": "^4.5.4",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"znv": "^0.4.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isNull, isNumber, isUndefined } from '#lodash'
|
||||
import { isNull, isNumber, isUndefined, noop } from '#lodash'
|
||||
import type {
|
||||
MaybeAsync,
|
||||
NonNullableProperties,
|
||||
@@ -144,3 +144,5 @@ export const removeNullOrUndefinedKeys = <T extends Record<string, unknown>>(
|
||||
|
||||
export const isArrayOf = <T>(arr: unknown, guard: (v: unknown) => v is T): arr is T[] =>
|
||||
Array.isArray(arr) && arr.every(guard)
|
||||
|
||||
export const waitForever = (): Promise<never> => new Promise<never>(noop)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable camelcase */
|
||||
import Mixpanel from 'mixpanel'
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import type { Optional } from '../core/helpers/utilityTypes.js'
|
||||
import UAParser from 'ua-parser-js'
|
||||
import { resolveMixpanelServerId } from '../core/helpers/tracking.js'
|
||||
|
||||
export const buildServerMixpanelClient = (params: {
|
||||
tokenId: string
|
||||
apiHostname: string
|
||||
debug?: boolean
|
||||
}) => {
|
||||
const { tokenId, apiHostname, debug } = params
|
||||
const client = Mixpanel.init(tokenId, {
|
||||
host: apiHostname,
|
||||
debug
|
||||
})
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
export const buildBasePropertiesPayload = (params: {
|
||||
/**
|
||||
* Host app identifier
|
||||
*/
|
||||
hostApp: string
|
||||
/**
|
||||
* The public origin (URL) of the server
|
||||
*/
|
||||
serverOrigin: string
|
||||
speckleVersion: string
|
||||
}) => {
|
||||
const { hostApp, serverOrigin, speckleVersion } = params
|
||||
|
||||
return {
|
||||
server_id: resolveMixpanelServerId(new URL(serverOrigin).hostname),
|
||||
hostApp,
|
||||
speckleVersion
|
||||
}
|
||||
}
|
||||
|
||||
export const buildPropertiesPayload = (params: {
|
||||
/**
|
||||
* User's distinctId. If not provided, the user will be treated as anonymous
|
||||
*/
|
||||
distinctId?: string
|
||||
headers?: IncomingHttpHeaders
|
||||
query?: Record<string, unknown>
|
||||
/**
|
||||
* User's IP address from request
|
||||
*/
|
||||
remoteAddress?: string
|
||||
}) => {
|
||||
const { distinctId, headers, query, remoteAddress } = params
|
||||
|
||||
const userProps = distinctId ? { distinct_id: distinctId } : {}
|
||||
|
||||
// User agent
|
||||
const userAgentString = headers?.['user-agent'] as Optional<string>
|
||||
const uaParser = userAgentString ? new UAParser(userAgentString) : null
|
||||
const uaProps = uaParser
|
||||
? {
|
||||
$browser: uaParser.getBrowser().name,
|
||||
$device: uaParser.getDevice().model,
|
||||
$os: uaParser.getOS().name
|
||||
}
|
||||
: {}
|
||||
|
||||
// Referer
|
||||
const refererHeader = headers?.['referer'] as Optional<string>
|
||||
const refererDomain = refererHeader ? new URL(refererHeader).host : null
|
||||
const refererProps = {
|
||||
...(refererHeader ? { $referrer: refererHeader } : {}),
|
||||
...(refererDomain ? { $referring_domain: refererDomain } : {})
|
||||
}
|
||||
|
||||
// Utm
|
||||
const utmKeys = [
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_content',
|
||||
'utm_term'
|
||||
]
|
||||
const utmProps = utmKeys.reduce((acc, key) => {
|
||||
const value = query?.[key] as Optional<string>
|
||||
return value ? { ...acc, [key]: value } : acc
|
||||
}, {})
|
||||
|
||||
// Remote addr
|
||||
const remoteAddr = (headers?.['x-forwarded-for'] as Optional<string>) || remoteAddress
|
||||
const remoteAddrProps = remoteAddr ? { ip: remoteAddr } : {}
|
||||
|
||||
return {
|
||||
...userProps,
|
||||
...uaProps,
|
||||
...refererProps,
|
||||
...utmProps,
|
||||
...remoteAddrProps
|
||||
}
|
||||
}
|
||||
@@ -97,16 +97,16 @@ import {
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { XMarkIcon } from '@heroicons/vue/20/solid'
|
||||
import { computed } from 'vue'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { ToastNotificationType } from '~~/src/helpers/global/toast'
|
||||
import type { ToastNotification } from '~~/src/helpers/global/toast'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:notification', val: Nullable<ToastNotification>): void
|
||||
(e: 'update:notification', val: MaybeNullOrUndefined<ToastNotification>): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
notification: Nullable<ToastNotification>
|
||||
notification: MaybeNullOrUndefined<ToastNotification>
|
||||
}>()
|
||||
|
||||
const isTitleOnly = computed(
|
||||
|
||||
@@ -15431,6 +15431,7 @@ __metadata:
|
||||
"@types/mixpanel-browser": "npm:^2.38.0"
|
||||
"@types/node": "npm:^18.17.5"
|
||||
"@types/pino-http": "npm:^5.8.1"
|
||||
"@types/ua-parser-js": "npm:^0.7.39"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^7.12.0"
|
||||
"@typescript-eslint/parser": "npm:^7.12.0"
|
||||
"@vitejs/plugin-legacy": "npm:^5.4.1"
|
||||
@@ -15455,6 +15456,7 @@ __metadata:
|
||||
marked: "npm:^5.1.0"
|
||||
marked-plaintext: "npm:^0.0.2"
|
||||
mitt: "npm:^3.0.0"
|
||||
mixpanel: "npm:^0.18.0"
|
||||
mixpanel-browser: "npm:^2.45.0"
|
||||
nanoid: "npm:^3.0.0"
|
||||
nuxt: "npm:^3.12.2"
|
||||
@@ -15480,6 +15482,7 @@ __metadata:
|
||||
tweetnacl-util: "npm:^0.15.1"
|
||||
type-fest: "npm:^3.5.1"
|
||||
typescript: "npm:^4.8.3"
|
||||
ua-parser-js: "npm:^1.0.38"
|
||||
vee-validate: "npm:^4.7.0"
|
||||
vue-advanced-cropper: "npm:^2.8.8"
|
||||
vue-tippy: "npm:^6.0.0"
|
||||
@@ -15725,6 +15728,7 @@ __metadata:
|
||||
"@types/pino-http": "npm:^5.8.4"
|
||||
"@types/sanitize-html": "npm:^2.6.2"
|
||||
"@types/supertest": "npm:^2.0.12"
|
||||
"@types/ua-parser-js": "npm:^0.7.39"
|
||||
"@types/uuid": "npm:^9.0.0"
|
||||
"@types/verror": "npm:^1.10.6"
|
||||
"@types/yargs": "npm:^17.0.10"
|
||||
@@ -15812,6 +15816,7 @@ __metadata:
|
||||
type-fest: "npm:^2.19.0"
|
||||
typescript: "npm:^4.6.4"
|
||||
typescript-eslint: "npm:^7.12.0"
|
||||
ua-parser-js: "npm:^1.0.38"
|
||||
undici: "npm:^5.28.4"
|
||||
verror: "npm:^1.10.1"
|
||||
ws: "npm:^8.17.1"
|
||||
@@ -15846,24 +15851,29 @@ __metadata:
|
||||
"@tiptap/core": "npm:^2.0.0-beta.176"
|
||||
"@types/lodash": "npm:^4.14.184"
|
||||
"@types/lodash-es": "npm:^4.17.12"
|
||||
"@types/ua-parser-js": "npm:^0.7.39"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^7.12.0"
|
||||
"@typescript-eslint/parser": "npm:^7.12.0"
|
||||
eslint: "npm:^9.4.0"
|
||||
eslint-config-prettier: "npm:^9.1.0"
|
||||
lodash: "npm:^4.17.0"
|
||||
lodash-es: "npm:^4.17.21"
|
||||
mixpanel: "npm:^0.17.0"
|
||||
pino: "npm:^8.7.0"
|
||||
pino-http: "npm:^8.0.0"
|
||||
tshy: "npm:^1.14.0"
|
||||
type-fest: "npm:^3.11.1"
|
||||
typescript: "npm:^4.5.4"
|
||||
ua-parser-js: "npm:^1.0.38"
|
||||
znv: "npm:^0.4.0"
|
||||
zod: "npm:^3.22.4"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^2.0.0-beta.176
|
||||
mixpanel: ^0.17.0
|
||||
pino: ^8.7.0
|
||||
pino-http: ^8.0.0
|
||||
pino-pretty: ">=8.0.0"
|
||||
ua-parser-js: ^1.0.38
|
||||
znv: ^0.4.0
|
||||
zod: ^3.22.4
|
||||
languageName: unknown
|
||||
@@ -19343,6 +19353,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/ua-parser-js@npm:^0.7.39":
|
||||
version: 0.7.39
|
||||
resolution: "@types/ua-parser-js@npm:0.7.39"
|
||||
checksum: 10/8d173a79b37f9404cda49e848d82694c4854828ff20d94ddfba53bf9ccd9b4df16bf28f72786cf253a73305e9a54d2f2bb54ebfc144fca2496d3f3f025a79cb5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/unist@npm:*, @types/unist@npm:^3.0.0":
|
||||
version: 3.0.2
|
||||
resolution: "@types/unist@npm:3.0.2"
|
||||
@@ -37891,6 +37908,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mixpanel@npm:^0.18.0":
|
||||
version: 0.18.0
|
||||
resolution: "mixpanel@npm:0.18.0"
|
||||
dependencies:
|
||||
https-proxy-agent: "npm:5.0.0"
|
||||
checksum: 10/90cd6be667e75d5a34fc54c2b7947f9d095736f8925d68586143f73ad525503d3951e852b3ae4349df04f21a1fa810325023d20126c145ebaff96b03745b732c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mjml-accordion@npm:4.13.0":
|
||||
version: 4.13.0
|
||||
resolution: "mjml-accordion@npm:4.13.0"
|
||||
@@ -49295,6 +49321,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ua-parser-js@npm:^1.0.38":
|
||||
version: 1.0.38
|
||||
resolution: "ua-parser-js@npm:1.0.38"
|
||||
checksum: 10/f2345e9bd0f9c5f85bcaa434535fae88f4bb891538e568106f0225b2c2937fbfbeb5782bd22320d07b6b3d68b350b8861574c1d7af072ff9b2362fb72d326fd9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uc.micro@npm:^1.0.1, uc.micro@npm:^1.0.5":
|
||||
version: 1.0.6
|
||||
resolution: "uc.micro@npm:1.0.6"
|
||||
|
||||
Reference in New Issue
Block a user