mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-30 04:53:14 +03:00
feat: add workspace level feature apis (#5503)
This commit is contained in:
parent
04ca554525
commit
f6ec786ef9
@ -124,7 +124,8 @@
|
|||||||
"node"
|
"node"
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"tests/**/feature.spec.ts"
|
"tests/**/*.spec.ts",
|
||||||
|
"tests/**/*.e2e.ts"
|
||||||
],
|
],
|
||||||
"require": [
|
"require": [
|
||||||
"./src/prelude.ts"
|
"./src/prelude.ts"
|
||||||
|
@ -1,13 +1,7 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Features } from '../../modules/features';
|
||||||
|
|
||||||
import {
|
|
||||||
CommonFeature,
|
|
||||||
FeatureKind,
|
|
||||||
Features,
|
|
||||||
FeatureType,
|
|
||||||
} from '../../modules/features';
|
|
||||||
import { Quotas } from '../../modules/quota/schema';
|
import { Quotas } from '../../modules/quota/schema';
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
|
import { migrateNewFeatureTable, upsertFeature } from './utils/user-features';
|
||||||
|
|
||||||
export class UserFeaturesInit1698652531198 {
|
export class UserFeaturesInit1698652531198 {
|
||||||
// do the migration
|
// do the migration
|
||||||
@ -28,95 +22,3 @@ export class UserFeaturesInit1698652531198 {
|
|||||||
// TODO: revert the migration
|
// TODO: revert the migration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// upgrade features from lower version to higher version
|
|
||||||
async function upsertFeature(
|
|
||||||
db: PrismaService,
|
|
||||||
feature: CommonFeature
|
|
||||||
): Promise<void> {
|
|
||||||
const hasEqualOrGreaterVersion =
|
|
||||||
(await db.features.count({
|
|
||||||
where: {
|
|
||||||
feature: feature.feature,
|
|
||||||
version: {
|
|
||||||
gte: feature.version,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})) > 0;
|
|
||||||
// will not update exists version
|
|
||||||
if (!hasEqualOrGreaterVersion) {
|
|
||||||
await db.features.create({
|
|
||||||
data: {
|
|
||||||
feature: feature.feature,
|
|
||||||
type: feature.type,
|
|
||||||
version: feature.version,
|
|
||||||
configs: feature.configs as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateNewFeatureTable(prisma: PrismaService) {
|
|
||||||
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
|
||||||
for (const oldUser of waitingList) {
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
email: oldUser.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (user) {
|
|
||||||
const hasEarlyAccess = await prisma.userFeatures.count({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
feature: {
|
|
||||||
feature: FeatureType.EarlyAccess,
|
|
||||||
},
|
|
||||||
activated: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (hasEarlyAccess === 0) {
|
|
||||||
await prisma.$transaction(async tx => {
|
|
||||||
const latestFlag = await tx.userFeatures.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
feature: {
|
|
||||||
feature: FeatureType.EarlyAccess,
|
|
||||||
type: FeatureKind.Feature,
|
|
||||||
},
|
|
||||||
activated: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (latestFlag) {
|
|
||||||
return latestFlag.id;
|
|
||||||
} else {
|
|
||||||
return tx.userFeatures
|
|
||||||
.create({
|
|
||||||
data: {
|
|
||||||
reason: 'Early access user',
|
|
||||||
activated: true,
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
feature: {
|
|
||||||
connect: {
|
|
||||||
feature_version: {
|
|
||||||
feature: FeatureType.EarlyAccess,
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
type: FeatureKind.Feature,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(r => r.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
import { Features } from '../../modules/features';
|
||||||
|
import { PrismaService } from '../../prisma';
|
||||||
|
import { upsertFeature } from './utils/user-features';
|
||||||
|
|
||||||
|
export class RefreshUserFeatures1704352562369 {
|
||||||
|
// do the migration
|
||||||
|
static async up(db: PrismaService) {
|
||||||
|
// add early access v2 & copilot feature
|
||||||
|
for (const feature of Features) {
|
||||||
|
await upsertFeature(db, feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// revert the migration
|
||||||
|
static async down(_db: PrismaService) {}
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CommonFeature,
|
||||||
|
FeatureKind,
|
||||||
|
FeatureType,
|
||||||
|
} from '../../../modules/features';
|
||||||
|
import { PrismaService } from '../../../prisma';
|
||||||
|
|
||||||
|
// upgrade features from lower version to higher version
|
||||||
|
export async function upsertFeature(
|
||||||
|
db: PrismaService,
|
||||||
|
feature: CommonFeature
|
||||||
|
): Promise<void> {
|
||||||
|
const hasEqualOrGreaterVersion =
|
||||||
|
(await db.features.count({
|
||||||
|
where: {
|
||||||
|
feature: feature.feature,
|
||||||
|
version: {
|
||||||
|
gte: feature.version,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})) > 0;
|
||||||
|
// will not update exists version
|
||||||
|
if (!hasEqualOrGreaterVersion) {
|
||||||
|
await db.features.create({
|
||||||
|
data: {
|
||||||
|
feature: feature.feature,
|
||||||
|
type: feature.type,
|
||||||
|
version: feature.version,
|
||||||
|
configs: feature.configs as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateNewFeatureTable(prisma: PrismaService) {
|
||||||
|
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
||||||
|
for (const oldUser of waitingList) {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: oldUser.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
const hasEarlyAccess = await prisma.userFeatures.count({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
feature: {
|
||||||
|
feature: FeatureType.EarlyAccess,
|
||||||
|
},
|
||||||
|
activated: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (hasEarlyAccess === 0) {
|
||||||
|
await prisma.$transaction(async tx => {
|
||||||
|
const latestFlag = await tx.userFeatures.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
feature: {
|
||||||
|
feature: FeatureType.EarlyAccess,
|
||||||
|
type: FeatureKind.Feature,
|
||||||
|
},
|
||||||
|
activated: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (latestFlag) {
|
||||||
|
return latestFlag.id;
|
||||||
|
} else {
|
||||||
|
return tx.userFeatures
|
||||||
|
.create({
|
||||||
|
data: {
|
||||||
|
reason: 'Early access user',
|
||||||
|
activated: true,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
feature: {
|
||||||
|
connect: {
|
||||||
|
feature_version: {
|
||||||
|
feature: FeatureType.EarlyAccess,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
type: FeatureKind.Feature,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(r => r.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -40,15 +40,6 @@ export class EarlyAccessFeatureConfig extends FeatureConfig {
|
|||||||
throw new Error('Invalid feature config: type is not EarlyAccess');
|
throw new Error('Invalid feature config: type is not EarlyAccess');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkWhiteList(email: string) {
|
|
||||||
for (const domain in this.config.configs.whitelist) {
|
|
||||||
if (email.endsWith(domain)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeatureConfigMap = {
|
const FeatureConfigMap = {
|
||||||
|
@ -1,35 +1,32 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config } from '../../config';
|
import { Config } from '../../config';
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
import { EarlyAccessFeatureConfig } from './feature';
|
|
||||||
import { FeatureService } from './service';
|
import { FeatureService } from './service';
|
||||||
import { FeatureType } from './types';
|
import { FeatureType } from './types';
|
||||||
|
|
||||||
enum NewFeaturesKind {
|
const STAFF = ['@toeverything.info'];
|
||||||
EarlyAccess,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeatureManagementService implements OnModuleInit {
|
export class FeatureManagementService {
|
||||||
protected logger = new Logger(FeatureManagementService.name);
|
protected logger = new Logger(FeatureManagementService.name);
|
||||||
private earlyAccessFeature?: EarlyAccessFeatureConfig;
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly feature: FeatureService,
|
private readonly feature: FeatureService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly config: Config
|
private readonly config: Config
|
||||||
) {}
|
) {}
|
||||||
async onModuleInit() {
|
|
||||||
this.earlyAccessFeature = await this.feature.getFeature(
|
|
||||||
FeatureType.EarlyAccess
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======== Admin ========
|
// ======== Admin ========
|
||||||
|
|
||||||
// todo(@darkskygit): replace this with abac
|
// todo(@darkskygit): replace this with abac
|
||||||
isStaff(email: string) {
|
isStaff(email: string) {
|
||||||
return this.earlyAccessFeature?.checkWhiteList(email) ?? false;
|
for (const domain of STAFF) {
|
||||||
|
if (email.endsWith(domain)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======== Early Access ========
|
// ======== Early Access ========
|
||||||
@ -38,7 +35,7 @@ export class FeatureManagementService implements OnModuleInit {
|
|||||||
return this.feature.addUserFeature(
|
return this.feature.addUserFeature(
|
||||||
userId,
|
userId,
|
||||||
FeatureType.EarlyAccess,
|
FeatureType.EarlyAccess,
|
||||||
1,
|
2,
|
||||||
'Early access user'
|
'Early access user'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -63,23 +60,8 @@ export class FeatureManagementService implements OnModuleInit {
|
|||||||
const canEarlyAccess = await this.feature
|
const canEarlyAccess = await this.feature
|
||||||
.hasUserFeature(user.id, FeatureType.EarlyAccess)
|
.hasUserFeature(user.id, FeatureType.EarlyAccess)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
if (canEarlyAccess) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Outdated, switch to feature gates
|
return canEarlyAccess;
|
||||||
const oldCanEarlyAccess = await this.prisma.newFeaturesWaitingList
|
|
||||||
.findUnique({
|
|
||||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
|
||||||
})
|
|
||||||
.then(x => !!x)
|
|
||||||
.catch(() => false);
|
|
||||||
if (oldCanEarlyAccess) {
|
|
||||||
this.logger.warn(
|
|
||||||
`User ${email} has early access in old table but not in new table`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return oldCanEarlyAccess;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
|
@ -292,9 +292,7 @@ export class FeatureService {
|
|||||||
return configs.filter(feature => !!feature.feature);
|
return configs.filter(feature => !!feature.feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatureWorkspaces(
|
async listFeatureWorkspaces(feature: FeatureType): Promise<WorkspaceType[]> {
|
||||||
feature: FeatureType
|
|
||||||
): Promise<Omit<WorkspaceType, 'members'>[]> {
|
|
||||||
return this.prisma.workspaceFeatures
|
return this.prisma.workspaceFeatures
|
||||||
.findMany({
|
.findMany({
|
||||||
where: {
|
where: {
|
||||||
@ -314,7 +312,7 @@ export class FeatureService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(wss => wss.map(ws => ws.workspace));
|
.then(wss => wss.map(ws => ws.workspace as WorkspaceType));
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
|
import { registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
export enum FeatureType {
|
export enum FeatureType {
|
||||||
Copilot = 'copilot',
|
Copilot = 'copilot',
|
||||||
EarlyAccess = 'early_access',
|
EarlyAccess = 'early_access',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerEnumType(FeatureType, {
|
||||||
|
name: 'FeatureType',
|
||||||
|
description: 'The type of workspace feature',
|
||||||
|
});
|
||||||
|
@ -1,24 +1,8 @@
|
|||||||
import { URL } from 'node:url';
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { FeatureType } from './common';
|
import { FeatureType } from './common';
|
||||||
|
|
||||||
function checkHostname(host: string) {
|
|
||||||
try {
|
|
||||||
return new URL(`https://${host}`).hostname === host;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const featureEarlyAccess = z.object({
|
export const featureEarlyAccess = z.object({
|
||||||
feature: z.literal(FeatureType.EarlyAccess),
|
feature: z.literal(FeatureType.EarlyAccess),
|
||||||
configs: z.object({
|
configs: z.object({}),
|
||||||
whitelist: z
|
|
||||||
.string()
|
|
||||||
.startsWith('@')
|
|
||||||
.refine(domain => checkHostname(domain.slice(1)))
|
|
||||||
.array(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
@ -37,6 +37,12 @@ export const Features: Feature[] = [
|
|||||||
whitelist: ['@toeverything.info'],
|
whitelist: ['@toeverything.info'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
feature: FeatureType.EarlyAccess,
|
||||||
|
type: FeatureKind.Feature,
|
||||||
|
version: 2,
|
||||||
|
configs: {},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// ======== schema infer ========
|
/// ======== schema infer ========
|
||||||
|
@ -4,12 +4,13 @@ import { FeatureModule } from '../features';
|
|||||||
import { QuotaModule } from '../quota';
|
import { QuotaModule } from '../quota';
|
||||||
import { StorageModule } from '../storage';
|
import { StorageModule } from '../storage';
|
||||||
import { UserAvatarController } from './controller';
|
import { UserAvatarController } from './controller';
|
||||||
|
import { UserManagementResolver } from './management';
|
||||||
import { UserResolver } from './resolver';
|
import { UserResolver } from './resolver';
|
||||||
import { UsersService } from './users';
|
import { UsersService } from './users';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StorageModule, FeatureModule, QuotaModule],
|
imports: [StorageModule, FeatureModule, QuotaModule],
|
||||||
providers: [UserResolver, UsersService],
|
providers: [UserResolver, UserManagementResolver, UsersService],
|
||||||
controllers: [UserAvatarController],
|
controllers: [UserAvatarController],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
|
91
packages/backend/server/src/modules/users/management.ts
Normal file
91
packages/backend/server/src/modules/users/management.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
||||||
|
import { Auth, CurrentUser } from '../auth/guard';
|
||||||
|
import { AuthService } from '../auth/service';
|
||||||
|
import { FeatureManagementService } from '../features';
|
||||||
|
import { UserType } from './types';
|
||||||
|
import { UsersService } from './users';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User resolver
|
||||||
|
* All op rate limit: 10 req/m
|
||||||
|
*/
|
||||||
|
@UseGuards(CloudThrottlerGuard)
|
||||||
|
@Auth()
|
||||||
|
@Resolver(() => UserType)
|
||||||
|
export class UserManagementResolver {
|
||||||
|
constructor(
|
||||||
|
private readonly auth: AuthService,
|
||||||
|
private readonly users: UsersService,
|
||||||
|
private readonly feature: FeatureManagementService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 10,
|
||||||
|
ttl: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Mutation(() => Int)
|
||||||
|
async addToEarlyAccess(
|
||||||
|
@CurrentUser() currentUser: UserType,
|
||||||
|
@Args('email') email: string
|
||||||
|
): Promise<number> {
|
||||||
|
if (!this.feature.isStaff(currentUser.email)) {
|
||||||
|
throw new ForbiddenException('You are not allowed to do this');
|
||||||
|
}
|
||||||
|
const user = await this.users.findUserByEmail(email);
|
||||||
|
if (user) {
|
||||||
|
return this.feature.addEarlyAccess(user.id);
|
||||||
|
} else {
|
||||||
|
const user = await this.auth.createAnonymousUser(email);
|
||||||
|
return this.feature.addEarlyAccess(user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 10,
|
||||||
|
ttl: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Mutation(() => Int)
|
||||||
|
async removeEarlyAccess(
|
||||||
|
@CurrentUser() currentUser: UserType,
|
||||||
|
@Args('email') email: string
|
||||||
|
): Promise<number> {
|
||||||
|
if (!this.feature.isStaff(currentUser.email)) {
|
||||||
|
throw new ForbiddenException('You are not allowed to do this');
|
||||||
|
}
|
||||||
|
const user = await this.users.findUserByEmail(email);
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException(`User ${email} not found`);
|
||||||
|
}
|
||||||
|
return this.feature.removeEarlyAccess(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 10,
|
||||||
|
ttl: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Query(() => [UserType])
|
||||||
|
async earlyAccessUsers(
|
||||||
|
@Context() ctx: { isAdminQuery: boolean },
|
||||||
|
@CurrentUser() user: UserType
|
||||||
|
): Promise<UserType[]> {
|
||||||
|
if (!this.feature.isStaff(user.email)) {
|
||||||
|
throw new ForbiddenException('You are not allowed to do this');
|
||||||
|
}
|
||||||
|
// allow query other user's subscription
|
||||||
|
ctx.isAdminQuery = true;
|
||||||
|
return this.feature.listEarlyAccess();
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,6 @@
|
|||||||
import {
|
import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
ForbiddenException,
|
|
||||||
HttpStatus,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Context,
|
|
||||||
Int,
|
Int,
|
||||||
Mutation,
|
Mutation,
|
||||||
Query,
|
Query,
|
||||||
@ -22,7 +16,6 @@ import { PrismaService } from '../../prisma/service';
|
|||||||
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
||||||
import type { FileUpload } from '../../types';
|
import type { FileUpload } from '../../types';
|
||||||
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
|
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
|
||||||
import { AuthService } from '../auth/service';
|
|
||||||
import { FeatureManagementService } from '../features';
|
import { FeatureManagementService } from '../features';
|
||||||
import { QuotaService } from '../quota';
|
import { QuotaService } from '../quota';
|
||||||
import { AvatarStorage } from '../storage';
|
import { AvatarStorage } from '../storage';
|
||||||
@ -38,7 +31,6 @@ import { UsersService } from './users';
|
|||||||
@Resolver(() => UserType)
|
@Resolver(() => UserType)
|
||||||
export class UserResolver {
|
export class UserResolver {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly auth: AuthService,
|
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly storage: AvatarStorage,
|
private readonly storage: AvatarStorage,
|
||||||
private readonly users: UsersService,
|
private readonly users: UsersService,
|
||||||
@ -199,67 +191,4 @@ export class UserResolver {
|
|||||||
this.event.emit('user.deleted', deletedUser);
|
this.event.emit('user.deleted', deletedUser);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle({
|
|
||||||
default: {
|
|
||||||
limit: 10,
|
|
||||||
ttl: 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@Mutation(() => Int)
|
|
||||||
async addToEarlyAccess(
|
|
||||||
@CurrentUser() currentUser: UserType,
|
|
||||||
@Args('email') email: string
|
|
||||||
): Promise<number> {
|
|
||||||
if (!this.feature.isStaff(currentUser.email)) {
|
|
||||||
throw new ForbiddenException('You are not allowed to do this');
|
|
||||||
}
|
|
||||||
const user = await this.users.findUserByEmail(email);
|
|
||||||
if (user) {
|
|
||||||
return this.feature.addEarlyAccess(user.id);
|
|
||||||
} else {
|
|
||||||
const user = await this.auth.createAnonymousUser(email);
|
|
||||||
return this.feature.addEarlyAccess(user.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throttle({
|
|
||||||
default: {
|
|
||||||
limit: 10,
|
|
||||||
ttl: 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@Mutation(() => Int)
|
|
||||||
async removeEarlyAccess(
|
|
||||||
@CurrentUser() currentUser: UserType,
|
|
||||||
@Args('email') email: string
|
|
||||||
): Promise<number> {
|
|
||||||
if (!this.feature.isStaff(currentUser.email)) {
|
|
||||||
throw new ForbiddenException('You are not allowed to do this');
|
|
||||||
}
|
|
||||||
const user = await this.users.findUserByEmail(email);
|
|
||||||
if (!user) {
|
|
||||||
throw new BadRequestException(`User ${email} not found`);
|
|
||||||
}
|
|
||||||
return this.feature.removeEarlyAccess(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throttle({
|
|
||||||
default: {
|
|
||||||
limit: 10,
|
|
||||||
ttl: 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@Query(() => [UserType])
|
|
||||||
async earlyAccessUsers(
|
|
||||||
@Context() ctx: { isAdminQuery: boolean },
|
|
||||||
@CurrentUser() user: UserType
|
|
||||||
): Promise<UserType[]> {
|
|
||||||
if (!this.feature.isStaff(user.email)) {
|
|
||||||
throw new ForbiddenException('You are not allowed to do this');
|
|
||||||
}
|
|
||||||
// allow query other user's subscription
|
|
||||||
ctx.isAdminQuery = true;
|
|
||||||
return this.feature.listEarlyAccess();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { DocModule } from '../doc';
|
import { DocModule } from '../doc';
|
||||||
|
import { FeatureModule } from '../features';
|
||||||
import { QuotaModule } from '../quota';
|
import { QuotaModule } from '../quota';
|
||||||
import { StorageModule } from '../storage';
|
import { StorageModule } from '../storage';
|
||||||
import { UsersService } from '../users';
|
import { UsersService } from '../users';
|
||||||
import { WorkspacesController } from './controller';
|
import { WorkspacesController } from './controller';
|
||||||
|
import { WorkspaceManagementResolver } from './management';
|
||||||
import { PermissionService } from './permission';
|
import { PermissionService } from './permission';
|
||||||
import {
|
import {
|
||||||
DocHistoryResolver,
|
DocHistoryResolver,
|
||||||
@ -14,10 +16,11 @@ import {
|
|||||||
} from './resolvers';
|
} from './resolvers';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DocModule, QuotaModule, StorageModule],
|
imports: [DocModule, FeatureModule, QuotaModule, StorageModule],
|
||||||
controllers: [WorkspacesController],
|
controllers: [WorkspacesController],
|
||||||
providers: [
|
providers: [
|
||||||
WorkspaceResolver,
|
WorkspaceResolver,
|
||||||
|
WorkspaceManagementResolver,
|
||||||
PermissionService,
|
PermissionService,
|
||||||
UsersService,
|
UsersService,
|
||||||
PagePermissionResolver,
|
PagePermissionResolver,
|
||||||
|
87
packages/backend/server/src/modules/workspaces/management.ts
Normal file
87
packages/backend/server/src/modules/workspaces/management.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { ForbiddenException, UseGuards } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
Args,
|
||||||
|
Int,
|
||||||
|
Mutation,
|
||||||
|
Parent,
|
||||||
|
Query,
|
||||||
|
ResolveField,
|
||||||
|
Resolver,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
||||||
|
import { Auth, CurrentUser } from '../auth';
|
||||||
|
import { FeatureManagementService, FeatureType } from '../features';
|
||||||
|
import { UserType } from '../users';
|
||||||
|
import { WorkspaceType } from './types';
|
||||||
|
|
||||||
|
@UseGuards(CloudThrottlerGuard)
|
||||||
|
@Auth()
|
||||||
|
@Resolver(() => WorkspaceType)
|
||||||
|
export class WorkspaceManagementResolver {
|
||||||
|
constructor(private readonly feature: FeatureManagementService) {}
|
||||||
|
|
||||||
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 10,
|
||||||
|
ttl: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Mutation(() => Int)
|
||||||
|
async addWorkspaceFeature(
|
||||||
|
@CurrentUser() currentUser: UserType,
|
||||||
|
@Args('workspaceId') workspaceId: string,
|
||||||
|
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||||
|
): Promise<number> {
|
||||||
|
if (!this.feature.isStaff(currentUser.email)) {
|
||||||
|
throw new ForbiddenException('You are not allowed to do this');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.feature.addWorkspaceFeatures(workspaceId, feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 10,
|
||||||
|
ttl: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Mutation(() => Int)
|
||||||
|
async removeWorkspaceFeature(
|
||||||
|
@CurrentUser() currentUser: UserType,
|
||||||
|
@Args('workspaceId') workspaceId: string,
|
||||||
|
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.feature.isStaff(currentUser.email)) {
|
||||||
|
throw new ForbiddenException('You are not allowed to do this');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.feature.removeWorkspaceFeature(workspaceId, feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 10,
|
||||||
|
ttl: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Query(() => [WorkspaceType])
|
||||||
|
async listWorkspaceFeatures(
|
||||||
|
@CurrentUser() user: UserType,
|
||||||
|
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||||
|
): Promise<WorkspaceType[]> {
|
||||||
|
if (!this.feature.isStaff(user.email)) {
|
||||||
|
throw new ForbiddenException('You are not allowed to do this');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.feature.listFeatureWorkspaces(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => [FeatureType], {
|
||||||
|
description: 'Enabled features of workspace',
|
||||||
|
complexity: 2,
|
||||||
|
})
|
||||||
|
async features(@Parent() workspace: WorkspaceType): Promise<FeatureType[]> {
|
||||||
|
return this.feature.getWorkspaceFeatures(workspace.id);
|
||||||
|
}
|
||||||
|
}
|
@ -47,7 +47,7 @@ import { defaultWorkspaceAvatar } from '../utils';
|
|||||||
@Auth()
|
@Auth()
|
||||||
@Resolver(() => WorkspaceType)
|
@Resolver(() => WorkspaceType)
|
||||||
export class WorkspaceResolver {
|
export class WorkspaceResolver {
|
||||||
private readonly logger = new Logger('WorkspaceResolver');
|
private readonly logger = new Logger(WorkspaceResolver.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly auth: AuthService,
|
private readonly auth: AuthService,
|
||||||
|
@ -131,6 +131,9 @@ type WorkspaceType {
|
|||||||
"""Owner of workspace"""
|
"""Owner of workspace"""
|
||||||
owner: UserType!
|
owner: UserType!
|
||||||
|
|
||||||
|
"""Enabled features of workspace"""
|
||||||
|
features: [FeatureType!]!
|
||||||
|
|
||||||
"""Shared pages of workspace"""
|
"""Shared pages of workspace"""
|
||||||
sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages")
|
sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages")
|
||||||
|
|
||||||
@ -142,6 +145,12 @@ type WorkspaceType {
|
|||||||
blobsSize: Int!
|
blobsSize: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""The type of workspace feature"""
|
||||||
|
enum FeatureType {
|
||||||
|
Copilot
|
||||||
|
EarlyAccess
|
||||||
|
}
|
||||||
|
|
||||||
type InvitationWorkspaceType {
|
type InvitationWorkspaceType {
|
||||||
id: ID!
|
id: ID!
|
||||||
|
|
||||||
@ -279,6 +288,7 @@ type Query {
|
|||||||
|
|
||||||
"""Update workspace"""
|
"""Update workspace"""
|
||||||
getInviteInfo(inviteId: String!): InvitationType!
|
getInviteInfo(inviteId: String!): InvitationType!
|
||||||
|
listWorkspaceFeatures(feature: FeatureType!): [WorkspaceType!]!
|
||||||
|
|
||||||
"""List blobs of workspace"""
|
"""List blobs of workspace"""
|
||||||
listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead")
|
listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead")
|
||||||
@ -314,6 +324,8 @@ type Mutation {
|
|||||||
revoke(workspaceId: String!, userId: String!): Boolean!
|
revoke(workspaceId: String!, userId: String!): Boolean!
|
||||||
acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean!
|
acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean!
|
||||||
leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): Boolean!
|
leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): Boolean!
|
||||||
|
addWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int!
|
||||||
|
removeWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int!
|
||||||
sharePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "renamed to publicPage")
|
sharePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "renamed to publicPage")
|
||||||
publishPage(workspaceId: String!, pageId: String!, mode: PublicPageMode = Page): WorkspacePage!
|
publishPage(workspaceId: String!, pageId: String!, mode: PublicPageMode = Page): WorkspacePage!
|
||||||
revokePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
|
revokePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
|
||||||
|
@ -46,13 +46,6 @@ class FakePrisma {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
get newFeaturesWaitingList() {
|
|
||||||
return {
|
|
||||||
async findUnique() {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.beforeEach(async t => {
|
test.beforeEach(async t => {
|
||||||
|
@ -114,7 +114,7 @@ test('should be able to set user feature', async t => {
|
|||||||
const f1 = await feature.getUserFeatures(u1.id);
|
const f1 = await feature.getUserFeatures(u1.id);
|
||||||
t.is(f1.length, 0, 'should be empty');
|
t.is(f1.length, 0, 'should be empty');
|
||||||
|
|
||||||
await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 1, 'test');
|
await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 2, 'test');
|
||||||
|
|
||||||
const f2 = await feature.getUserFeatures(u1.id);
|
const f2 = await feature.getUserFeatures(u1.id);
|
||||||
t.is(f2.length, 1, 'should have 1 feature');
|
t.is(f2.length, 1, 'should have 1 feature');
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
query getWorkspaceFeatures($workspaceId: String!) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
features
|
||||||
|
}
|
||||||
|
}
|
@ -383,6 +383,19 @@ query getWorkspacePublicPages($workspaceId: String!) {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getWorkspaceFeaturesQuery = {
|
||||||
|
id: 'getWorkspaceFeaturesQuery' as const,
|
||||||
|
operationName: 'getWorkspaceFeatures',
|
||||||
|
definitionName: 'workspace',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
query getWorkspaceFeatures($workspaceId: String!) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
features
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const getWorkspaceQuery = {
|
export const getWorkspaceQuery = {
|
||||||
id: 'getWorkspaceQuery' as const,
|
id: 'getWorkspaceQuery' as const,
|
||||||
operationName: 'getWorkspace',
|
operationName: 'getWorkspace',
|
||||||
@ -760,6 +773,48 @@ mutation uploadAvatar($avatar: Upload!) {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const addWorkspaceFeatureMutation = {
|
||||||
|
id: 'addWorkspaceFeatureMutation' as const,
|
||||||
|
operationName: 'addWorkspaceFeature',
|
||||||
|
definitionName: 'addWorkspaceFeature',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation addWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) {
|
||||||
|
addWorkspaceFeature(workspaceId: $workspaceId, feature: $feature)
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listWorkspaceFeaturesQuery = {
|
||||||
|
id: 'listWorkspaceFeaturesQuery' as const,
|
||||||
|
operationName: 'listWorkspaceFeatures',
|
||||||
|
definitionName: 'listWorkspaceFeatures',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
query listWorkspaceFeatures($feature: FeatureType!) {
|
||||||
|
listWorkspaceFeatures(feature: $feature) {
|
||||||
|
id
|
||||||
|
public
|
||||||
|
createdAt
|
||||||
|
memberCount
|
||||||
|
owner {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
features
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeWorkspaceFeatureMutation = {
|
||||||
|
id: 'removeWorkspaceFeatureMutation' as const,
|
||||||
|
operationName: 'removeWorkspaceFeature',
|
||||||
|
definitionName: 'removeWorkspaceFeature',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation removeWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) {
|
||||||
|
removeWorkspaceFeature(workspaceId: $workspaceId, feature: $feature)
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const inviteByEmailMutation = {
|
export const inviteByEmailMutation = {
|
||||||
id: 'inviteByEmailMutation' as const,
|
id: 'inviteByEmailMutation' as const,
|
||||||
operationName: 'inviteByEmail',
|
operationName: 'inviteByEmail',
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
mutation addWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) {
|
||||||
|
addWorkspaceFeature(workspaceId: $workspaceId, feature: $feature)
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
query listWorkspaceFeatures($feature: FeatureType!) {
|
||||||
|
listWorkspaceFeatures(feature: $feature) {
|
||||||
|
id
|
||||||
|
public
|
||||||
|
createdAt
|
||||||
|
memberCount
|
||||||
|
owner {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
features
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
mutation removeWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) {
|
||||||
|
removeWorkspaceFeature(workspaceId: $workspaceId, feature: $feature)
|
||||||
|
}
|
@ -32,6 +32,12 @@ export interface Scalars {
|
|||||||
Upload: { input: File; output: File };
|
Upload: { input: File; output: File };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The type of workspace feature */
|
||||||
|
export enum FeatureType {
|
||||||
|
Copilot = 'Copilot',
|
||||||
|
EarlyAccess = 'EarlyAccess',
|
||||||
|
}
|
||||||
|
|
||||||
export enum InvoiceStatus {
|
export enum InvoiceStatus {
|
||||||
Draft = 'Draft',
|
Draft = 'Draft',
|
||||||
Open = 'Open',
|
Open = 'Open',
|
||||||
@ -392,6 +398,15 @@ export type GetWorkspacePublicPagesQuery = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetWorkspaceFeaturesQueryVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type GetWorkspaceFeaturesQuery = {
|
||||||
|
__typename?: 'Query';
|
||||||
|
workspace: { __typename?: 'WorkspaceType'; features: Array<FeatureType> };
|
||||||
|
};
|
||||||
|
|
||||||
export type GetWorkspaceQueryVariables = Exact<{
|
export type GetWorkspaceQueryVariables = Exact<{
|
||||||
id: Scalars['String']['input'];
|
id: Scalars['String']['input'];
|
||||||
}>;
|
}>;
|
||||||
@ -724,6 +739,43 @@ export type UploadAvatarMutation = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AddWorkspaceFeatureMutationVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
feature: FeatureType;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AddWorkspaceFeatureMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
addWorkspaceFeature: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListWorkspaceFeaturesQueryVariables = Exact<{
|
||||||
|
feature: FeatureType;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ListWorkspaceFeaturesQuery = {
|
||||||
|
__typename?: 'Query';
|
||||||
|
listWorkspaceFeatures: Array<{
|
||||||
|
__typename?: 'WorkspaceType';
|
||||||
|
id: string;
|
||||||
|
public: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
memberCount: number;
|
||||||
|
features: Array<FeatureType>;
|
||||||
|
owner: { __typename?: 'UserType'; id: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoveWorkspaceFeatureMutationVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
feature: FeatureType;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RemoveWorkspaceFeatureMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
removeWorkspaceFeature: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type InviteByEmailMutationVariables = Exact<{
|
export type InviteByEmailMutationVariables = Exact<{
|
||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
email: Scalars['String']['input'];
|
email: Scalars['String']['input'];
|
||||||
@ -815,6 +867,11 @@ export type Queries =
|
|||||||
variables: GetWorkspacePublicPagesQueryVariables;
|
variables: GetWorkspacePublicPagesQueryVariables;
|
||||||
response: GetWorkspacePublicPagesQuery;
|
response: GetWorkspacePublicPagesQuery;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'getWorkspaceFeaturesQuery';
|
||||||
|
variables: GetWorkspaceFeaturesQueryVariables;
|
||||||
|
response: GetWorkspaceFeaturesQuery;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'getWorkspaceQuery';
|
name: 'getWorkspaceQuery';
|
||||||
variables: GetWorkspaceQueryVariables;
|
variables: GetWorkspaceQueryVariables;
|
||||||
@ -859,6 +916,11 @@ export type Queries =
|
|||||||
name: 'subscriptionQuery';
|
name: 'subscriptionQuery';
|
||||||
variables: SubscriptionQueryVariables;
|
variables: SubscriptionQueryVariables;
|
||||||
response: SubscriptionQuery;
|
response: SubscriptionQuery;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'listWorkspaceFeaturesQuery';
|
||||||
|
variables: ListWorkspaceFeaturesQueryVariables;
|
||||||
|
response: ListWorkspaceFeaturesQuery;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Mutations =
|
export type Mutations =
|
||||||
@ -1002,6 +1064,16 @@ export type Mutations =
|
|||||||
variables: UploadAvatarMutationVariables;
|
variables: UploadAvatarMutationVariables;
|
||||||
response: UploadAvatarMutation;
|
response: UploadAvatarMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'addWorkspaceFeatureMutation';
|
||||||
|
variables: AddWorkspaceFeatureMutationVariables;
|
||||||
|
response: AddWorkspaceFeatureMutation;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'removeWorkspaceFeatureMutation';
|
||||||
|
variables: RemoveWorkspaceFeatureMutationVariables;
|
||||||
|
response: RemoveWorkspaceFeatureMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'inviteByEmailMutation';
|
name: 'inviteByEmailMutation';
|
||||||
variables: InviteByEmailMutationVariables;
|
variables: InviteByEmailMutationVariables;
|
||||||
|
Loading…
Reference in New Issue
Block a user