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:
Kristaps Fabians Geikins
2024-08-12 11:30:01 +03:00
committed by GitHub
parent 03db1cca94
commit 4dae1569cd
69 changed files with 1903 additions and 1327 deletions
+1
View File
@@ -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 })
@@ -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> & {