feat(server): improve team invite (#9092)

This commit is contained in:
DarkSky
2024-12-11 18:00:49 +08:00
committed by GitHub
parent 671c41cb1a
commit 0fb3293ecf
14 changed files with 146 additions and 46 deletions
@@ -1,3 +1,5 @@
import { pick } from 'lodash-es';
import { PrismaTransaction } from '../../fundamentals'; import { PrismaTransaction } from '../../fundamentals';
import { formatDate, formatSize, Quota, QuotaSchema } from './types'; import { formatDate, formatSize, Quota, QuotaSchema } from './types';
@@ -5,7 +7,7 @@ const QuotaCache = new Map<number, QuotaConfig>();
export class QuotaConfig { export class QuotaConfig {
readonly config: Quota; readonly config: Quota;
readonly override?: Quota['configs']; readonly override?: Partial<Quota['configs']>;
static async get(tx: PrismaTransaction, featureId: number) { static async get(tx: PrismaTransaction, featureId: number) {
const cachedQuota = QuotaCache.get(featureId); const cachedQuota = QuotaCache.get(featureId);
@@ -49,7 +51,10 @@ export class QuotaConfig {
configs: Object.assign({}, config.data.configs, override), configs: Object.assign({}, config.data.configs, override),
}); });
if (overrideConfig.success) { if (overrideConfig.success) {
this.override = overrideConfig.data.configs; this.override = pick(
overrideConfig.data.configs,
Object.keys(override)
);
} else { } else {
throw new Error( throw new Error(
`Invalid quota override config: ${override.error.message}, ${JSON.stringify( `Invalid quota override config: ${override.error.message}, ${JSON.stringify(
@@ -280,7 +280,7 @@ export class QuotaService {
.findFirst({ .findFirst({
where: { where: {
workspaceId, workspaceId,
feature: { feature: type, type: FeatureKind.Feature }, feature: { feature: type, type: FeatureKind.Quota },
activated: true, activated: true,
}, },
select: { configs: true }, select: { configs: true },
@@ -191,7 +191,7 @@ export class QuotaManagementService {
FeatureType.UnlimitedWorkspace FeatureType.UnlimitedWorkspace
); );
const quota = { const quota: QuotaBusinessType = {
name, name,
blobLimit, blobLimit,
businessBlobLimit, businessBlobLimit,
@@ -16,12 +16,14 @@ import {
NotInSpace, NotInSpace,
RequestMutex, RequestMutex,
TooManyRequest, TooManyRequest,
URLHelper,
} from '../../../fundamentals'; } from '../../../fundamentals';
import { CurrentUser } from '../../auth'; import { CurrentUser } from '../../auth';
import { Permission, PermissionService } from '../../permission'; import { Permission, PermissionService } from '../../permission';
import { QuotaManagementService } from '../../quota'; import { QuotaManagementService } from '../../quota';
import { UserService } from '../../user'; import { UserService } from '../../user';
import { import {
InviteLink,
InviteResult, InviteResult,
WorkspaceInviteLinkExpireTime, WorkspaceInviteLinkExpireTime,
WorkspaceType, WorkspaceType,
@@ -41,6 +43,7 @@ export class TeamWorkspaceResolver {
private readonly cache: Cache, private readonly cache: Cache,
private readonly event: EventEmitter, private readonly event: EventEmitter,
private readonly mailer: MailService, private readonly mailer: MailService,
private readonly url: URLHelper,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly users: UserService, private readonly users: UserService,
@@ -71,6 +74,10 @@ export class TeamWorkspaceResolver {
Permission.Admin Permission.Admin
); );
if (emails.length > 512) {
return new TooManyRequest();
}
// lock to prevent concurrent invite // lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`; const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.lock(lockFlag); await using lock = await this.mutex.lock(lockFlag);
@@ -150,8 +157,27 @@ export class TeamWorkspaceResolver {
return results; return results;
} }
@ResolveField(() => InviteLink, {
description: 'invite link for workspace',
nullable: true,
})
async inviteLink(@Parent() workspace: WorkspaceType) {
const cacheId = `workspace:inviteLink:${workspace.id}`;
const id = await this.cache.get<{ inviteId: string }>(cacheId);
if (id) {
const expireTime = await this.cache.ttl(cacheId);
if (Number.isSafeInteger(expireTime)) {
return {
link: this.url.link(`/invite/${id.inviteId}`),
expireTime: new Date(Date.now() + expireTime),
};
}
}
return null;
}
@Mutation(() => String) @Mutation(() => String)
async inviteLink( async createInviteLink(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime }) @Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
@@ -171,7 +197,11 @@ export class TeamWorkspaceResolver {
const inviteId = nanoid(); const inviteId = nanoid();
const cacheInviteId = `workspace:inviteLinkId:${inviteId}`; const cacheInviteId = `workspace:inviteLinkId:${inviteId}`;
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime }); await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
await this.cache.set(cacheInviteId, { workspaceId }, { ttl: expireTime }); await this.cache.set(
cacheInviteId,
{ workspaceId, inviteeUserId: user.id },
{ ttl: expireTime }
);
return inviteId; return inviteId;
} }
@@ -485,12 +485,15 @@ export class WorkspaceResolver {
}) })
async getInviteInfo(@Args('inviteId') inviteId: string) { async getInviteInfo(@Args('inviteId') inviteId: string) {
let workspaceId = null; let workspaceId = null;
let invitee = null;
// invite link // invite link
const invite = await this.cache.get<{ workspaceId: string }>( const invite = await this.cache.get<{
`workspace:inviteLinkId:${inviteId}` workspaceId: string;
); inviteeUserId: string;
}>(`workspace:inviteLinkId:${inviteId}`);
if (typeof invite?.workspaceId === 'string') { if (typeof invite?.workspaceId === 'string') {
workspaceId = invite.workspaceId; workspaceId = invite.workspaceId;
invitee = { user: await this.users.findUserById(invite.inviteeUserId) };
} }
if (!workspaceId) { if (!workspaceId) {
workspaceId = await this.prisma.workspaceUserPermission workspaceId = await this.prisma.workspaceUserPermission
@@ -508,10 +511,13 @@ export class WorkspaceResolver {
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId); const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
const owner = await this.permissions.getWorkspaceOwner(workspaceId); const owner = await this.permissions.getWorkspaceOwner(workspaceId);
const invitee = await this.permissions.getWorkspaceInvitation(
inviteId, if (!invitee) {
workspaceId invitee = await this.permissions.getWorkspaceInvitation(
); inviteId,
workspaceId
);
}
let avatar = ''; let avatar = '';
if (workspaceContent?.avatarKey) { if (workspaceContent?.avatarKey) {
@@ -115,6 +115,15 @@ export class UpdateWorkspaceInput extends PickType(
id!: string; id!: string;
} }
@ObjectType()
export class InviteLink {
@Field(() => String, { description: 'Invite link' })
link!: string;
@Field(() => Date, { description: 'Invite link expire time' })
expireTime!: Date;
}
@ObjectType() @ObjectType()
export class InviteResult { export class InviteResult {
@Field(() => String) @Field(() => String)
+12 -1
View File
@@ -361,6 +361,14 @@ type InvitationWorkspaceType {
name: String! name: String!
} }
type InviteLink {
"""Invite link expire time"""
expireTime: DateTime!
"""Invite link"""
link: String!
}
type InviteResult { type InviteResult {
email: String! email: String!
@@ -496,6 +504,7 @@ type Mutation {
"""Create a stripe customer portal to manage payment methods""" """Create a stripe customer portal to manage payment methods"""
createCustomerPortal: String! createCustomerPortal: String!
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): String!
"""Create a new user""" """Create a new user"""
createUser(input: CreateUserInput!): UserType! createUser(input: CreateUserInput!): UserType!
@@ -514,7 +523,6 @@ type Mutation {
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String! grantMember(permission: Permission!, userId: String!, workspaceId: String!): String!
invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String! invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String!
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]! inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
inviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): String!
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean! leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean!
publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage! publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage!
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime! recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
@@ -996,6 +1004,9 @@ type WorkspaceType {
"""is current workspace initialized""" """is current workspace initialized"""
initialized: Boolean! initialized: Boolean!
"""invite link for workspace"""
inviteLink: InviteLink
"""Get user invoice count""" """Get user invoice count"""
invoiceCount: Int! invoiceCount: Int!
invoices(skip: Int, take: Int = 8): [InvoiceType!]! invoices(skip: Int, take: Int = 8): [InvoiceType!]!
+18 -7
View File
@@ -17,6 +17,7 @@ import {
acceptInviteById, acceptInviteById,
createTestingApp, createTestingApp,
createWorkspace, createWorkspace,
getInviteInfo,
grantMember, grantMember,
inviteLink, inviteLink,
inviteUser, inviteUser,
@@ -95,11 +96,14 @@ const init = async (app: INestApplication, memberLimit = 10) => {
const createInviteLink = async () => { const createInviteLink = async () => {
const inviteId = await inviteLink(app, owner.token.token, ws.id, 'OneDay'); const inviteId = await inviteLink(app, owner.token.token, ws.id, 'OneDay');
return async (email: string): Promise<UserAuthedType> => { return [
const member = await signUp(app, email.split('@')[0], email, '123456'); inviteId,
await acceptInviteById(app, ws.id, inviteId, false, member.token.token); async (email: string): Promise<UserAuthedType> => {
return member; const member = await signUp(app, email.split('@')[0], email, '123456');
}; await acceptInviteById(app, ws.id, inviteId, false, member.token.token);
return member;
},
] as const;
}; };
const admin = await invite('admin@affine.pro', 'Admin'); const admin = await invite('admin@affine.pro', 'Admin');
@@ -237,8 +241,15 @@ test('should be able to leave workspace', async t => {
test('should be able to invite by link', async t => { test('should be able to invite by link', async t => {
const { app, permissions, quotaManager } = t.context; const { app, permissions, quotaManager } = t.context;
const { createInviteLink, ws } = await init(app, 4); const { createInviteLink, owner, ws } = await init(app, 4);
const invite = await createInviteLink(); const [inviteId, invite] = await createInviteLink();
{
// check invite link
const info = await getInviteInfo(app, owner.token.token, inviteId);
t.is(info.workspace.id, ws.id, 'should be able to get invite info');
}
{ {
// invite link // invite link
const members: UserAuthedType[] = []; const members: UserAuthedType[] = [];
@@ -78,7 +78,7 @@ export async function inviteLink(
.send({ .send({
query: ` query: `
mutation { mutation {
inviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime})
} }
`, `,
}) })
@@ -86,7 +86,7 @@ export async function inviteLink(
if (res.body.errors) { if (res.body.errors) {
throw new Error(res.body.errors[0].message); throw new Error(res.body.errors[0].message);
} }
return res.body.data.inviteLink; return res.body.data.createInviteLink;
} }
export async function acceptInviteById( export async function acceptInviteById(
@@ -187,5 +187,10 @@ export async function getInviteInfo(
`, `,
}) })
.expect(200); .expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message, {
cause: res.body.errors[0].cause,
});
}
return res.body.data.getInviteInfo; return res.body.data.getInviteInfo;
} }
@@ -2,11 +2,11 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import { import {
acceptInviteByInviteIdMutation, acceptInviteByInviteIdMutation,
approveWorkspaceTeamMemberMutation, approveWorkspaceTeamMemberMutation,
createInviteLinkMutation,
getWorkspaceInfoQuery, getWorkspaceInfoQuery,
grantWorkspaceTeamMemberMutation, grantWorkspaceTeamMemberMutation,
inviteByEmailMutation, inviteByEmailMutation,
inviteByEmailsMutation, inviteByEmailsMutation,
inviteLinkMutation,
leaveWorkspaceMutation, leaveWorkspaceMutation,
type Permission, type Permission,
revokeInviteLinkMutation, revokeInviteLinkMutation,
@@ -83,13 +83,13 @@ export class WorkspacePermissionStore extends Store {
throw new Error('No Server'); throw new Error('No Server');
} }
const inviteLink = await this.workspaceServerService.server.gql({ const inviteLink = await this.workspaceServerService.server.gql({
query: inviteLinkMutation, query: createInviteLinkMutation,
variables: { variables: {
workspaceId, workspaceId,
expireTime, expireTime,
}, },
}); });
return inviteLink.inviteLink; return inviteLink.createInviteLink;
} }
async revokeInviteLink(workspaceId: string, signal?: AbortSignal) { async revokeInviteLink(workspaceId: string, signal?: AbortSignal) {
+10 -6
View File
@@ -1249,6 +1249,10 @@ query getWorkspaceConfig($id: String!) {
workspace(id: $id) { workspace(id: $id) {
enableAi enableAi
enableUrlPreview enableUrlPreview
inviteLink {
link
expireTime
}
} }
}`, }`,
}; };
@@ -1431,14 +1435,14 @@ mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail
}`, }`,
}; };
export const inviteLinkMutation = { export const createInviteLinkMutation = {
id: 'inviteLinkMutation' as const, id: 'createInviteLinkMutation' as const,
operationName: 'inviteLink', operationName: 'createInviteLink',
definitionName: 'inviteLink', definitionName: 'createInviteLink',
containsFile: false, containsFile: false,
query: ` query: `
mutation inviteLink($workspaceId: String!, $expireTime: WorkspaceInviteLinkExpireTime!) { mutation createInviteLink($workspaceId: String!, $expireTime: WorkspaceInviteLinkExpireTime!) {
inviteLink(workspaceId: $workspaceId, expireTime: $expireTime) createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
}`, }`,
}; };
@@ -2,5 +2,9 @@ query getWorkspaceConfig($id: String!) {
workspace(id: $id) { workspace(id: $id) {
enableAi enableAi
enableUrlPreview enableUrlPreview
inviteLink {
link
expireTime
}
} }
} }
@@ -1,6 +1,6 @@
mutation inviteLink( mutation createInviteLink(
$workspaceId: String! $workspaceId: String!
$expireTime: WorkspaceInviteLinkExpireTime! $expireTime: WorkspaceInviteLinkExpireTime!
) { ) {
inviteLink(workspaceId: $workspaceId, expireTime: $expireTime) createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
} }
+27 -12
View File
@@ -438,6 +438,14 @@ export interface InvitationWorkspaceType {
name: Scalars['String']['output']; name: Scalars['String']['output'];
} }
export interface InviteLink {
__typename?: 'InviteLink';
/** Invite link expire time */
expireTime: Scalars['DateTime']['output'];
/** Invite link */
link: Scalars['String']['output'];
}
export interface InviteResult { export interface InviteResult {
__typename?: 'InviteResult'; __typename?: 'InviteResult';
email: Scalars['String']['output']; email: Scalars['String']['output'];
@@ -559,6 +567,7 @@ export interface Mutation {
createCopilotSession: Scalars['String']['output']; createCopilotSession: Scalars['String']['output'];
/** Create a stripe customer portal to manage payment methods */ /** Create a stripe customer portal to manage payment methods */
createCustomerPortal: Scalars['String']['output']; createCustomerPortal: Scalars['String']['output'];
createInviteLink: Scalars['String']['output'];
/** Create a new user */ /** Create a new user */
createUser: UserType; createUser: UserType;
/** Create a new workspace */ /** Create a new workspace */
@@ -573,7 +582,6 @@ export interface Mutation {
grantMember: Scalars['String']['output']; grantMember: Scalars['String']['output'];
invite: Scalars['String']['output']; invite: Scalars['String']['output'];
inviteBatch: Array<InviteResult>; inviteBatch: Array<InviteResult>;
inviteLink: Scalars['String']['output'];
leaveWorkspace: Scalars['Boolean']['output']; leaveWorkspace: Scalars['Boolean']['output'];
publishPage: WorkspacePage; publishPage: WorkspacePage;
recoverDoc: Scalars['DateTime']['output']; recoverDoc: Scalars['DateTime']['output'];
@@ -673,6 +681,11 @@ export interface MutationCreateCopilotSessionArgs {
options: CreateChatSessionInput; options: CreateChatSessionInput;
} }
export interface MutationCreateInviteLinkArgs {
expireTime: WorkspaceInviteLinkExpireTime;
workspaceId: Scalars['String']['input'];
}
export interface MutationCreateUserArgs { export interface MutationCreateUserArgs {
input: CreateUserInput; input: CreateUserInput;
} }
@@ -719,11 +732,6 @@ export interface MutationInviteBatchArgs {
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
} }
export interface MutationInviteLinkArgs {
expireTime: WorkspaceInviteLinkExpireTime;
workspaceId: Scalars['String']['input'];
}
export interface MutationLeaveWorkspaceArgs { export interface MutationLeaveWorkspaceArgs {
sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>; sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
@@ -1342,6 +1350,8 @@ export interface WorkspaceType {
id: Scalars['ID']['output']; id: Scalars['ID']['output'];
/** is current workspace initialized */ /** is current workspace initialized */
initialized: Scalars['Boolean']['output']; initialized: Scalars['Boolean']['output'];
/** invite link for workspace */
inviteLink: Maybe<InviteLink>;
/** Get user invoice count */ /** Get user invoice count */
invoiceCount: Scalars['Int']['output']; invoiceCount: Scalars['Int']['output'];
invoices: Array<InvoiceType>; invoices: Array<InvoiceType>;
@@ -2526,6 +2536,11 @@ export type GetWorkspaceConfigQuery = {
__typename?: 'WorkspaceType'; __typename?: 'WorkspaceType';
enableAi: boolean; enableAi: boolean;
enableUrlPreview: boolean; enableUrlPreview: boolean;
inviteLink: {
__typename?: 'InviteLink';
link: string;
expireTime: string;
} | null;
}; };
}; };
@@ -2670,14 +2685,14 @@ export type InviteBatchMutation = {
}>; }>;
}; };
export type InviteLinkMutationVariables = Exact<{ export type CreateInviteLinkMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
expireTime: WorkspaceInviteLinkExpireTime; expireTime: WorkspaceInviteLinkExpireTime;
}>; }>;
export type InviteLinkMutation = { export type CreateInviteLinkMutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
inviteLink: string; createInviteLink: string;
}; };
export type RevokeInviteLinkMutationVariables = Exact<{ export type RevokeInviteLinkMutationVariables = Exact<{
@@ -3228,9 +3243,9 @@ export type Mutations =
response: InviteBatchMutation; response: InviteBatchMutation;
} }
| { | {
name: 'inviteLinkMutation'; name: 'createInviteLinkMutation';
variables: InviteLinkMutationVariables; variables: CreateInviteLinkMutationVariables;
response: InviteLinkMutation; response: CreateInviteLinkMutation;
} }
| { | {
name: 'revokeInviteLinkMutation'; name: 'revokeInviteLinkMutation';