feat: improve admin panel (#14180)
This commit is contained in:
@@ -15,10 +15,10 @@ import {
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
Cache,
|
||||
Config,
|
||||
CryptoHelper,
|
||||
EarlyAccessRequired,
|
||||
EmailTokenNotFound,
|
||||
InvalidAuthState,
|
||||
InvalidEmail,
|
||||
@@ -120,7 +120,7 @@ export class AuthController {
|
||||
validators.assertValidEmail(credential.email);
|
||||
const canSignIn = await this.auth.canSignIn(credential.email);
|
||||
if (!canSignIn) {
|
||||
throw new EarlyAccessRequired();
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
if (credential.password) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { assign, pick } from 'lodash-es';
|
||||
|
||||
import { Config, SignUpForbidden } from '../../base';
|
||||
import { Models, type User, type UserSession } from '../../models';
|
||||
import { FeatureService } from '../features';
|
||||
import { Mailer } from '../mail/mailer';
|
||||
import { createDevUsers } from './dev';
|
||||
import type { CurrentUser } from './session';
|
||||
@@ -44,8 +43,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly models: Models,
|
||||
private readonly mailer: Mailer,
|
||||
private readonly feature: FeatureService
|
||||
private readonly mailer: Mailer
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -54,8 +52,9 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
async canSignIn(email: string) {
|
||||
return await this.feature.canEarlyAccess(email);
|
||||
async canSignIn(_email: string) {
|
||||
// may add more sign-in check later
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import { z } from 'zod';
|
||||
import { defineModuleConfig } from '../../base';
|
||||
|
||||
export interface ServerFlags {
|
||||
earlyAccessControl: boolean;
|
||||
allowGuestDemoWorkspace: boolean;
|
||||
}
|
||||
|
||||
@@ -72,10 +71,6 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi
|
||||
});
|
||||
|
||||
defineModuleConfig('flags', {
|
||||
earlyAccessControl: {
|
||||
desc: 'Only allow users with early access features to access the app',
|
||||
default: false,
|
||||
},
|
||||
allowGuestDemoWorkspace: {
|
||||
desc: 'Whether allow guest users to create demo workspaces.',
|
||||
default: true,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { Config, URLHelper } from '../../base';
|
||||
import { Namespace } from '../../env';
|
||||
import { Feature } from '../../models';
|
||||
import { Feature, type WorkspaceFeatureName } from '../../models';
|
||||
import { CurrentUser, Public } from '../auth';
|
||||
import { Admin } from '../common';
|
||||
import { AvailableUserFeatureConfig } from '../features';
|
||||
@@ -75,7 +75,7 @@ export class ServerConfigResolver {
|
||||
name:
|
||||
this.config.server.name ??
|
||||
(env.selfhosted
|
||||
? 'AFFiNE Selfhosted Cloud'
|
||||
? 'AFFiNE SelfHosted Cloud'
|
||||
: env.namespaces.canary
|
||||
? 'AFFiNE Canary Cloud'
|
||||
: env.namespaces.beta
|
||||
@@ -85,8 +85,6 @@ export class ServerConfigResolver {
|
||||
baseUrl: this.url.requestBaseUrl,
|
||||
type: env.DEPLOYMENT_TYPE,
|
||||
features: this.server.features,
|
||||
// TODO(@fengmk2): remove this field after the feature 0.25.0 is released
|
||||
allowGuestDemoWorkspace: this.config.flags.allowGuestDemoWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +168,13 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
|
||||
override availableUserFeatures() {
|
||||
return super.availableUserFeatures();
|
||||
}
|
||||
|
||||
@ResolveField(() => [Feature], {
|
||||
description: 'Workspace features available for admin configuration',
|
||||
})
|
||||
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
|
||||
return ['unlimited_workspace', 'team_plan_v1'];
|
||||
}
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -40,11 +40,4 @@ export class ServerConfigType {
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'Whether allow guest users to create demo workspaces.',
|
||||
deprecationReason:
|
||||
'This field is deprecated, please use `features` instead. Will be removed in 0.25.0',
|
||||
})
|
||||
allowGuestDemoWorkspace!: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
|
||||
const STAFF = ['@toeverything.info', '@affine.pro'];
|
||||
@@ -14,10 +13,7 @@ export enum EarlyAccessType {
|
||||
export class FeatureService {
|
||||
protected logger = new Logger(FeatureService.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly models: Models
|
||||
) {}
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
// ======== Admin ========
|
||||
isStaff(email: string) {
|
||||
@@ -38,27 +34,6 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
// ======== Early Access ========
|
||||
async addEarlyAccess(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return this.models.userFeature.add(
|
||||
userId,
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access',
|
||||
'Early access user'
|
||||
);
|
||||
}
|
||||
|
||||
async removeEarlyAccess(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return this.models.userFeature.remove(
|
||||
userId,
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
|
||||
);
|
||||
}
|
||||
|
||||
async isEarlyAccessUser(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
@@ -68,21 +43,4 @@ export class FeatureService {
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
|
||||
);
|
||||
}
|
||||
|
||||
async canEarlyAccess(
|
||||
email: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
const earlyAccessControlEnabled = this.config.flags.earlyAccessControl;
|
||||
|
||||
if (earlyAccessControlEnabled && !this.isStaff(email)) {
|
||||
const user = await this.models.user.getUserByEmail(email);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return this.isEarlyAccessUser(user.id, type);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
Throttle,
|
||||
UserNotFound,
|
||||
} from '../../base';
|
||||
import { Models, UserSettingsSchema } from '../../models';
|
||||
import {
|
||||
Feature,
|
||||
Models,
|
||||
UserFeatureName,
|
||||
UserSettingsSchema,
|
||||
} from '../../models';
|
||||
import { Public } from '../auth/guard';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
@@ -194,6 +199,12 @@ class ListUserInput {
|
||||
|
||||
@Field(() => Int, { nullable: true, defaultValue: 20 })
|
||||
first!: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
keyword?: string;
|
||||
|
||||
@Field(() => [Feature], { nullable: true })
|
||||
features?: Feature[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
@@ -242,8 +253,14 @@ export class UserManagementResolver {
|
||||
@Query(() => Int, {
|
||||
description: 'Get users count',
|
||||
})
|
||||
async usersCount(): Promise<number> {
|
||||
return this.db.user.count();
|
||||
async usersCount(
|
||||
@Args({ name: 'filter', type: () => ListUserInput, nullable: true })
|
||||
input?: ListUserInput
|
||||
): Promise<number> {
|
||||
return this.models.user.count({
|
||||
keyword: input?.keyword ?? null,
|
||||
features: (input?.features as UserFeatureName[]) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => [UserType], {
|
||||
@@ -252,7 +269,12 @@ export class UserManagementResolver {
|
||||
async users(
|
||||
@Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput
|
||||
): Promise<UserType[]> {
|
||||
const users = await this.models.user.pagination(input.skip, input.first);
|
||||
const users = await this.models.user.list({
|
||||
skip: input.skip,
|
||||
take: input.first,
|
||||
keyword: input.keyword,
|
||||
features: input.features as UserFeatureName[],
|
||||
});
|
||||
|
||||
return users.map(sessionUser);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
WorkspaceMemberResolver,
|
||||
WorkspaceResolver,
|
||||
} from './resolvers';
|
||||
import { AdminWorkspaceResolver } from './resolvers/admin';
|
||||
import { WorkspaceService } from './service';
|
||||
|
||||
@Module({
|
||||
@@ -43,6 +44,7 @@ import { WorkspaceService } from './service';
|
||||
WorkspaceBlobResolver,
|
||||
WorkspaceService,
|
||||
WorkspaceEvents,
|
||||
AdminWorkspaceResolver,
|
||||
],
|
||||
exports: [WorkspaceService],
|
||||
})
|
||||
|
||||
305
packages/backend/server/src/core/workspaces/resolvers/admin.ts
Normal file
305
packages/backend/server/src/core/workspaces/resolvers/admin.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
PartialType,
|
||||
PickType,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import {
|
||||
Feature,
|
||||
Models,
|
||||
WorkspaceFeatureName,
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { Admin } from '../../common';
|
||||
import { WorkspaceUserType } from '../../user';
|
||||
|
||||
enum AdminWorkspaceSort {
|
||||
CreatedAt = 'CreatedAt',
|
||||
SnapshotSize = 'SnapshotSize',
|
||||
BlobCount = 'BlobCount',
|
||||
BlobSize = 'BlobSize',
|
||||
}
|
||||
|
||||
registerEnumType(AdminWorkspaceSort, {
|
||||
name: 'AdminWorkspaceSort',
|
||||
});
|
||||
|
||||
@InputType()
|
||||
class ListWorkspaceInput {
|
||||
@Field(() => Int, { defaultValue: 20 })
|
||||
first!: number;
|
||||
|
||||
@Field(() => Int, { defaultValue: 0 })
|
||||
skip!: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
keyword?: string;
|
||||
|
||||
@Field(() => [Feature], { nullable: true })
|
||||
features?: WorkspaceFeatureName[];
|
||||
|
||||
@Field(() => AdminWorkspaceSort, { nullable: true })
|
||||
orderBy?: AdminWorkspaceSort;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class AdminWorkspaceMember {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
email!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
avatarUrl?: string | null;
|
||||
|
||||
@Field(() => WorkspaceRole)
|
||||
role!: WorkspaceRole;
|
||||
|
||||
@Field(() => WorkspaceMemberStatus)
|
||||
status!: WorkspaceMemberStatus;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AdminWorkspace {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
public!: boolean;
|
||||
|
||||
@Field()
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
name?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
avatarKey?: string | null;
|
||||
|
||||
@Field()
|
||||
enableAi!: boolean;
|
||||
|
||||
@Field()
|
||||
enableUrlPreview!: boolean;
|
||||
|
||||
@Field()
|
||||
enableDocEmbedding!: boolean;
|
||||
|
||||
@Field(() => [Feature])
|
||||
features!: WorkspaceFeatureName[];
|
||||
|
||||
@Field(() => WorkspaceUserType, { nullable: true })
|
||||
owner?: WorkspaceUserType | null;
|
||||
|
||||
@Field(() => Int)
|
||||
memberCount!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
publicPageCount!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
snapshotCount!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
snapshotSize!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
blobCount!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
blobSize!: number;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class AdminUpdateWorkspaceInput extends PartialType(
|
||||
PickType(AdminWorkspace, [
|
||||
'public',
|
||||
'enableAi',
|
||||
'enableUrlPreview',
|
||||
'enableDocEmbedding',
|
||||
'name',
|
||||
'avatarKey',
|
||||
] as const),
|
||||
InputType
|
||||
) {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field(() => [Feature], { nullable: true })
|
||||
features?: WorkspaceFeatureName[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@Admin()
|
||||
@Resolver(() => AdminWorkspace)
|
||||
export class AdminWorkspaceResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
@Query(() => [AdminWorkspace], {
|
||||
description: 'List workspaces for admin',
|
||||
})
|
||||
async adminWorkspaces(
|
||||
@Args('filter', { type: () => ListWorkspaceInput })
|
||||
filter: ListWorkspaceInput
|
||||
) {
|
||||
const { rows } = await this.models.workspace.adminListWorkspaces({
|
||||
first: filter.first,
|
||||
skip: filter.skip,
|
||||
keyword: filter.keyword,
|
||||
features: filter.features,
|
||||
order: this.mapSort(filter.orderBy),
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Query(() => Int, { description: 'Workspaces count for admin' })
|
||||
async adminWorkspacesCount(
|
||||
@Args('filter', { type: () => ListWorkspaceInput })
|
||||
filter: ListWorkspaceInput
|
||||
) {
|
||||
const { total } = await this.models.workspace.adminListWorkspaces({
|
||||
...filter,
|
||||
first: 1,
|
||||
skip: 0,
|
||||
order: this.mapSort(filter.orderBy),
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
@Query(() => AdminWorkspace, {
|
||||
description: 'Get workspace detail for admin',
|
||||
nullable: true,
|
||||
})
|
||||
async adminWorkspace(@Args('id') id: string) {
|
||||
const { rows } = await this.models.workspace.adminListWorkspaces({
|
||||
first: 1,
|
||||
skip: 0,
|
||||
keyword: id,
|
||||
order: 'createdAt',
|
||||
});
|
||||
const row = rows.find(r => r.id === id);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@ResolveField(() => [AdminWorkspaceMember], {
|
||||
description: 'Members of workspace',
|
||||
})
|
||||
async members(
|
||||
@Parent() workspace: AdminWorkspace,
|
||||
@Args('skip', { type: () => Int, nullable: true }) skip: number | null,
|
||||
@Args('take', { type: () => Int, nullable: true }) take: number | null,
|
||||
@Args('query', { type: () => String, nullable: true }) query: string | null
|
||||
): Promise<AdminWorkspaceMember[]> {
|
||||
const workspaceId = workspace.id;
|
||||
const pagination = {
|
||||
offset: skip ?? 0,
|
||||
first: take ?? 20,
|
||||
after: undefined,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
const list = await this.models.workspaceUser.search(
|
||||
workspaceId,
|
||||
query,
|
||||
pagination
|
||||
);
|
||||
return list.map(({ user, status, type }) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: type,
|
||||
status,
|
||||
}));
|
||||
}
|
||||
|
||||
const [list] = await this.models.workspaceUser.paginate(
|
||||
workspaceId,
|
||||
pagination
|
||||
);
|
||||
return list.map(({ user, status, type }) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: type,
|
||||
status,
|
||||
}));
|
||||
}
|
||||
|
||||
@Mutation(() => AdminWorkspace, {
|
||||
description: 'Update workspace flags and features for admin',
|
||||
nullable: true,
|
||||
})
|
||||
async adminUpdateWorkspace(
|
||||
@Args('input', { type: () => AdminUpdateWorkspaceInput })
|
||||
input: AdminUpdateWorkspaceInput
|
||||
) {
|
||||
const { id, features, ...updates } = input;
|
||||
|
||||
if (Object.keys(updates).length) {
|
||||
await this.models.workspace.update(id, updates);
|
||||
}
|
||||
|
||||
if (features) {
|
||||
const current = await this.models.workspaceFeature.list(id);
|
||||
const toAdd = features.filter(feature => !current.includes(feature));
|
||||
const toRemove = current.filter(feature => !features.includes(feature));
|
||||
|
||||
await Promise.all([
|
||||
...toAdd.map(feature =>
|
||||
this.models.workspaceFeature.add(id, feature, 'admin panel update')
|
||||
),
|
||||
...toRemove.map(feature =>
|
||||
this.models.workspaceFeature.remove(id, feature)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const { rows } = await this.models.workspace.adminListWorkspaces({
|
||||
first: 1,
|
||||
skip: 0,
|
||||
keyword: id,
|
||||
order: 'createdAt',
|
||||
});
|
||||
const row = rows.find(r => r.id === id);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private mapSort(orderBy?: AdminWorkspaceSort) {
|
||||
switch (orderBy) {
|
||||
case AdminWorkspaceSort.SnapshotSize:
|
||||
return 'snapshotSize';
|
||||
case AdminWorkspaceSort.BlobCount:
|
||||
return 'blobCount';
|
||||
case AdminWorkspaceSort.BlobSize:
|
||||
return 'blobSize';
|
||||
case AdminWorkspaceSort.CreatedAt:
|
||||
default:
|
||||
return 'createdAt';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './admin';
|
||||
export * from './blob';
|
||||
export * from './doc';
|
||||
export * from './history';
|
||||
|
||||
Reference in New Issue
Block a user