feat(fe2): invite + list workspace invites (#2629)
* list invites table * invites list works * update last reminded date on resend * fix FE * WIP invitedialog + updated debounced utility * invite create works * exclude users correctly * more adjustments * minor cleanup * using workspace invite server role * test fix * fixed multiple root eslint issues * minor adjustments
This commit is contained in:
committed by
GitHub
parent
03db1cca94
commit
4dae1569cd
@@ -338,6 +338,7 @@ export const ServerInvites = buildTableHelper('server_invites', [
|
||||
'target',
|
||||
'inviterId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'message',
|
||||
'resource',
|
||||
'token'
|
||||
|
||||
@@ -1734,12 +1734,17 @@ export type PendingWorkspaceCollaborator = {
|
||||
title: Scalars['String']['output'];
|
||||
/** Only available if the active user is the pending workspace collaborator */
|
||||
token?: Maybe<Scalars['String']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** Set only if user is registered */
|
||||
user?: Maybe<LimitedUser>;
|
||||
workspaceId: Scalars['String']['output'];
|
||||
workspaceName: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PendingWorkspaceCollaboratorsFilter = {
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
__typename?: 'Project';
|
||||
allowPublicComments: Scalars['Boolean']['output'];
|
||||
@@ -3831,6 +3836,11 @@ export type Workspace = {
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceInvitedTeamArgs = {
|
||||
filter?: InputMaybe<PendingWorkspaceCollaboratorsFilter>;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceProjectsArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<WorkspaceProjectsFilter>;
|
||||
@@ -3866,6 +3876,8 @@ export type WorkspaceInviteCreateInput = {
|
||||
email?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Defaults to the member role, if not specified */
|
||||
role?: InputMaybe<WorkspaceRole>;
|
||||
/** Defaults to User, if not specified */
|
||||
serverRole?: InputMaybe<ServerRole>;
|
||||
/** Either this or email must be filled */
|
||||
userId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
@@ -4167,6 +4179,7 @@ export type ResolversTypes = {
|
||||
PasswordStrengthCheckResults: ResolverTypeWrapper<PasswordStrengthCheckResults>;
|
||||
PendingStreamCollaborator: ResolverTypeWrapper<PendingStreamCollaboratorGraphQLReturn>;
|
||||
PendingWorkspaceCollaborator: ResolverTypeWrapper<PendingWorkspaceCollaboratorGraphQLReturn>;
|
||||
PendingWorkspaceCollaboratorsFilter: PendingWorkspaceCollaboratorsFilter;
|
||||
Project: ResolverTypeWrapper<ProjectGraphQLReturn>;
|
||||
ProjectAccessRequest: ResolverTypeWrapper<ProjectAccessRequestGraphQLReturn>;
|
||||
ProjectAccessRequestMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
|
||||
@@ -4409,6 +4422,7 @@ export type ResolversParentTypes = {
|
||||
PasswordStrengthCheckResults: PasswordStrengthCheckResults;
|
||||
PendingStreamCollaborator: PendingStreamCollaboratorGraphQLReturn;
|
||||
PendingWorkspaceCollaborator: PendingWorkspaceCollaboratorGraphQLReturn;
|
||||
PendingWorkspaceCollaboratorsFilter: PendingWorkspaceCollaboratorsFilter;
|
||||
Project: ProjectGraphQLReturn;
|
||||
ProjectAccessRequest: ProjectAccessRequestGraphQLReturn;
|
||||
ProjectAccessRequestMutations: MutationsObjectGraphQLReturn;
|
||||
@@ -5196,6 +5210,7 @@ export type PendingWorkspaceCollaboratorResolvers<ContextType = GraphQLContext,
|
||||
role?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
token?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
user?: Resolver<Maybe<ResolversTypes['LimitedUser']>, ParentType, ContextType>;
|
||||
workspaceId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
workspaceName?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
@@ -5848,7 +5863,7 @@ export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends
|
||||
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, ParentType, ContextType>;
|
||||
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, ParentType, ContextType, Partial<WorkspaceInvitedTeamArgs>>;
|
||||
logo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
projects?: Resolver<ResolversTypes['ProjectCollection'], ParentType, ContextType, RequireFields<WorkspaceProjectsArgs, 'limit'>>;
|
||||
|
||||
@@ -1723,12 +1723,17 @@ export type PendingWorkspaceCollaborator = {
|
||||
title: Scalars['String']['output'];
|
||||
/** Only available if the active user is the pending workspace collaborator */
|
||||
token?: Maybe<Scalars['String']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** Set only if user is registered */
|
||||
user?: Maybe<LimitedUser>;
|
||||
workspaceId: Scalars['String']['output'];
|
||||
workspaceName: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PendingWorkspaceCollaboratorsFilter = {
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
__typename?: 'Project';
|
||||
allowPublicComments: Scalars['Boolean']['output'];
|
||||
@@ -3820,6 +3825,11 @@ export type Workspace = {
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceInvitedTeamArgs = {
|
||||
filter?: InputMaybe<PendingWorkspaceCollaboratorsFilter>;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceProjectsArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<WorkspaceProjectsFilter>;
|
||||
@@ -3855,6 +3865,8 @@ export type WorkspaceInviteCreateInput = {
|
||||
email?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Defaults to the member role, if not specified */
|
||||
role?: InputMaybe<WorkspaceRole>;
|
||||
/** Defaults to User, if not specified */
|
||||
serverRole?: InputMaybe<ServerRole>;
|
||||
/** Either this or email must be filled */
|
||||
userId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,10 @@ import { ServerInviteResourceFilter } from '@/modules/serverinvites/repositories
|
||||
|
||||
export type FindUserByTarget = (target: string) => Promise<UserWithOptionalRole | null>
|
||||
|
||||
export type ServerInviteRecordInsertModel = Omit<ServerInviteRecord, 'createdAt'>
|
||||
export type ServerInviteRecordInsertModel = Omit<
|
||||
ServerInviteRecord,
|
||||
'createdAt' | 'updatedAt'
|
||||
>
|
||||
|
||||
export type InsertInviteAndDeleteOld = (
|
||||
invite: ServerInviteRecordInsertModel,
|
||||
@@ -57,7 +60,7 @@ export type QueryAllResourceInvites = <
|
||||
filter: Pick<
|
||||
InviteResourceTarget<TargetType, RoleType>,
|
||||
'resourceId' | 'resourceType'
|
||||
>
|
||||
> & { search?: string }
|
||||
) => Promise<ServerInviteRecord<InviteResourceTarget<TargetType, RoleType>>[]>
|
||||
|
||||
export type DeleteAllResourceInvites = <
|
||||
@@ -104,3 +107,5 @@ export type CreateInviteParams = {
|
||||
message?: string | null
|
||||
primaryResourceTarget: PrimaryInviteResourceTarget
|
||||
}
|
||||
|
||||
export type MarkInviteUpdated = (params: { inviteId: string }) => Promise<boolean>
|
||||
|
||||
@@ -54,6 +54,7 @@ export type ServerInviteRecord<
|
||||
target: string
|
||||
inviterId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
message: Nullable<string>
|
||||
resource: PrimaryInviteResourceTarget<Resource>
|
||||
token: string
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
insertInviteAndDeleteOldFactory,
|
||||
deleteInviteFactory as deleteInviteFromDbFactory,
|
||||
queryAllUserResourceInvitesFactory,
|
||||
queryAllResourceInvitesFactory
|
||||
queryAllResourceInvitesFactory,
|
||||
markInviteUpdatedfactory
|
||||
} from '@/modules/serverinvites/repositories/serverInvites'
|
||||
import {
|
||||
createProjectInviteFactory,
|
||||
@@ -301,7 +302,8 @@ export = {
|
||||
getStream
|
||||
}),
|
||||
findUserByTarget: findUserByTargetFactory(),
|
||||
findInvite: findInviteFactory({ db })
|
||||
findInvite: findInviteFactory({ db }),
|
||||
markInviteUpdated: markInviteUpdatedfactory({ db })
|
||||
})
|
||||
|
||||
await resendInviteEmail({ inviteId })
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Knex } from 'knex'
|
||||
|
||||
const TABLE_NAME = 'server_invites'
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable(TABLE_NAME, (table) => {
|
||||
table
|
||||
.timestamp('updatedAt', { precision: 3, useTz: true })
|
||||
.defaultTo(knex.fn.now())
|
||||
.notNullable()
|
||||
})
|
||||
|
||||
// set updatedAt to be same value as createdAt
|
||||
await knex(TABLE_NAME).update({ updatedAt: knex.raw('??', ['createdAt']) })
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable(TABLE_NAME, (table) => {
|
||||
table.dropColumn('updatedAt')
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { knex, ServerInvites, Streams } from '@/modules/core/dbSchema'
|
||||
import { knex, ServerInvites, Streams, Users } from '@/modules/core/dbSchema'
|
||||
import {
|
||||
getUserByEmail,
|
||||
getUser,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
FindServerInvite,
|
||||
FindServerInvites,
|
||||
InsertInviteAndDeleteOld,
|
||||
MarkInviteUpdated,
|
||||
QueryAllResourceInvites,
|
||||
QueryAllUserResourceInvites,
|
||||
QueryInvites,
|
||||
@@ -91,7 +92,7 @@ const buildInvitesBaseQuery =
|
||||
|
||||
const q = db(ServerInvites.name)
|
||||
.select<Result>(ServerInvites.cols)
|
||||
.orderBy(ServerInvites.col.createdAt, sort)
|
||||
.orderBy(ServerInvites.col.updatedAt, sort)
|
||||
|
||||
// single built in filter
|
||||
projectInviteValidityFilter(q)
|
||||
@@ -241,13 +242,31 @@ export const queryAllResourceInvitesFactory =
|
||||
filter: Pick<
|
||||
InviteResourceTarget<TargetType, RoleType>,
|
||||
'resourceId' | 'resourceType'
|
||||
>
|
||||
> & { search?: string }
|
||||
) => {
|
||||
if (!filter.resourceId) return []
|
||||
|
||||
return await buildInvitesBaseQuery({ db })<
|
||||
const q = buildInvitesBaseQuery({ db })<
|
||||
ServerInviteRecord<InviteResourceTarget<TargetType, RoleType>>[]
|
||||
>({ filterQuery }).where((q) => filterByResource(q, filter))
|
||||
>({ filterQuery })
|
||||
|
||||
q.where((q) => filterByResource(q, filter))
|
||||
|
||||
if (filter.search) {
|
||||
q.leftJoin(
|
||||
Users.name,
|
||||
Users.col.id,
|
||||
knex.raw('SUBSTRING(?? FROM 2)', [ServerInvites.col.target])
|
||||
).where((w1) => {
|
||||
w1.where(ServerInvites.col.target, 'ILIKE', `%${filter.search}%`).orWhere(
|
||||
Users.col.name,
|
||||
'ILIKE',
|
||||
`%${filter.search}%`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return await q
|
||||
}
|
||||
|
||||
export const deleteAllResourceInvitesFactory =
|
||||
@@ -481,3 +500,13 @@ export const findInviteByTokenFactory =
|
||||
|
||||
return (await q) || null
|
||||
}
|
||||
|
||||
export const markInviteUpdatedfactory =
|
||||
({ db }: { db: Knex }): MarkInviteUpdated =>
|
||||
async ({ inviteId }) => {
|
||||
const cols = ServerInvites.with({ withoutTablePrefix: true }).col
|
||||
const ret = await db(ServerInvites.name)
|
||||
.where(ServerInvites.col.id, inviteId)
|
||||
.update(cols.updatedAt, new Date())
|
||||
return !!ret
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FindInvite,
|
||||
FindUserByTarget,
|
||||
InsertInviteAndDeleteOld,
|
||||
MarkInviteUpdated,
|
||||
ServerInviteRecordInsertModel
|
||||
} from '@/modules/serverinvites/domain/operations'
|
||||
import {
|
||||
@@ -188,11 +189,13 @@ export const resendInviteEmailFactory =
|
||||
({
|
||||
buildInviteEmailContents,
|
||||
findUserByTarget,
|
||||
findInvite
|
||||
findInvite,
|
||||
markInviteUpdated
|
||||
}: {
|
||||
buildInviteEmailContents: BuildInviteEmailContents
|
||||
findUserByTarget: FindUserByTarget
|
||||
findInvite: FindInvite
|
||||
markInviteUpdated: MarkInviteUpdated
|
||||
}): ResendInviteEmail =>
|
||||
async (params: { inviteId: string }) => {
|
||||
const sendInviteEmail = sendInviteEmailFactory({ buildInviteEmailContents })
|
||||
@@ -221,4 +224,6 @@ export const resendInviteEmailFactory =
|
||||
targetUser,
|
||||
targetData
|
||||
})
|
||||
|
||||
await markInviteUpdated({ inviteId })
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
UseStreamInviteDocument
|
||||
} from '@/test/graphql/generated/graphql'
|
||||
import { grantStreamPermissions } from '@/modules/core/repositories/streams'
|
||||
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
|
||||
import { reduce } from 'lodash'
|
||||
|
||||
async function cleanup() {
|
||||
await truncateTables([ServerInvites.name, Streams.name, Users.name])
|
||||
@@ -503,6 +505,17 @@ describe('[Stream & Server Invites]', () => {
|
||||
)
|
||||
|
||||
const inviteIds = invites.map((i) => i.inviteId)
|
||||
const inviteLastRemindedDates = reduce(
|
||||
await ServerInvites.knex<ServerInviteRecord[]>().whereIn(
|
||||
ServerInvites.col.id,
|
||||
inviteIds
|
||||
),
|
||||
(res, item) => {
|
||||
res[item.id] = item.updatedAt
|
||||
return res
|
||||
},
|
||||
{} as Record<string, Date>
|
||||
)
|
||||
|
||||
const results = await Promise.all(
|
||||
inviteIds.map((inviteId) =>
|
||||
@@ -516,6 +529,22 @@ describe('[Stream & Server Invites]', () => {
|
||||
}
|
||||
|
||||
expect(sendEmailInvocations.length()).to.eq(inviteIds.length)
|
||||
|
||||
const newInviteLastRemindedDates = reduce(
|
||||
await ServerInvites.knex<ServerInviteRecord[]>().whereIn(
|
||||
ServerInvites.col.id,
|
||||
inviteIds
|
||||
),
|
||||
(res, item) => {
|
||||
res[item.id] = item.updatedAt
|
||||
return res
|
||||
},
|
||||
{} as Record<string, Date>
|
||||
)
|
||||
|
||||
for (const [id, newDate] of Object.entries(newInviteLastRemindedDates)) {
|
||||
expect(newDate).to.be.greaterThan(inviteLastRemindedDates[id])
|
||||
}
|
||||
})
|
||||
|
||||
it('they can delete pre-existing invites irregardless of type', async () => {
|
||||
|
||||
@@ -41,13 +41,15 @@ export const onProjectCreatedFactory =
|
||||
const workspaceMembers = await getWorkspaceRoles({ workspaceId })
|
||||
|
||||
await Promise.all(
|
||||
workspaceMembers.map(({ userId, role: workspaceRole }) =>
|
||||
grantStreamPermissions({
|
||||
workspaceMembers.map(({ userId, role: workspaceRole }) => {
|
||||
if (workspaceRole === Roles.Workspace.Guest) return
|
||||
|
||||
return grantStreamPermissions({
|
||||
streamId: projectId,
|
||||
userId,
|
||||
role: mapWorkspaceRoleToProjectRole(workspaceRole)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
|
||||
return collaborators
|
||||
},
|
||||
invitedTeam: async (parent) => {
|
||||
invitedTeam: async (parent, args) => {
|
||||
const getPendingTeam = getPendingWorkspaceCollaboratorsFactory({
|
||||
queryAllResourceInvites: queryAllResourceInvitesFactory({
|
||||
db,
|
||||
@@ -444,7 +444,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
getInvitationTargetUsers: getInvitationTargetUsersFactory({ getUsers })
|
||||
})
|
||||
|
||||
return await getPendingTeam({ workspaceId: parent.id })
|
||||
return await getPendingTeam({ workspaceId: parent.id, filter: args.filter })
|
||||
},
|
||||
projects: async (parent, args) => {
|
||||
const getWorkspaceProjects = getWorkspaceProjectsFactory({ getStreams })
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
|
||||
import {
|
||||
PendingWorkspaceCollaboratorsFilter,
|
||||
TokenResourceIdentifierType,
|
||||
WorkspaceInviteCreateInput
|
||||
} from '@/modules/core/graph/generated/graphql'
|
||||
import { mapServerRoleToValue } from '@/modules/core/helpers/graphTypes'
|
||||
import { getWorkspaceRoute } from '@/modules/core/helpers/routeHelper'
|
||||
import { isResourceAllowed } from '@/modules/core/helpers/token'
|
||||
import { LimitedUserRecord } from '@/modules/core/helpers/types'
|
||||
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
|
||||
import { getUser } from '@/modules/core/repositories/users'
|
||||
import { ServerInviteResourceType } from '@/modules/serverinvites/domain/constants'
|
||||
import {
|
||||
FindInvite,
|
||||
QueryAllResourceInvites,
|
||||
@@ -82,7 +85,12 @@ export const createWorkspaceInviteFactory =
|
||||
role:
|
||||
(input.role ? mapGqlWorkspaceRoleToMainRole(input.role) : null) ||
|
||||
Roles.Workspace.Member,
|
||||
primary: true
|
||||
primary: true,
|
||||
secondaryResourceRoles: {
|
||||
...(input.serverRole
|
||||
? { [ServerInviteResourceType]: mapServerRoleToValue(input.serverRole) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
return await deps.createAndSendInvite(
|
||||
@@ -241,7 +249,8 @@ function buildPendingWorkspaceCollaboratorModel(
|
||||
title: resolveInviteTargetTitle(invite, targetUser),
|
||||
role: invite.resource.role || Roles.Workspace.Member,
|
||||
invitedById: invite.inviterId,
|
||||
user: targetUser
|
||||
user: targetUser,
|
||||
updatedAt: invite.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,8 +315,9 @@ export const getPendingWorkspaceCollaboratorsFactory =
|
||||
}) =>
|
||||
async (params: {
|
||||
workspaceId: string
|
||||
filter?: MaybeNullOrUndefined<PendingWorkspaceCollaboratorsFilter>
|
||||
}): Promise<PendingWorkspaceCollaboratorGraphQLReturn[]> => {
|
||||
const { workspaceId } = params
|
||||
const { workspaceId, filter } = params
|
||||
|
||||
// Get all pending invites
|
||||
const invites = await deps.queryAllResourceInvites<
|
||||
@@ -315,7 +325,8 @@ export const getPendingWorkspaceCollaboratorsFactory =
|
||||
WorkspaceRoles
|
||||
>({
|
||||
resourceId: workspaceId,
|
||||
resourceType: WorkspaceInviteResourceType
|
||||
resourceType: WorkspaceInviteResourceType,
|
||||
search: filter?.search || undefined
|
||||
})
|
||||
|
||||
// Get all target users, if any
|
||||
|
||||
@@ -7,7 +7,7 @@ import { expect } from 'chai'
|
||||
|
||||
describe('Event handlers', () => {
|
||||
describe('onProjectCreatedFactory creates a function, that', () => {
|
||||
it('grants project roles for all workspace members', async () => {
|
||||
it('grants project roles for all workspace members, except guests', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const projectId = cryptoRandomString({ length: 10 })
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Event handlers', () => {
|
||||
project: { workspaceId, id: projectId } as StreamRecord
|
||||
})
|
||||
|
||||
expect(projectRoles.length).to.equal(3)
|
||||
expect(projectRoles.length).to.equal(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ export type PendingWorkspaceCollaboratorGraphQLReturn = {
|
||||
role: WorkspaceRoles
|
||||
invitedById: string
|
||||
user: LimitedUserRecord | null
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export type WorkspaceCollaboratorGraphQLReturn = UserWithRole<LimitedUserRecord> & {
|
||||
|
||||
Reference in New Issue
Block a user