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:
Kristaps Fabians Geikins
2024-07-11 11:45:11 +03:00
committed by GitHub
parent 790d97383c
commit ee5ae8af62
43 changed files with 774 additions and 347 deletions
-4
View File
@@ -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
}>()
+6 -2
View File
@@ -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 }
)
}
})
@@ -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
})
})
+3
View File
@@ -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)
})
})
-83
View File
@@ -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
}
})
])
}
/**
+1 -1
View File
@@ -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 }
+2
View File
@@ -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",
+5
View File
@@ -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"
},
+3 -1
View File
@@ -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(
+33
View File
@@ -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"