Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fe2): error reporting when invite (middleware level) auto-accept doesn't work #2711

Merged
merged 3 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/frontend-2/lib/common/generated/gql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,10 @@ const documents = {
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": types.SettingsUserProfileDetails_UserFragmentDoc,
"\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n": types.UserProfileEditDialogAvatar_UserFragmentDoc,
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n id\n name\n description\n logo\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsWorkspacesProjects_ProjectCollectionFragmentDoc,
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsWorkspacesProjects_ProjectCollectionFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n inviteId\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc,
Expand Down Expand Up @@ -669,19 +669,19 @@ export function graphql(source: "\n fragment SettingsWorkspacesGeneral_Workspac
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n"): (typeof documents)["\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n"): (typeof documents)["\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down
8 changes: 4 additions & 4 deletions packages/frontend-2/lib/common/generated/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4216,14 +4216,14 @@ export type UserProfileEditDialogAvatar_UserFragment = { __typename?: 'User', id

export type SettingsWorkspacesGeneral_WorkspaceFragment = { __typename?: 'Workspace', id: string, name: string, description?: string | null, logo?: string | null };

export type SettingsWorkspacesMembers_WorkspaceFragment = { __typename?: 'Workspace', id: string, role?: string | null };

export type SettingsWorkspacesProjects_ProjectCollectionFragment = { __typename?: 'ProjectCollection', totalCount: number, items: Array<{ __typename?: 'Project', id: string, name: string, visibility: ProjectVisibility, createdAt: string, updatedAt: string, models: { __typename?: 'ModelCollection', totalCount: number }, versions: { __typename?: 'VersionCollection', totalCount: number }, team: Array<{ __typename?: 'ProjectCollaborator', id: string, user: { __typename?: 'LimitedUser', name: string, id: string, avatar?: string | null } }> }> };

export type SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragment = { __typename?: 'Workspace', id: string, name: string };

export type SettingsWorkspacesGeneralEditAvatar_WorkspaceFragment = { __typename?: 'Workspace', id: string, logo?: string | null, name: string };

export type SettingsWorkspacesMembers_WorkspaceFragment = { __typename?: 'Workspace', id: string, role?: string | null };

export type SettingsWorkspacesProjects_ProjectCollectionFragment = { __typename?: 'ProjectCollection', totalCount: number, items: Array<{ __typename?: 'Project', id: string, name: string, visibility: ProjectVisibility, createdAt: string, updatedAt: string, models: { __typename?: 'ModelCollection', totalCount: number }, versions: { __typename?: 'VersionCollection', totalCount: number }, team: Array<{ __typename?: 'ProjectCollaborator', id: string, user: { __typename?: 'LimitedUser', name: string, id: string, avatar?: string | null } }> }> };

export type SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragment = { __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', id: string, avatar?: string | null, name: string, company?: string | null, verified?: boolean | null } };

export type SettingsWorkspacesMembersGuestsTable_WorkspaceFragment = { __typename?: 'Workspace', id: string, role?: string | null, team: Array<{ __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', id: string, avatar?: string | null, name: string, company?: string | null, verified?: boolean | null } }>, invitedTeam?: Array<{ __typename?: 'PendingWorkspaceCollaborator', title: string, user?: { __typename?: 'LimitedUser', id: string } | null }> | null };
Expand Down
16 changes: 12 additions & 4 deletions packages/frontend-2/lib/workspaces/composables/management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
inviteToWorkspaceMutation,
processWorkspaceInviteMutation
} from '~/lib/workspaces/graphql/mutations'
import { isFunction } from 'lodash-es'
import type { GraphQLError } from 'graphql'

export const useInviteUserToWorkspace = () => {
const { activeUser } = useActiveUser()
Expand Down Expand Up @@ -114,7 +116,9 @@ export const useProcessWorkspaceInvite = () => {
* Do something once mutation has finished, before all cache updates
*/
callback: () => MaybeAsync<void>
preventErrorToasts?: boolean
preventErrorToasts?:
| boolean
| ((errors: GraphQLError[], errMsg: string) => boolean)
}>
) => {
if (!isWorkspacesEnabled.value) return
Expand Down Expand Up @@ -185,7 +189,12 @@ export const useProcessWorkspaceInvite = () => {
accepted: input.accept
})
} else {
if (!options?.preventErrorToasts) {
const err = getFirstErrorMessage(errors)
const preventErrorToasts = isFunction(options?.preventErrorToasts)
? options?.preventErrorToasts(errors?.slice() || [], err)
: options?.preventErrorToasts

if (!preventErrorToasts) {
const err = getFirstErrorMessage(errors)
triggerNotification({
type: ToastNotificationType.Danger,
Expand Down Expand Up @@ -222,7 +231,7 @@ export const useWorkspaceInviteManager = <
*/
preventRedirect: boolean
route: RouteLocationNormalized
preventErrorToasts: boolean
preventErrorToasts: boolean | ((errors: GraphQLError[], errMsg: string) => boolean)
}>
) => {
const isWorkspacesEnabled = useIsWorkspacesEnabled()
Expand Down Expand Up @@ -264,7 +273,6 @@ export const useWorkspaceInviteManager = <
const { addNewEmail } = options || {}
if (!isWorkspacesEnabled.value) return false
if (!token.value || !invite.value) return false
if (needsToAddNewEmail.value && !addNewEmail) return false

const workspaceId = invite.value.workspaceId
const shouldAddNewEmail = canAddNewEmail.value && addNewEmail
Expand Down
29 changes: 26 additions & 3 deletions packages/frontend-2/middleware/003-acceptInvites.global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { graphql } from '~/lib/common/generated/gql'
import type { UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
import { useProjectInviteManager } from '~/lib/projects/composables/invites'
import { useWorkspaceInviteManager } from '~/lib/workspaces/composables/management'
import { workspaceAccessCheckQuery } from '~/lib/workspaces/graphql/queries'

const autoAcceptableWorkspaceInviteQuery = graphql(`
query AutoAcceptableWorkspaceInvite($token: String!, $workspaceId: String!) {
Expand All @@ -29,6 +30,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
const token = to.query.token as Optional<string>
const accept = to.query.accept === 'true'
const addNewEmail = to.query.addNewEmail === 'true'
let hasWorkspaceAccess = false

if (!idParam?.length) return
if (!shouldTryProjectAccept && !shouldTryWorkspaceAccept) return
Expand All @@ -46,18 +48,27 @@ export default defineNuxtRouteMiddleware(async (to) => {
{
route: to,
preventRedirect: true,
preventErrorToasts: true
preventErrorToasts: (errors) => {
// Don't show if INVITE_FINALIZED_FOR_NEW_EMAIL and doesn't have any workspace access yet,
// cause we expect the user to manually press the "Add email" button in that scenario
const isNewEmailError = errors.some(
(e) => e.extensions?.code === 'INVITE_FINALIZED_FOR_NEW_EMAIL'
)
if (isNewEmailError && !hasWorkspaceAccess) return true

return false
}
}
)

const [activeUserData, workspaceInviteData] = await Promise.all([
const [activeUserData, workspaceInviteData, workspaceAccessData] = await Promise.all([
client
.query({
query: activeUserQuery
})
.catch(convertThrowIntoFetchResult),
...(shouldTryWorkspaceAccept
? [
? <const>[
client
.query({
query: autoAcceptableWorkspaceInviteQuery,
Expand All @@ -66,13 +77,25 @@ export default defineNuxtRouteMiddleware(async (to) => {
workspaceId: idParam
}
})
.catch(convertThrowIntoFetchResult),
client
.query({
query: workspaceAccessCheckQuery,
variables: {
id: idParam
}
})
.catch(convertThrowIntoFetchResult)
]
: [])
])

if (workspaceInviteData?.data?.workspaceInvite) {
workspaceInvite.value = workspaceInviteData.data.workspaceInvite
}
if (workspaceAccessData?.data?.workspace.id) {
hasWorkspaceAccess = true
}

// Ignore if not logged in
if (!activeUserData.data?.activeUser?.id) return
Expand Down
6 changes: 6 additions & 0 deletions packages/server/modules/serverinvites/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export class InviteFinalizingError extends BaseError {
static defaultMessage = 'An issue occurred while finalizing the invitation'
}

export class InviteFinalizedForNewEmail extends BaseError {
static code = 'INVITE_FINALIZED_FOR_NEW_EMAIL'
static defaultMessage =
'Attempted to finalize an invite for a mismatched e-mail address'
}

export class NoInviteFoundError extends BaseError {
static code = 'NO_INVITE_FOUND'
static defaultMessage = 'No invitation for the related resources was found'
Expand Down
31 changes: 23 additions & 8 deletions packages/server/modules/serverinvites/services/processing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getStreamRoute } from '@/modules/core/helpers/routeHelper'
import {
InviteFinalizedForNewEmail,
InviteFinalizingError,
NoInviteFoundError
} from '@/modules/serverinvites/errors'
Expand Down Expand Up @@ -146,7 +147,7 @@ export const finalizeResourceInviteFactory =
const finalizerUserTarget = buildUserTarget(finalizerUserId)
const invite = await findInvite({
token,
target: allowAttachingNewEmail ? undefined : finalizerUserTarget,
// target: allowAttachingNewEmail ? undefined : finalizerUserTarget,
resourceFilter: resourceType ? { resourceType } : undefined
})
if (!invite) {
Expand All @@ -165,13 +166,27 @@ export const finalizeResourceInviteFactory =
}
}

if (!isNewEmailTarget && invite.target !== finalizerUserTarget) {
throw new InviteFinalizingError('Attempted to finalize mismatched invite', {
info: {
finalizerUserId,
invite
}
})
if (isNewEmailTarget) {
if (!allowAttachingNewEmail) {
throw new InviteFinalizedForNewEmail(
InviteFinalizedForNewEmail.defaultMessage,
{
info: {
finalizerUserId,
invite
}
}
)
}
} else {
if (invite.target !== finalizerUserTarget) {
throw new InviteFinalizingError('Attempted to finalize mismatched invite', {
info: {
finalizerUserId,
invite
}
})
}
}

const action = accept
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,25 @@ describe('Workspaces Invites GQL', () => {
}
)

it("can't accept the invite, if it belongs to another user", async () => {
const res = await gqlHelpers.useInvite(
{
input: {
accept: true,
token: processableWorkspaceInvite.token
}
},
{
context: {
userId: myWorkspaceFriend.id
}
}
)

expect(res).to.haveGraphQLErrors()
expect(res.data?.workspaceMutations?.invites?.use).to.not.be.ok
})

it("can't accept invite, if token resource access rules prevent it", async () => {
const res = await gqlHelpers.useInvite(
{
Expand Down