feat: improve admin panel (#14180)

This commit is contained in:
DarkSky
2025-12-30 05:22:54 +08:00
committed by GitHub
parent d6b380aee5
commit 95a5e941e7
94 changed files with 3146 additions and 1114 deletions

View File

@@ -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) {

View File

@@ -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;
}
/**

View File

@@ -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,

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}

View File

@@ -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],
})

View 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';
}
}
}

View File

@@ -1,3 +1,4 @@
export * from './admin';
export * from './blob';
export * from './doc';
export * from './history';