feat: improve admin panel (#14180)

This commit is contained in:
DarkSky authored and GitHub committed 2025-12-30 05:22:54 +08:00
1 parent d6b380aee5
commit 95a5e941e7
94 files changed
+3090 -1058

No files matched your search

-5
View File
@@ -611,11 +611,6 @@
"type": "object", "type": "object",
"description": "Configuration for flags module", "description": "Configuration for flags module",
"properties": { "properties": {
"earlyAccessControl": {
"type": "boolean",
"description": "Only allow users with early access features to access the app\n@default false",
"default": false
},
"allowGuestDemoWorkspace": { "allowGuestDemoWorkspace": {
"type": "boolean", "type": "boolean",
"description": "Whether allow guest users to create demo workspaces.\n@default true", "description": "Whether allow guest users to create demo workspaces.\n@default true",
@@ -0,0 +1,28 @@
/*
Warnings:
- You are about to drop the column `feature_id` on the `user_features` table. All the data in the column will be lost.
- You are about to drop the column `feature_id` on the `workspace_features` table. All the data in the column will be lost.
- You are about to drop the `features` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "user_features" DROP CONSTRAINT "user_features_feature_id_fkey";
-- DropForeignKey
ALTER TABLE "workspace_features" DROP CONSTRAINT "workspace_features_feature_id_fkey";
-- DropIndex
DROP INDEX "user_features_feature_id_idx";
-- DropIndex
DROP INDEX "workspace_features_feature_id_idx";
-- AlterTable
ALTER TABLE "user_features" DROP COLUMN "feature_id";
-- AlterTable
ALTER TABLE "workspace_features" DROP COLUMN "feature_id";
-- DropTable
DROP TABLE "features";
-28
View File
@@ -225,32 +225,9 @@ model WorkspaceDocUserRole {
@@map("workspace_page_user_permissions") @@map("workspace_page_user_permissions")
} }
model Feature {
id Int @id @default(autoincrement())
name String @map("feature") @db.VarChar
configs Json @default("{}") @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
/// TODO(@forehalo): remove in the coming version
/// @deprecated
/// we don't need to record all the historical version of features
deprecatedVersion Int @default(0) @map("version") @db.Integer
/// @deprecated
/// we don't need to record type of features any more, there are always static,
/// but set it in `WorkspaceFeature` and `UserFeature` for fast query with just a little redundant.
deprecatedType Int @default(0) @map("type") @db.Integer
userFeatures UserFeature[]
workspaceFeatures WorkspaceFeature[]
@@unique([name, deprecatedVersion])
@@map("features")
}
model UserFeature { model UserFeature {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String @map("user_id") @db.VarChar userId String @map("user_id") @db.VarChar
featureId Int @map("feature_id") @db.Integer
// it should be typed as `optional` in the codebase, but we would keep all values exists during data migration. // it should be typed as `optional` in the codebase, but we would keep all values exists during data migration.
// so it's safe to assert it a non-null value. // so it's safe to assert it a non-null value.
name String @default("") @map("name") @db.VarChar name String @default("") @map("name") @db.VarChar
@@ -261,19 +238,16 @@ model UserFeature {
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
activated Boolean @default(false) activated Boolean @default(false)
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
@@index([name]) @@index([name])
@@index([featureId])
@@map("user_features") @@map("user_features")
} }
model WorkspaceFeature { model WorkspaceFeature {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
workspaceId String @map("workspace_id") @db.VarChar workspaceId String @map("workspace_id") @db.VarChar
featureId Int @map("feature_id") @db.Integer
// it should be typed as `optional` in the codebase, but we would keep all values exists during data migration. // it should be typed as `optional` in the codebase, but we would keep all values exists during data migration.
// so it's safe to assert it a non-null value. // so it's safe to assert it a non-null value.
name String @default("") @map("name") @db.VarChar name String @default("") @map("name") @db.VarChar
@@ -286,12 +260,10 @@ model WorkspaceFeature {
activated Boolean @default(false) activated Boolean @default(false)
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId]) @@index([workspaceId])
@@index([name]) @@index([name])
@@index([featureId])
@@map("workspace_features") @@map("workspace_features")
} }
@@ -28,22 +28,9 @@ export class MockTeamWorkspace extends Mocker<
}, },
}); });
const feature = await this.db.feature.findFirst({
where: {
name: Feature.TeamPlan,
},
});
if (!feature) {
throw new Error(
`Feature ${Feature.TeamPlan} does not exist in DB. You might forgot to run data-migration first.`
);
}
await this.db.workspaceFeature.create({ await this.db.workspaceFeature.create({
data: { data: {
workspaceId: id, workspaceId: id,
featureId: feature.id,
reason: 'test', reason: 'test',
activated: true, activated: true,
name: Feature.TeamPlan, name: Feature.TeamPlan,
@@ -27,23 +27,10 @@ export class MockUser extends Mocker<MockUserInput, MockedUser> {
}); });
if (feature) { if (feature) {
const featureRecord = await this.db.feature.findFirst({
where: {
name: feature,
},
});
if (!featureRecord) {
throw new Error(
`Feature ${feature} does not exist in DB. You might forgot to run data-migration first.`
);
}
const config = FeatureConfigs[feature]; const config = FeatureConfigs[feature];
await this.db.userFeature.create({ await this.db.userFeature.create({
data: { data: {
userId: user.id, userId: user.id,
featureId: featureRecord.id,
name: feature, name: feature,
type: config.type, type: config.type,
reason: 'test', reason: 'test',
@@ -1,6 +1,5 @@
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import { FeatureType } from '../../models';
import { FeatureModel } from '../../models/feature'; import { FeatureModel } from '../../models/feature';
import { createTestingModule, type TestingModule } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
@@ -39,96 +38,3 @@ test('should throw if feature not found', async t => {
message: 'Feature not_found_feature not found', message: 'Feature not_found_feature not found',
}); });
}); });
test('should throw if feature config in invalid', async t => {
const { feature } = t.context;
const freePlanFeature = await feature.get('free_plan_v1');
// @ts-expect-error internal
await feature.db.feature.update({
where: {
id: freePlanFeature.id,
},
data: {
configs: {
...freePlanFeature.configs,
memberLimit: 'invalid' as any,
},
},
});
await t.throwsAsync(feature.get('free_plan_v1'), {
message: 'Invalid feature config for free_plan_v1',
});
});
// NOTE(@forehalo): backward compatibility
// new version of feature config may introduce new field
// this test means to ensure that the older version of AFFiNE Server can still read it
test('should get feature if extra fields exist in feature config', async t => {
const { feature } = t.context;
const freePlanFeature = await feature.get('free_plan_v1');
// @ts-expect-error internal
await feature.db.feature.update({
where: {
id: freePlanFeature.id,
},
data: {
configs: {
...freePlanFeature.configs,
extraField: 'extraValue',
},
},
});
const freePlanFeature2 = await feature.get('free_plan_v1');
t.snapshot(freePlanFeature2.configs);
});
test('should create feature', async t => {
const { feature } = t.context;
// @ts-expect-error internal
const newFeature = await feature.upsert(
'new_feature' as any,
{},
FeatureType.Feature,
1
);
t.deepEqual(newFeature.configs, {});
});
test('should update feature', async t => {
const { feature } = t.context;
const freePlanFeature = await feature.get('free_plan_v1');
// @ts-expect-error internal
const newFreePlanFeature = await feature.upsert(
'free_plan_v1',
{
...freePlanFeature.configs,
memberLimit: 10,
},
FeatureType.Quota,
1
);
t.deepEqual(newFreePlanFeature.configs, {
...freePlanFeature.configs,
memberLimit: 10,
});
});
test('should throw if feature config is invalid when updating', async t => {
const { feature } = t.context;
await t.throwsAsync(
// @ts-expect-error internal
feature.upsert('free_plan_v1', {} as any, FeatureType.Quota, 1),
{
message: 'Invalid feature config for free_plan_v1',
}
);
});
@@ -295,7 +295,7 @@ test('should paginate users', async t => {
) )
); );
const users = await t.context.user.pagination(0, 10); const users = await t.context.user.list({ skip: 0, take: 10 });
t.is(users.length, 10); t.is(users.length, 10);
t.deepEqual( t.deepEqual(
users.map(user => user.email), users.map(user => user.email),
@@ -1,10 +1,7 @@
import { INestApplicationContext, LogLevel } from '@nestjs/common'; import { INestApplicationContext, LogLevel } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import whywhywhy from 'why-is-node-running'; import whywhywhy from 'why-is-node-running';
import { RefreshFeatures0001 } from '../../data/migrations/0001-refresh-features';
export const TEST_LOG_LEVEL: LogLevel = export const TEST_LOG_LEVEL: LogLevel =
(process.env.TEST_LOG_LEVEL as LogLevel) ?? 'fatal'; (process.env.TEST_LOG_LEVEL as LogLevel) ?? 'fatal';
@@ -27,7 +24,6 @@ async function flushDB(client: PrismaClient) {
export async function initTestingDB(context: INestApplicationContext) { export async function initTestingDB(context: INestApplicationContext) {
const db = context.get(PrismaClient, { strict: false }); const db = context.get(PrismaClient, { strict: false });
await flushDB(db); await flushDB(db);
await RefreshFeatures0001.up(db, context.get(ModuleRef));
} }
export async function sleep(ms: number) { export async function sleep(ms: number) {
@@ -375,10 +375,6 @@ export const USER_FRIENDLY_ERRORS = {
message: message:
'You are trying to sign in by a different method than you signed up with.', 'You are trying to sign in by a different method than you signed up with.',
}, },
early_access_required: {
type: 'action_forbidden',
message: `You don't have early access permission. Visit https://community.affine.pro/c/insider-general/ for more information.`,
},
sign_up_forbidden: { sign_up_forbidden: {
type: 'action_forbidden', type: 'action_forbidden',
message: `You are not allowed to sign up.`, message: `You are not allowed to sign up.`,
@@ -213,12 +213,6 @@ export class WrongSignInMethod extends UserFriendlyError {
} }
} }
export class EarlyAccessRequired extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'early_access_required', message);
}
}
export class SignUpForbidden extends UserFriendlyError { export class SignUpForbidden extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
super('action_forbidden', 'sign_up_forbidden', message); super('action_forbidden', 'sign_up_forbidden', message);
@@ -1146,7 +1140,6 @@ export enum ErrorNames {
INVALID_PASSWORD_LENGTH, INVALID_PASSWORD_LENGTH,
PASSWORD_REQUIRED, PASSWORD_REQUIRED,
WRONG_SIGN_IN_METHOD, WRONG_SIGN_IN_METHOD,
EARLY_ACCESS_REQUIRED,
SIGN_UP_FORBIDDEN, SIGN_UP_FORBIDDEN,
EMAIL_TOKEN_NOT_FOUND, EMAIL_TOKEN_NOT_FOUND,
INVALID_EMAIL_TOKEN, INVALID_EMAIL_TOKEN,
@@ -15,10 +15,10 @@ import {
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { import {
ActionForbidden,
Cache, Cache,
Config, Config,
CryptoHelper, CryptoHelper,
EarlyAccessRequired,
EmailTokenNotFound, EmailTokenNotFound,
InvalidAuthState, InvalidAuthState,
InvalidEmail, InvalidEmail,
@@ -120,7 +120,7 @@ export class AuthController {
validators.assertValidEmail(credential.email); validators.assertValidEmail(credential.email);
const canSignIn = await this.auth.canSignIn(credential.email); const canSignIn = await this.auth.canSignIn(credential.email);
if (!canSignIn) { if (!canSignIn) {
throw new EarlyAccessRequired(); throw new ActionForbidden();
} }
if (credential.password) { if (credential.password) {
@@ -4,7 +4,6 @@ import { assign, pick } from 'lodash-es';
import { Config, SignUpForbidden } from '../../base'; import { Config, SignUpForbidden } from '../../base';
import { Models, type User, type UserSession } from '../../models'; import { Models, type User, type UserSession } from '../../models';
import { FeatureService } from '../features';
import { Mailer } from '../mail/mailer'; import { Mailer } from '../mail/mailer';
import { createDevUsers } from './dev'; import { createDevUsers } from './dev';
import type { CurrentUser } from './session'; import type { CurrentUser } from './session';
@@ -44,8 +43,7 @@ export class AuthService implements OnApplicationBootstrap {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly models: Models, private readonly models: Models,
private readonly mailer: Mailer, private readonly mailer: Mailer
private readonly feature: FeatureService
) {} ) {}
async onApplicationBootstrap() { async onApplicationBootstrap() {
@@ -54,8 +52,9 @@ export class AuthService implements OnApplicationBootstrap {
} }
} }
async canSignIn(email: string) { async canSignIn(_email: string) {
return await this.feature.canEarlyAccess(email); // may add more sign-in check later
return true;
} }
/** /**
@@ -3,7 +3,6 @@ import { z } from 'zod';
import { defineModuleConfig } from '../../base'; import { defineModuleConfig } from '../../base';
export interface ServerFlags { export interface ServerFlags {
earlyAccessControl: boolean;
allowGuestDemoWorkspace: boolean; allowGuestDemoWorkspace: boolean;
} }
@@ -72,10 +71,6 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi
}); });
defineModuleConfig('flags', { defineModuleConfig('flags', {
earlyAccessControl: {
desc: 'Only allow users with early access features to access the app',
default: false,
},
allowGuestDemoWorkspace: { allowGuestDemoWorkspace: {
desc: 'Whether allow guest users to create demo workspaces.', desc: 'Whether allow guest users to create demo workspaces.',
default: true, default: true,
@@ -14,7 +14,7 @@ import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { Config, URLHelper } from '../../base'; import { Config, URLHelper } from '../../base';
import { Namespace } from '../../env'; import { Namespace } from '../../env';
import { Feature } from '../../models'; import { Feature, type WorkspaceFeatureName } from '../../models';
import { CurrentUser, Public } from '../auth'; import { CurrentUser, Public } from '../auth';
import { Admin } from '../common'; import { Admin } from '../common';
import { AvailableUserFeatureConfig } from '../features'; import { AvailableUserFeatureConfig } from '../features';
@@ -75,7 +75,7 @@ export class ServerConfigResolver {
name: name:
this.config.server.name ?? this.config.server.name ??
(env.selfhosted (env.selfhosted
? 'AFFiNE Selfhosted Cloud' ? 'AFFiNE SelfHosted Cloud'
: env.namespaces.canary : env.namespaces.canary
? 'AFFiNE Canary Cloud' ? 'AFFiNE Canary Cloud'
: env.namespaces.beta : env.namespaces.beta
@@ -85,8 +85,6 @@ export class ServerConfigResolver {
baseUrl: this.url.requestBaseUrl, baseUrl: this.url.requestBaseUrl,
type: env.DEPLOYMENT_TYPE, type: env.DEPLOYMENT_TYPE,
features: this.server.features, 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() { override availableUserFeatures() {
return super.availableUserFeatures(); return super.availableUserFeatures();
} }
@ResolveField(() => [Feature], {
description: 'Workspace features available for admin configuration',
})
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
return ['unlimited_workspace', 'team_plan_v1'];
}
} }
@InputType() @InputType()
@@ -40,11 +40,4 @@ export class ServerConfigType {
@Field(() => [ServerFeature], { description: 'enabled server features' }) @Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[]; 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 { Injectable, Logger } from '@nestjs/common';
import { Config } from '../../base';
import { Models } from '../../models'; import { Models } from '../../models';
const STAFF = ['@toeverything.info', '@affine.pro']; const STAFF = ['@toeverything.info', '@affine.pro'];
@@ -14,10 +13,7 @@ export enum EarlyAccessType {
export class FeatureService { export class FeatureService {
protected logger = new Logger(FeatureService.name); protected logger = new Logger(FeatureService.name);
constructor( constructor(private readonly models: Models) {}
private readonly config: Config,
private readonly models: Models
) {}
// ======== Admin ======== // ======== Admin ========
isStaff(email: string) { isStaff(email: string) {
@@ -38,27 +34,6 @@ export class FeatureService {
} }
// ======== Early Access ======== // ======== 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( async isEarlyAccessUser(
userId: string, userId: string,
type: EarlyAccessType = EarlyAccessType.App type: EarlyAccessType = EarlyAccessType.App
@@ -68,21 +43,4 @@ export class FeatureService {
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access' 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, Throttle,
UserNotFound, UserNotFound,
} from '../../base'; } from '../../base';
import { Models, UserSettingsSchema } from '../../models'; import {
Feature,
Models,
UserFeatureName,
UserSettingsSchema,
} from '../../models';
import { Public } from '../auth/guard'; import { Public } from '../auth/guard';
import { sessionUser } from '../auth/service'; import { sessionUser } from '../auth/service';
import { CurrentUser } from '../auth/session'; import { CurrentUser } from '../auth/session';
@@ -194,6 +199,12 @@ class ListUserInput {
@Field(() => Int, { nullable: true, defaultValue: 20 }) @Field(() => Int, { nullable: true, defaultValue: 20 })
first!: number; first!: number;
@Field(() => String, { nullable: true })
keyword?: string;
@Field(() => [Feature], { nullable: true })
features?: Feature[];
} }
@InputType() @InputType()
@@ -242,8 +253,14 @@ export class UserManagementResolver {
@Query(() => Int, { @Query(() => Int, {
description: 'Get users count', description: 'Get users count',
}) })
async usersCount(): Promise<number> { async usersCount(
return this.db.user.count(); @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], { @Query(() => [UserType], {
@@ -252,7 +269,12 @@ export class UserManagementResolver {
async users( async users(
@Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput @Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput
): Promise<UserType[]> { ): 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); return users.map(sessionUser);
} }
@@ -19,6 +19,7 @@ import {
WorkspaceMemberResolver, WorkspaceMemberResolver,
WorkspaceResolver, WorkspaceResolver,
} from './resolvers'; } from './resolvers';
import { AdminWorkspaceResolver } from './resolvers/admin';
import { WorkspaceService } from './service'; import { WorkspaceService } from './service';
@Module({ @Module({
@@ -43,6 +44,7 @@ import { WorkspaceService } from './service';
WorkspaceBlobResolver, WorkspaceBlobResolver,
WorkspaceService, WorkspaceService,
WorkspaceEvents, WorkspaceEvents,
AdminWorkspaceResolver,
], ],
exports: [WorkspaceService], exports: [WorkspaceService],
}) })
@@ -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 './blob';
export * from './doc'; export * from './doc';
export * from './history'; export * from './history';
@@ -1,16 +0,0 @@
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { FeatureModel } from '../../models';
export class RefreshFeatures0001 {
static always = true;
// do the migration
static async up(_db: PrismaClient, ref: ModuleRef) {
await ref.get(FeatureModel, { strict: false }).refreshFeatures();
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,57 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { FeatureConfigs, FeatureName, FeatureType } from '../../models';
export class FeatureRedundant1738590347632 {
// do the migration
static async up(db: PrismaClient) {
const features = await db.feature.findMany();
const validFeatures = new Map<
number,
{
name: string;
type: FeatureType;
}
>();
for (const feature of features) {
const def = FeatureConfigs[feature.name as FeatureName];
if (!def || def.deprecatedVersion !== feature.deprecatedVersion) {
await db.feature.delete({
where: { id: feature.id },
});
} else {
validFeatures.set(feature.id, {
name: feature.name,
type: def.type,
});
}
}
for (const [id, def] of validFeatures.entries()) {
await db.userFeature.updateMany({
where: {
featureId: id,
},
data: {
name: def.name,
type: def.type,
},
});
await db.workspaceFeature.updateMany({
where: {
featureId: id,
},
data: {
name: def.name,
type: def.type,
},
});
}
}
// revert the migration
static async down(_db: PrismaClient) {
// noop
}
}
@@ -1,9 +1,7 @@
export * from './0001-refresh-features';
export * from './1698398506533-guid'; export * from './1698398506533-guid';
export * from './1703756315970-unamed-account'; export * from './1703756315970-unamed-account';
export * from './1721299086340-refresh-unnamed-user'; export * from './1721299086340-refresh-unnamed-user';
export * from './1732861452428-migrate-invite-status'; export * from './1732861452428-migrate-invite-status';
export * from './1733125339942-universal-subscription'; export * from './1733125339942-universal-subscription';
export * from './1738590347632-feature-redundant';
export * from './1745211351719-create-indexer-tables'; export * from './1745211351719-create-indexer-tables';
export * from './1751966744168-correct-session-update-time'; export * from './1751966744168-correct-session-update-time';
@@ -57,7 +57,7 @@ export enum Feature {
// TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`? // TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`?
export const FeaturesShapes = { export const FeaturesShapes = {
early_access: z.object({ whitelist: z.array(z.string()) }), early_access: z.object({ whitelist: z.array(z.string()).readonly() }),
unlimited_workspace: EMPTY_CONFIG, unlimited_workspace: EMPTY_CONFIG,
unlimited_copilot: EMPTY_CONFIG, unlimited_copilot: EMPTY_CONFIG,
ai_early_access: EMPTY_CONFIG, ai_early_access: EMPTY_CONFIG,
@@ -88,16 +88,8 @@ export type FeatureConfig<T extends FeatureName> = z.infer<
(typeof FeaturesShapes)[T] (typeof FeaturesShapes)[T]
>; >;
export const FeatureConfigs: { const FreeFeature = {
[K in FeatureName]: {
type: FeatureType;
configs: FeatureConfig<K>;
deprecatedVersion: number;
};
} = {
free_plan_v1: {
type: FeatureType.Quota, type: FeatureType.Quota,
deprecatedVersion: 4,
configs: { configs: {
// quota name // quota name
name: 'Free', name: 'Free',
@@ -108,10 +100,10 @@ export const FeatureConfigs: {
memberLimit: 3, memberLimit: 3,
copilotActionLimit: 10, copilotActionLimit: 10,
}, },
}, } as const;
pro_plan_v1: {
const ProFeature = {
type: FeatureType.Quota, type: FeatureType.Quota,
deprecatedVersion: 2,
configs: { configs: {
name: 'Pro', name: 'Pro',
blobLimit: 100 * OneMB, blobLimit: 100 * OneMB,
@@ -120,10 +112,10 @@ export const FeatureConfigs: {
memberLimit: 10, memberLimit: 10,
copilotActionLimit: 10, copilotActionLimit: 10,
}, },
}, } as const;
lifetime_pro_plan_v1: {
const LifetimeProFeature = {
type: FeatureType.Quota, type: FeatureType.Quota,
deprecatedVersion: 1,
configs: { configs: {
name: 'Lifetime Pro', name: 'Lifetime Pro',
blobLimit: 100 * OneMB, blobLimit: 100 * OneMB,
@@ -132,10 +124,10 @@ export const FeatureConfigs: {
memberLimit: 10, memberLimit: 10,
copilotActionLimit: 10, copilotActionLimit: 10,
}, },
}, } as const;
team_plan_v1: {
const TeamFeature = {
type: FeatureType.Quota, type: FeatureType.Quota,
deprecatedVersion: 1,
configs: { configs: {
name: 'Team Workspace', name: 'Team Workspace',
blobLimit: 500 * OneMB, blobLimit: 500 * OneMB,
@@ -144,30 +136,33 @@ export const FeatureConfigs: {
historyPeriod: 30 * OneDay, historyPeriod: 30 * OneDay,
memberLimit: 1, memberLimit: 1,
}, },
}, } as const;
early_access: {
const WhitelistFeature = {
type: FeatureType.Feature, type: FeatureType.Feature,
deprecatedVersion: 2,
configs: { whitelist: [] }, configs: { whitelist: [] },
}, } as const;
unlimited_workspace: {
const EmptyFeature = {
type: FeatureType.Feature, type: FeatureType.Feature,
deprecatedVersion: 1,
configs: {},
},
unlimited_copilot: {
type: FeatureType.Feature,
deprecatedVersion: 1,
configs: {},
},
ai_early_access: {
type: FeatureType.Feature,
deprecatedVersion: 1,
configs: {},
},
administrator: {
type: FeatureType.Feature,
deprecatedVersion: 1,
configs: {}, configs: {},
} as const;
export const FeatureConfigs: {
[K in FeatureName]: {
type: FeatureType;
configs: FeatureConfig<K>;
};
} = {
get free_plan_v1() {
return env.selfhosted ? ProFeature : FreeFeature;
}, },
pro_plan_v1: ProFeature,
lifetime_pro_plan_v1: LifetimeProFeature,
team_plan_v1: TeamFeature,
early_access: WhitelistFeature,
unlimited_workspace: EmptyFeature,
unlimited_copilot: EmptyFeature,
ai_early_access: EmptyFeature,
administrator: EmptyFeature,
}; };
+11 -81
View File
@@ -1,6 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Feature } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { BaseModel } from './base'; import { BaseModel } from './base';
@@ -12,12 +10,6 @@ import {
FeatureType, FeatureType,
} from './common'; } from './common';
// TODO(@forehalo):
// `version` column in `features` table will deprecated because it's makes the whole system complicated without any benefits.
// It was brought to introduce a version control for features, but the version controlling is not and will not actually needed.
// It even makes things harder when a new version of an existing feature is released.
// We have to manually update all the users and workspaces binding to the latest version, which are thousands of handreds.
// This is a huge burden for us and we should remove it.
@Injectable() @Injectable()
export class FeatureModel extends BaseModel { export class FeatureModel extends BaseModel {
async get<T extends FeatureName>(name: T) { async get<T extends FeatureName>(name: T) {
@@ -30,31 +22,32 @@ export class FeatureModel extends BaseModel {
} }
/** /**
* Get the latest feature from database. * Get the latest feature from code definitions.
* *
* @internal * @internal
*/ */
async try_get_unchecked<T extends FeatureName>(name: T) { async try_get_unchecked<T extends FeatureName>(name: T) {
const feature = await this.db.feature.findFirst({ const config = FeatureConfigs[name];
where: { name }, if (!config) {
}); return null;
}
return feature as Omit<Feature, 'configs'> & { return {
configs: Record<string, any>; name,
configs: config.configs,
type: config.type,
}; };
} }
/** /**
* Get the latest feature from database. * Get the latest feature from code definitions.
* *
* @throws {Error} If the feature is not found in DB. * @throws {Error} If the feature is not found in code.
* @internal * @internal
*/ */
async get_unchecked<T extends FeatureName>(name: T) { async get_unchecked<T extends FeatureName>(name: T) {
const feature = await this.try_get_unchecked(name); const feature = await this.try_get_unchecked(name);
// All features are hardcoded in the codebase
// It would be a fatal error if the feature is not found in DB.
if (!feature) { if (!feature) {
throw new Error(`Feature ${name} not found`); throw new Error(`Feature ${name} not found`);
} }
@@ -82,67 +75,4 @@ export class FeatureModel extends BaseModel {
getFeatureType(name: FeatureName): FeatureType { getFeatureType(name: FeatureName): FeatureType {
return FeatureConfigs[name].type; return FeatureConfigs[name].type;
} }
@Transactional()
private async upsert<T extends FeatureName>(
name: T,
configs: FeatureConfig<T>,
deprecatedType: FeatureType,
deprecatedVersion: number
) {
const parsedConfigs = this.check(name, configs);
// TODO(@forehalo):
// could be a simple upsert operation, but we got useless `version` column in the database
// will be fixed when `version` column gets deprecated
const latest = await this.db.feature.findFirst({
where: {
name,
},
orderBy: {
deprecatedVersion: 'desc',
},
});
let feature: Feature;
if (!latest) {
feature = await this.db.feature.create({
data: {
name,
deprecatedType,
deprecatedVersion,
configs: parsedConfigs,
},
});
} else {
feature = await this.db.feature.update({
where: { id: latest.id },
data: {
configs: parsedConfigs,
},
});
}
this.logger.verbose(`Feature ${name} upserted`);
return feature as Feature & { configs: FeatureConfig<T> };
}
async refreshFeatures() {
for (const key in FeatureConfigs) {
const name = key as FeatureName;
const def = FeatureConfigs[name];
// self-hosted instance will use pro plan as free plan
if (name === 'free_plan_v1' && env.selfhosted) {
await this.upsert(
name,
FeatureConfigs['pro_plan_v1'].configs,
def.type,
def.deprecatedVersion
);
} else {
await this.upsert(name, def.configs, def.type, def.deprecatedVersion);
}
}
}
} }
@@ -77,7 +77,8 @@ export class UserFeatureModel extends BaseModel {
} }
async add(userId: string, name: UserFeatureName, reason: string) { async add(userId: string, name: UserFeatureName, reason: string) {
const feature = await this.models.feature.get_unchecked(name); // ensure feature exists
await this.models.feature.get_unchecked(name);
const existing = await this.db.userFeature.findFirst({ const existing = await this.db.userFeature.findFirst({
where: { where: {
userId, userId,
@@ -93,7 +94,6 @@ export class UserFeatureModel extends BaseModel {
const userFeature = await this.db.userFeature.create({ const userFeature = await this.db.userFeature.create({
data: { data: {
userId, userId,
featureId: feature.id,
name, name,
type: this.models.feature.getFeatureType(name), type: this.models.feature.getFeatureType(name),
activated: true, activated: true,
+71 -11
View File
@@ -13,7 +13,12 @@ import {
WrongSignInMethod, WrongSignInMethod,
} from '../base'; } from '../base';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { publicUserSelect, WorkspaceRole, workspaceUserSelect } from './common'; import {
publicUserSelect,
type UserFeatureName,
WorkspaceRole,
workspaceUserSelect,
} from './common';
import type { Workspace } from './workspace'; import type { Workspace } from './workspace';
type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string }; type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string };
@@ -313,23 +318,78 @@ export class UserModel extends BaseModel {
}); });
} }
async pagination(skip: number = 0, take: number = 20, after?: Date) { private buildListWhere(options: {
keyword?: string | null;
features?: UserFeatureName[] | null;
after?: Date;
}): Prisma.UserWhereInput {
const where: Prisma.UserWhereInput = {};
if (options.after) {
where.createdAt = {
gt: options.after,
};
}
const keyword = options.keyword?.trim();
if (keyword) {
where.OR = [
{
email: {
contains: keyword,
mode: 'insensitive',
},
},
{
id: {
contains: keyword,
},
},
];
}
if (options.features?.length) {
where.features = {
some: {
name: {
in: options.features,
},
activated: true,
},
};
}
return where;
}
async list(options: {
skip?: number;
take?: number;
keyword?: string | null;
features?: UserFeatureName[] | null;
after?: Date;
}) {
const where = this.buildListWhere(options);
return this.db.user.findMany({ return this.db.user.findMany({
where: { where,
createdAt: {
gt: after,
},
},
orderBy: { orderBy: {
createdAt: 'asc', createdAt: 'asc',
}, },
skip, skip: options.skip,
take, take: options.take,
}); });
} }
async count() { async count(
return this.db.user.count(); options: {
keyword?: string | null;
features?: UserFeatureName[] | null;
after?: Date;
} = {}
) {
const where = this.buildListWhere(options);
return this.db.user.count({ where });
} }
// #region ConnectedAccount // #region ConnectedAccount
@@ -133,7 +133,8 @@ export class WorkspaceFeatureModel extends BaseModel {
reason: string, reason: string,
overrides?: Partial<FeatureConfig<T>> overrides?: Partial<FeatureConfig<T>>
) { ) {
const feature = await this.models.feature.get_unchecked(name); // ensure feature exists
await this.models.feature.get_unchecked(name);
const existing = await this.db.workspaceFeature.findFirst({ const existing = await this.db.workspaceFeature.findFirst({
where: { where: {
@@ -178,7 +179,6 @@ export class WorkspaceFeatureModel extends BaseModel {
workspaceFeature = await this.db.workspaceFeature.create({ workspaceFeature = await this.db.workspaceFeature.create({
data: { data: {
workspaceId, workspaceId,
featureId: feature.id,
name, name,
type: this.models.feature.getFeatureType(name), type: this.models.feature.getFeatureType(name),
activated: true, activated: true,
+200 -1
View File
@@ -1,9 +1,58 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional'; import { Transactional } from '@nestjs-cls/transactional';
import { Prisma, type Workspace } from '@prisma/client'; import { Prisma, type Workspace, WorkspaceMemberStatus } from '@prisma/client';
import { EventBus } from '../base'; import { EventBus } from '../base';
import { BaseModel } from './base'; import { BaseModel } from './base';
import type { WorkspaceFeatureName } from './common';
import { WorkspaceRole } from './common/role';
type RawWorkspaceSummary = {
id: string;
public: boolean;
createdAt: Date;
name: string | null;
avatarKey: string | null;
enableAi: boolean;
enableUrlPreview: boolean;
enableDocEmbedding: boolean;
memberCount: bigint | number | null;
publicPageCount: bigint | number | null;
snapshotCount: bigint | number | null;
snapshotSize: bigint | number | null;
blobCount: bigint | number | null;
blobSize: bigint | number | null;
features: WorkspaceFeatureName[] | null;
ownerId: string | null;
ownerName: string | null;
ownerEmail: string | null;
ownerAvatarUrl: string | null;
total: bigint | number;
};
export type AdminWorkspaceSummary = {
id: string;
public: boolean;
createdAt: Date;
name: string | null;
avatarKey: string | null;
enableAi: boolean;
enableUrlPreview: boolean;
enableDocEmbedding: boolean;
memberCount: number;
publicPageCount: number;
snapshotCount: number;
snapshotSize: number;
blobCount: number;
blobSize: number;
features: WorkspaceFeatureName[];
owner: {
id: string;
name: string;
email: string;
avatarUrl: string | null;
} | null;
};
declare global { declare global {
interface Events { interface Events {
@@ -130,4 +179,154 @@ export class WorkspaceModel extends BaseModel {
return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1'); return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1');
} }
// #endregion // #endregion
// #region admin
async adminListWorkspaces(options: {
skip: number;
first: number;
keyword?: string | null;
features?: WorkspaceFeatureName[] | null;
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize';
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
const keyword = options.keyword?.trim();
const features = options.features ?? [];
const order = this.buildAdminOrder(options.order);
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
WITH feature_set AS (
SELECT workspace_id, array_agg(DISTINCT name) FILTER (WHERE activated) AS features
FROM workspace_features
GROUP BY workspace_id
),
owner AS (
SELECT wur.workspace_id,
u.id AS owner_id,
u.name AS owner_name,
u.email AS owner_email,
u.avatar_url AS owner_avatar_url
FROM workspace_user_permissions AS wur
JOIN users u ON wur.user_id = u.id
WHERE wur.type = ${WorkspaceRole.Owner}
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
),
snapshot_stats AS (
SELECT workspace_id,
SUM(octet_length(blob)) AS snapshot_size,
COUNT(*) AS snapshot_count
FROM snapshots
GROUP BY workspace_id
),
blob_stats AS (
SELECT workspace_id,
SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_size,
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count
FROM blobs
GROUP BY workspace_id
),
member_stats AS (
SELECT workspace_id, COUNT(*) AS member_count
FROM workspace_user_permissions
GROUP BY workspace_id
),
public_pages AS (
SELECT workspace_id, COUNT(*) AS public_page_count
FROM workspace_pages
WHERE public = true
GROUP BY workspace_id
)
SELECT w.id,
w.public,
w.created_at AS "createdAt",
w.name,
w.avatar_key AS "avatarKey",
w.enable_ai AS "enableAi",
w.enable_url_preview AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding",
COALESCE(ms.member_count, 0) AS "memberCount",
COALESCE(pp.public_page_count, 0) AS "publicPageCount",
COALESCE(ss.snapshot_count, 0) AS "snapshotCount",
COALESCE(ss.snapshot_size, 0) AS "snapshotSize",
COALESCE(bs.blob_count, 0) AS "blobCount",
COALESCE(bs.blob_size, 0) AS "blobSize",
COALESCE(fs.features, ARRAY[]::text[]) AS features,
o.owner_id AS "ownerId",
o.owner_name AS "ownerName",
o.owner_email AS "ownerEmail",
o.owner_avatar_url AS "ownerAvatarUrl",
COUNT(*) OVER() AS total
FROM workspaces w
LEFT JOIN feature_set fs ON fs.workspace_id = w.id
LEFT JOIN owner o ON o.workspace_id = w.id
LEFT JOIN snapshot_stats ss ON ss.workspace_id = w.id
LEFT JOIN blob_stats bs ON bs.workspace_id = w.id
LEFT JOIN member_stats ms ON ms.workspace_id = w.id
LEFT JOIN public_pages pp ON pp.workspace_id = w.id
WHERE ${
keyword
? Prisma.sql`
(
w.id ILIKE ${'%' + keyword + '%'}
OR o.owner_id ILIKE ${'%' + keyword + '%'}
OR o.owner_email ILIKE ${'%' + keyword + '%'}
)
`
: Prisma.sql`TRUE`
}
AND ${
features.length
? Prisma.sql`COALESCE(fs.features, ARRAY[]::text[]) @> ${features}`
: Prisma.sql`TRUE`
}
ORDER BY ${Prisma.raw(order)}
LIMIT ${options.first}
OFFSET ${options.skip}
`;
const total = rows.at(0)?.total ? Number(rows[0].total) : 0;
const mapped = rows.map(row => ({
id: row.id,
public: row.public,
createdAt: row.createdAt,
name: row.name,
avatarKey: row.avatarKey,
enableAi: row.enableAi,
enableUrlPreview: row.enableUrlPreview,
enableDocEmbedding: row.enableDocEmbedding,
memberCount: Number(row.memberCount ?? 0),
publicPageCount: Number(row.publicPageCount ?? 0),
snapshotCount: Number(row.snapshotCount ?? 0),
snapshotSize: Number(row.snapshotSize ?? 0),
blobCount: Number(row.blobCount ?? 0),
blobSize: Number(row.blobSize ?? 0),
features: (row.features ?? []) as WorkspaceFeatureName[],
owner: row.ownerId
? {
id: row.ownerId,
name: row.ownerName ?? '',
email: row.ownerEmail ?? '',
avatarUrl: row.ownerAvatarUrl,
}
: null,
}));
return { rows: mapped, total };
}
private buildAdminOrder(
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize'
) {
switch (order) {
case 'snapshotSize':
return `"snapshotSize" DESC NULLS LAST`;
case 'blobCount':
return `"blobCount" DESC NULLS LAST`;
case 'blobSize':
return `"blobSize" DESC NULLS LAST`;
case 'createdAt':
default:
return `"createdAt" DESC`;
}
}
// #endregion
} }
+75 -5
View File
@@ -31,6 +31,55 @@ input AddContextFileInput {
contextId: String! contextId: String!
} }
input AdminUpdateWorkspaceInput {
avatarKey: String
enableAi: Boolean
enableDocEmbedding: Boolean
enableUrlPreview: Boolean
features: [FeatureType!]
id: String!
name: String
public: Boolean
}
type AdminWorkspace {
avatarKey: String
blobCount: Int!
blobSize: SafeInt!
createdAt: DateTime!
enableAi: Boolean!
enableDocEmbedding: Boolean!
enableUrlPreview: Boolean!
features: [FeatureType!]!
id: String!
memberCount: Int!
"""Members of workspace"""
members(query: String, skip: Int, take: Int): [AdminWorkspaceMember!]!
name: String
owner: WorkspaceUserType
public: Boolean!
publicPageCount: Int!
snapshotCount: Int!
snapshotSize: SafeInt!
}
type AdminWorkspaceMember {
avatarUrl: String
email: String!
id: String!
name: String!
role: Permission!
status: WorkspaceMemberStatus!
}
enum AdminWorkspaceSort {
BlobCount
BlobSize
CreatedAt
SnapshotSize
}
type AggregateBucketHitsObjectType { type AggregateBucketHitsObjectType {
nodes: [SearchNodeObjectType!]! nodes: [SearchNodeObjectType!]!
} }
@@ -740,7 +789,6 @@ enum ErrorNames {
DOC_IS_NOT_PUBLIC DOC_IS_NOT_PUBLIC
DOC_NOT_FOUND DOC_NOT_FOUND
DOC_UPDATE_BLOCKED DOC_UPDATE_BLOCKED
EARLY_ACCESS_REQUIRED
EMAIL_ALREADY_USED EMAIL_ALREADY_USED
EMAIL_SERVICE_NOT_CONFIGURED EMAIL_SERVICE_NOT_CONFIGURED
EMAIL_TOKEN_NOT_FOUND EMAIL_TOKEN_NOT_FOUND
@@ -1161,10 +1209,20 @@ type LimitedUserType {
} }
input ListUserInput { input ListUserInput {
features: [FeatureType!]
first: Int = 20 first: Int = 20
keyword: String
skip: Int = 0 skip: Int = 0
} }
input ListWorkspaceInput {
features: [FeatureType!]
first: Int! = 20
keyword: String
orderBy: AdminWorkspaceSort
skip: Int! = 0
}
type ListedBlob { type ListedBlob {
createdAt: String! createdAt: String!
key: String! key: String!
@@ -1249,6 +1307,9 @@ type Mutation {
"""Update workspace embedding files""" """Update workspace embedding files"""
addWorkspaceEmbeddingFiles(blob: Upload!, workspaceId: String!): CopilotWorkspaceFile! addWorkspaceEmbeddingFiles(blob: Upload!, workspaceId: String!): CopilotWorkspaceFile!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
"""Update workspace flags and features for admin"""
adminUpdateWorkspace(input: AdminUpdateWorkspaceInput!): AdminWorkspace
approveMember(userId: String!, workspaceId: String!): Boolean! approveMember(userId: String!, workspaceId: String!): Boolean!
"""Ban an user""" """Ban an user"""
@@ -1613,6 +1674,15 @@ type PublicUserType {
type Query { type Query {
accessTokens: [AccessToken!]! accessTokens: [AccessToken!]!
"""Get workspace detail for admin"""
adminWorkspace(id: String!): AdminWorkspace
"""List workspaces for admin"""
adminWorkspaces(filter: ListWorkspaceInput!): [AdminWorkspace!]!
"""Workspaces count for admin"""
adminWorkspacesCount(filter: ListWorkspaceInput!): Int!
"""get the whole app configuration""" """get the whole app configuration"""
appConfig: JSONObject! appConfig: JSONObject!
@@ -1660,7 +1730,7 @@ type Query {
users(filter: ListUserInput!): [UserType!]! users(filter: ListUserInput!): [UserType!]!
"""Get users count""" """Get users count"""
usersCount: Int! usersCount(filter: ListUserInput): Int!
"""Get workspace by id""" """Get workspace by id"""
workspace(id: String!): WorkspaceType! workspace(id: String!): WorkspaceType!
@@ -1884,15 +1954,15 @@ enum SearchTable {
} }
type ServerConfigType { type ServerConfigType {
"""Whether allow guest users to create demo workspaces."""
allowGuestDemoWorkspace: Boolean! @deprecated(reason: "This field is deprecated, please use `features` instead. Will be removed in 0.25.0")
"""fetch latest available upgradable release of server""" """fetch latest available upgradable release of server"""
availableUpgrade: ReleaseVersionType availableUpgrade: ReleaseVersionType
"""Features for user that can be configured""" """Features for user that can be configured"""
availableUserFeatures: [FeatureType!]! availableUserFeatures: [FeatureType!]!
"""Workspace features available for admin configuration"""
availableWorkspaceFeatures: [FeatureType!]!
"""server base url""" """server base url"""
baseUrl: String! baseUrl: String!
@@ -19,5 +19,6 @@ query adminServerConfig {
url url
} }
availableUserFeatures availableUserFeatures
availableWorkspaceFeatures
} }
} }
@@ -0,0 +1,25 @@
mutation adminUpdateWorkspace($input: AdminUpdateWorkspaceInput!) {
adminUpdateWorkspace(input: $input) {
id
public
createdAt
name
avatarKey
enableAi
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
email
avatarUrl
}
memberCount
publicPageCount
snapshotCount
snapshotSize
blobCount
blobSize
}
}
@@ -0,0 +1,38 @@
query adminWorkspace(
$id: String!
$memberSkip: Int
$memberTake: Int
$memberQuery: String
) {
adminWorkspace(id: $id) {
id
public
createdAt
name
avatarKey
enableAi
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
email
avatarUrl
}
memberCount
publicPageCount
snapshotCount
snapshotSize
blobCount
blobSize
members(skip: $memberSkip, take: $memberTake, query: $memberQuery) {
id
name
email
avatarUrl
role
status
}
}
}
@@ -0,0 +1,29 @@
query adminWorkspaces($filter: ListWorkspaceInput!) {
adminWorkspaces(filter: $filter) {
id
public
createdAt
name
avatarKey
enableAi
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
email
avatarUrl
}
memberCount
publicPageCount
snapshotCount
snapshotSize
blobCount
blobSize
}
}
query adminWorkspacesCount($filter: ListWorkspaceInput!) {
adminWorkspacesCount(filter: $filter)
}
@@ -9,5 +9,5 @@ query listUsers($filter: ListUserInput!) {
emailVerified emailVerified
avatarUrl avatarUrl
} }
usersCount usersCount(filter: $filter)
} }
+108 -1
View File
@@ -127,12 +127,119 @@ export const adminServerConfigQuery = {
url url
} }
availableUserFeatures availableUserFeatures
availableWorkspaceFeatures
} }
} }
${passwordLimitsFragment} ${passwordLimitsFragment}
${credentialsRequirementsFragment}`, ${credentialsRequirementsFragment}`,
}; };
export const adminUpdateWorkspaceMutation = {
id: 'adminUpdateWorkspaceMutation' as const,
op: 'adminUpdateWorkspace',
query: `mutation adminUpdateWorkspace($input: AdminUpdateWorkspaceInput!) {
adminUpdateWorkspace(input: $input) {
id
public
createdAt
name
avatarKey
enableAi
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
email
avatarUrl
}
memberCount
publicPageCount
snapshotCount
snapshotSize
blobCount
blobSize
}
}`,
};
export const adminWorkspaceQuery = {
id: 'adminWorkspaceQuery' as const,
op: 'adminWorkspace',
query: `query adminWorkspace($id: String!, $memberSkip: Int, $memberTake: Int, $memberQuery: String) {
adminWorkspace(id: $id) {
id
public
createdAt
name
avatarKey
enableAi
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
email
avatarUrl
}
memberCount
publicPageCount
snapshotCount
snapshotSize
blobCount
blobSize
members(skip: $memberSkip, take: $memberTake, query: $memberQuery) {
id
name
email
avatarUrl
role
status
}
}
}`,
};
export const adminWorkspacesQuery = {
id: 'adminWorkspacesQuery' as const,
op: 'adminWorkspaces',
query: `query adminWorkspaces($filter: ListWorkspaceInput!) {
adminWorkspaces(filter: $filter) {
id
public
createdAt
name
avatarKey
enableAi
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
email
avatarUrl
}
memberCount
publicPageCount
snapshotCount
snapshotSize
blobCount
blobSize
}
}`,
};
export const adminWorkspacesCountQuery = {
id: 'adminWorkspacesCountQuery' as const,
op: 'adminWorkspacesCount',
query: `query adminWorkspacesCount($filter: ListWorkspaceInput!) {
adminWorkspacesCount(filter: $filter)
}`,
};
export const createChangePasswordUrlMutation = { export const createChangePasswordUrlMutation = {
id: 'createChangePasswordUrlMutation' as const, id: 'createChangePasswordUrlMutation' as const,
op: 'createChangePasswordUrl', op: 'createChangePasswordUrl',
@@ -287,7 +394,7 @@ export const listUsersQuery = {
emailVerified emailVerified
avatarUrl avatarUrl
} }
usersCount usersCount(filter: $filter)
}`, }`,
}; };
+237 -6
View File
@@ -67,6 +67,62 @@ export interface AddContextFileInput {
contextId: Scalars['String']['input']; contextId: Scalars['String']['input'];
} }
export interface AdminUpdateWorkspaceInput {
avatarKey?: InputMaybe<Scalars['String']['input']>;
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
enableDocEmbedding?: InputMaybe<Scalars['Boolean']['input']>;
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
features?: InputMaybe<Array<FeatureType>>;
id: Scalars['String']['input'];
name?: InputMaybe<Scalars['String']['input']>;
public?: InputMaybe<Scalars['Boolean']['input']>;
}
export interface AdminWorkspace {
__typename?: 'AdminWorkspace';
avatarKey: Maybe<Scalars['String']['output']>;
blobCount: Scalars['Int']['output'];
blobSize: Scalars['SafeInt']['output'];
createdAt: Scalars['DateTime']['output'];
enableAi: Scalars['Boolean']['output'];
enableDocEmbedding: Scalars['Boolean']['output'];
enableUrlPreview: Scalars['Boolean']['output'];
features: Array<FeatureType>;
id: Scalars['String']['output'];
memberCount: Scalars['Int']['output'];
/** Members of workspace */
members: Array<AdminWorkspaceMember>;
name: Maybe<Scalars['String']['output']>;
owner: Maybe<WorkspaceUserType>;
public: Scalars['Boolean']['output'];
publicPageCount: Scalars['Int']['output'];
snapshotCount: Scalars['Int']['output'];
snapshotSize: Scalars['SafeInt']['output'];
}
export interface AdminWorkspaceMembersArgs {
query?: InputMaybe<Scalars['String']['input']>;
skip?: InputMaybe<Scalars['Int']['input']>;
take?: InputMaybe<Scalars['Int']['input']>;
}
export interface AdminWorkspaceMember {
__typename?: 'AdminWorkspaceMember';
avatarUrl: Maybe<Scalars['String']['output']>;
email: Scalars['String']['output'];
id: Scalars['String']['output'];
name: Scalars['String']['output'];
role: Permission;
status: WorkspaceMemberStatus;
}
export enum AdminWorkspaceSort {
BlobCount = 'BlobCount',
BlobSize = 'BlobSize',
CreatedAt = 'CreatedAt',
SnapshotSize = 'SnapshotSize',
}
export interface AggregateBucketHitsObjectType { export interface AggregateBucketHitsObjectType {
__typename?: 'AggregateBucketHitsObjectType'; __typename?: 'AggregateBucketHitsObjectType';
nodes: Array<SearchNodeObjectType>; nodes: Array<SearchNodeObjectType>;
@@ -922,7 +978,6 @@ export enum ErrorNames {
DOC_IS_NOT_PUBLIC = 'DOC_IS_NOT_PUBLIC', DOC_IS_NOT_PUBLIC = 'DOC_IS_NOT_PUBLIC',
DOC_NOT_FOUND = 'DOC_NOT_FOUND', DOC_NOT_FOUND = 'DOC_NOT_FOUND',
DOC_UPDATE_BLOCKED = 'DOC_UPDATE_BLOCKED', DOC_UPDATE_BLOCKED = 'DOC_UPDATE_BLOCKED',
EARLY_ACCESS_REQUIRED = 'EARLY_ACCESS_REQUIRED',
EMAIL_ALREADY_USED = 'EMAIL_ALREADY_USED', EMAIL_ALREADY_USED = 'EMAIL_ALREADY_USED',
EMAIL_SERVICE_NOT_CONFIGURED = 'EMAIL_SERVICE_NOT_CONFIGURED', EMAIL_SERVICE_NOT_CONFIGURED = 'EMAIL_SERVICE_NOT_CONFIGURED',
EMAIL_TOKEN_NOT_FOUND = 'EMAIL_TOKEN_NOT_FOUND', EMAIL_TOKEN_NOT_FOUND = 'EMAIL_TOKEN_NOT_FOUND',
@@ -1338,10 +1393,20 @@ export interface LimitedUserType {
} }
export interface ListUserInput { export interface ListUserInput {
features?: InputMaybe<Array<FeatureType>>;
first?: InputMaybe<Scalars['Int']['input']>; first?: InputMaybe<Scalars['Int']['input']>;
keyword?: InputMaybe<Scalars['String']['input']>;
skip?: InputMaybe<Scalars['Int']['input']>; skip?: InputMaybe<Scalars['Int']['input']>;
} }
export interface ListWorkspaceInput {
features?: InputMaybe<Array<FeatureType>>;
first?: Scalars['Int']['input'];
keyword?: InputMaybe<Scalars['String']['input']>;
orderBy?: InputMaybe<AdminWorkspaceSort>;
skip?: Scalars['Int']['input'];
}
export interface ListedBlob { export interface ListedBlob {
__typename?: 'ListedBlob'; __typename?: 'ListedBlob';
createdAt: Scalars['String']['output']; createdAt: Scalars['String']['output'];
@@ -1423,6 +1488,8 @@ export interface Mutation {
/** Update workspace embedding files */ /** Update workspace embedding files */
addWorkspaceEmbeddingFiles: CopilotWorkspaceFile; addWorkspaceEmbeddingFiles: CopilotWorkspaceFile;
addWorkspaceFeature: Scalars['Boolean']['output']; addWorkspaceFeature: Scalars['Boolean']['output'];
/** Update workspace flags and features for admin */
adminUpdateWorkspace: Maybe<AdminWorkspace>;
approveMember: Scalars['Boolean']['output']; approveMember: Scalars['Boolean']['output'];
/** Ban an user */ /** Ban an user */
banUser: UserType; banUser: UserType;
@@ -1614,6 +1681,10 @@ export interface MutationAddWorkspaceFeatureArgs {
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
} }
export interface MutationAdminUpdateWorkspaceArgs {
input: AdminUpdateWorkspaceInput;
}
export interface MutationApproveMemberArgs { export interface MutationApproveMemberArgs {
userId: Scalars['String']['input']; userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
@@ -2216,6 +2287,12 @@ export interface PublicUserType {
export interface Query { export interface Query {
__typename?: 'Query'; __typename?: 'Query';
accessTokens: Array<AccessToken>; accessTokens: Array<AccessToken>;
/** Get workspace detail for admin */
adminWorkspace: Maybe<AdminWorkspace>;
/** List workspaces for admin */
adminWorkspaces: Array<AdminWorkspace>;
/** Workspaces count for admin */
adminWorkspacesCount: Scalars['Int']['output'];
/** get the whole app configuration */ /** get the whole app configuration */
appConfig: Scalars['JSONObject']['output']; appConfig: Scalars['JSONObject']['output'];
/** Apply updates to a doc using LLM and return the merged markdown. */ /** Apply updates to a doc using LLM and return the merged markdown. */
@@ -2268,6 +2345,18 @@ export interface Query {
workspaces: Array<WorkspaceType>; workspaces: Array<WorkspaceType>;
} }
export interface QueryAdminWorkspaceArgs {
id: Scalars['String']['input'];
}
export interface QueryAdminWorkspacesArgs {
filter: ListWorkspaceInput;
}
export interface QueryAdminWorkspacesCountArgs {
filter: ListWorkspaceInput;
}
export interface QueryApplyDocUpdatesArgs { export interface QueryApplyDocUpdatesArgs {
docId: Scalars['String']['input']; docId: Scalars['String']['input'];
op: Scalars['String']['input']; op: Scalars['String']['input'];
@@ -2315,6 +2404,10 @@ export interface QueryUsersArgs {
filter: ListUserInput; filter: ListUserInput;
} }
export interface QueryUsersCountArgs {
filter?: InputMaybe<ListUserInput>;
}
export interface QueryWorkspaceArgs { export interface QueryWorkspaceArgs {
id: Scalars['String']['input']; id: Scalars['String']['input'];
} }
@@ -2533,15 +2626,12 @@ export enum SearchTable {
export interface ServerConfigType { export interface ServerConfigType {
__typename?: 'ServerConfigType'; __typename?: 'ServerConfigType';
/**
* Whether allow guest users to create demo workspaces.
* @deprecated This field is deprecated, please use `features` instead. Will be removed in 0.25.0
*/
allowGuestDemoWorkspace: Scalars['Boolean']['output'];
/** fetch latest available upgradable release of server */ /** fetch latest available upgradable release of server */
availableUpgrade: Maybe<ReleaseVersionType>; availableUpgrade: Maybe<ReleaseVersionType>;
/** Features for user that can be configured */ /** Features for user that can be configured */
availableUserFeatures: Array<FeatureType>; availableUserFeatures: Array<FeatureType>;
/** Workspace features available for admin configuration */
availableWorkspaceFeatures: Array<FeatureType>;
/** server base url */ /** server base url */
baseUrl: Scalars['String']['output']; baseUrl: Scalars['String']['output'];
/** credentials requirement */ /** credentials requirement */
@@ -3200,6 +3290,7 @@ export type AdminServerConfigQuery = {
type: ServerDeploymentType; type: ServerDeploymentType;
initialized: boolean; initialized: boolean;
availableUserFeatures: Array<FeatureType>; availableUserFeatures: Array<FeatureType>;
availableWorkspaceFeatures: Array<FeatureType>;
credentialsRequirement: { credentialsRequirement: {
__typename?: 'CredentialsRequirementType'; __typename?: 'CredentialsRequirementType';
password: { password: {
@@ -3218,6 +3309,126 @@ export type AdminServerConfigQuery = {
}; };
}; };
export type AdminUpdateWorkspaceMutationVariables = Exact<{
input: AdminUpdateWorkspaceInput;
}>;
export type AdminUpdateWorkspaceMutation = {
__typename?: 'Mutation';
adminUpdateWorkspace: {
__typename?: 'AdminWorkspace';
id: string;
public: boolean;
createdAt: string;
name: string | null;
avatarKey: string | null;
enableAi: boolean;
enableUrlPreview: boolean;
enableDocEmbedding: boolean;
features: Array<FeatureType>;
memberCount: number;
publicPageCount: number;
snapshotCount: number;
snapshotSize: number;
blobCount: number;
blobSize: number;
owner: {
__typename?: 'WorkspaceUserType';
id: string;
name: string;
email: string;
avatarUrl: string | null;
} | null;
} | null;
};
export type AdminWorkspaceQueryVariables = Exact<{
id: Scalars['String']['input'];
memberSkip?: InputMaybe<Scalars['Int']['input']>;
memberTake?: InputMaybe<Scalars['Int']['input']>;
memberQuery?: InputMaybe<Scalars['String']['input']>;
}>;
export type AdminWorkspaceQuery = {
__typename?: 'Query';
adminWorkspace: {
__typename?: 'AdminWorkspace';
id: string;
public: boolean;
createdAt: string;
name: string | null;
avatarKey: string | null;
enableAi: boolean;
enableUrlPreview: boolean;
enableDocEmbedding: boolean;
features: Array<FeatureType>;
memberCount: number;
publicPageCount: number;
snapshotCount: number;
snapshotSize: number;
blobCount: number;
blobSize: number;
owner: {
__typename?: 'WorkspaceUserType';
id: string;
name: string;
email: string;
avatarUrl: string | null;
} | null;
members: Array<{
__typename?: 'AdminWorkspaceMember';
id: string;
name: string;
email: string;
avatarUrl: string | null;
role: Permission;
status: WorkspaceMemberStatus;
}>;
} | null;
};
export type AdminWorkspacesQueryVariables = Exact<{
filter: ListWorkspaceInput;
}>;
export type AdminWorkspacesQuery = {
__typename?: 'Query';
adminWorkspaces: Array<{
__typename?: 'AdminWorkspace';
id: string;
public: boolean;
createdAt: string;
name: string | null;
avatarKey: string | null;
enableAi: boolean;
enableUrlPreview: boolean;
enableDocEmbedding: boolean;
features: Array<FeatureType>;
memberCount: number;
publicPageCount: number;
snapshotCount: number;
snapshotSize: number;
blobCount: number;
blobSize: number;
owner: {
__typename?: 'WorkspaceUserType';
id: string;
name: string;
email: string;
avatarUrl: string | null;
} | null;
}>;
};
export type AdminWorkspacesCountQueryVariables = Exact<{
filter: ListWorkspaceInput;
}>;
export type AdminWorkspacesCountQuery = {
__typename?: 'Query';
adminWorkspacesCount: number;
};
export type CreateChangePasswordUrlMutationVariables = Exact<{ export type CreateChangePasswordUrlMutationVariables = Exact<{
callbackUrl: Scalars['String']['input']; callbackUrl: Scalars['String']['input'];
userId: Scalars['String']['input']; userId: Scalars['String']['input'];
@@ -6519,6 +6730,21 @@ export type Queries =
variables: AdminServerConfigQueryVariables; variables: AdminServerConfigQueryVariables;
response: AdminServerConfigQuery; response: AdminServerConfigQuery;
} }
| {
name: 'adminWorkspaceQuery';
variables: AdminWorkspaceQueryVariables;
response: AdminWorkspaceQuery;
}
| {
name: 'adminWorkspacesQuery';
variables: AdminWorkspacesQueryVariables;
response: AdminWorkspacesQuery;
}
| {
name: 'adminWorkspacesCountQuery';
variables: AdminWorkspacesCountQueryVariables;
response: AdminWorkspacesCountQuery;
}
| { | {
name: 'appConfigQuery'; name: 'appConfigQuery';
variables: AppConfigQueryVariables; variables: AppConfigQueryVariables;
@@ -6886,6 +7112,11 @@ export type Mutations =
variables: RevokeUserAccessTokenMutationVariables; variables: RevokeUserAccessTokenMutationVariables;
response: RevokeUserAccessTokenMutation; response: RevokeUserAccessTokenMutation;
} }
| {
name: 'adminUpdateWorkspaceMutation';
variables: AdminUpdateWorkspaceMutationVariables;
response: AdminUpdateWorkspaceMutation;
}
| { | {
name: 'createChangePasswordUrlMutation'; name: 'createChangePasswordUrlMutation';
variables: CreateChangePasswordUrlMutationVariables; variables: CreateChangePasswordUrlMutationVariables;
+7
View File
@@ -23,6 +23,9 @@ export const Setup = lazy(
export const Accounts = lazy( export const Accounts = lazy(
() => import(/* webpackChunkName: "accounts" */ './modules/accounts') () => import(/* webpackChunkName: "accounts" */ './modules/accounts')
); );
export const Workspaces = lazy(
() => import(/* webpackChunkName: "workspaces" */ './modules/workspaces')
);
export const AI = lazy( export const AI = lazy(
() => import(/* webpackChunkName: "ai" */ './modules/ai') () => import(/* webpackChunkName: "ai" */ './modules/ai')
); );
@@ -91,6 +94,10 @@ export const App = () => {
<Route path={ROUTES.admin.setup} element={<Setup />} /> <Route path={ROUTES.admin.setup} element={<Setup />} />
<Route element={<AuthenticatedRoutes />}> <Route element={<AuthenticatedRoutes />}>
<Route path={ROUTES.admin.accounts} element={<Accounts />} /> <Route path={ROUTES.admin.accounts} element={<Accounts />} />
<Route
path={ROUTES.admin.workspaces}
element={<Workspaces />}
/>
<Route path={ROUTES.admin.ai} element={<AI />} /> <Route path={ROUTES.admin.ai} element={<AI />} />
<Route path={ROUTES.admin.about} element={<About />} /> <Route path={ROUTES.admin.about} element={<About />} />
<Route <Route
@@ -0,0 +1,66 @@
import { Button, type ButtonProps } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import type { ReactNode } from 'react';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
cancelText?: string;
confirmText?: string;
confirmButtonVariant?: ButtonProps['variant'];
onConfirm: () => void;
onClose?: () => void;
}
export const ConfirmDialog = ({
open,
onOpenChange,
title,
description,
cancelText = 'Cancel',
confirmText = 'Confirm',
confirmButtonVariant = 'default',
onConfirm,
onClose,
}: ConfirmDialogProps) => {
const handleClose = () => {
onOpenChange(false);
onClose?.();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">{title}</DialogTitle>
<DialogDescription className="leading-6">
{description}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" onClick={handleClose} variant="outline">
<span>{cancelText}</span>
</Button>
<Button
type="button"
onClick={onConfirm}
variant={confirmButtonVariant}
>
<span>{confirmText}</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -111,7 +111,7 @@ export function DataTablePagination<TData>({
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="hidden h-8 w-8 p-0 lg:flex" className="h-8 w-8 p-0"
onClick={handleLastPage} onClick={handleLastPage}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
@@ -0,0 +1,166 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import {
type ColumnDef,
type ColumnFiltersState,
flexRender,
getCoreRowModel,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type Table as TableInstance,
useReactTable,
} from '@tanstack/react-table';
import { type ReactNode, useEffect, useState } from 'react';
import { DataTablePagination } from './data-table-pagination';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
totalCount: number;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
// Row Selection
rowSelection?: RowSelectionState;
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
// Toolbar
renderToolbar?: (table: TableInstance<TData>) => ReactNode;
// External State Sync (for filters etc to reset internal state if needed)
// In the original code, columnFilters was reset when keyword/features changed.
// We can expose onColumnFiltersChange or just handle it internally if we don't need to lift it.
// The original code reset columnFilters on keyword change.
// We can pass a dependency array to reset column filters?
// Or just let the parent handle it if they want to control column filters.
// For now, let's keep columnFilters internal but allow resetting.
resetFiltersDeps?: any[];
}
export function SharedDataTable<TData extends { id: string }, TValue>({
columns,
data,
totalCount,
pagination,
onPaginationChange,
rowSelection,
onRowSelectionChange,
renderToolbar,
resetFiltersDeps = [],
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
useEffect(() => {
setColumnFilters([]);
}, [resetFiltersDeps]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: row => row.id,
manualPagination: true,
rowCount: totalCount,
enableFilters: true,
onPaginationChange: onPaginationChange,
onColumnFiltersChange: setColumnFilters,
// Row Selection
enableRowSelection: !!onRowSelectionChange,
onRowSelectionChange: onRowSelectionChange,
state: {
pagination,
columnFilters,
rowSelection: rowSelection ?? {},
},
});
return (
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto">
{renderToolbar?.(table)}
<div className="rounded-md border h-full flex flex-col overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} className="flex items-center">
{headerGroup.headers.map(header => {
// Use meta.className if available, otherwise default to flex-1
const meta = header.column.columnDef.meta as
| { className?: string }
| undefined;
const className = meta?.className ?? 'flex-1 min-w-0';
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={`${className} py-2 text-xs flex items-center h-9`}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
</Table>
<div className="overflow-auto flex-1">
<Table>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className="flex items-center"
>
{row.getVisibleCells().map(cell => {
const meta = cell.column.columnDef.meta as
| { className?: string }
| undefined;
const className = meta?.className ?? 'flex-1 min-w-0';
return (
<TableCell
key={cell.id}
className={`${className} py-2`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center flex-1"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
);
}
@@ -0,0 +1,28 @@
import { ConfirmDialog } from './confirm-dialog';
export const DiscardChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
description = 'Changes will not be saved.',
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
description?: string;
}) => {
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title="Discard Changes"
description={description}
confirmText="Discard"
confirmButtonVariant="destructive"
onConfirm={onConfirm}
onClose={onClose}
/>
);
};
@@ -0,0 +1,91 @@
import { Button } from '@affine/admin/components/ui/button';
import { Checkbox } from '@affine/admin/components/ui/checkbox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@affine/admin/components/ui/popover';
import type { FeatureType } from '@affine/graphql';
import { useCallback } from 'react';
type FeatureFilterPopoverProps = {
selectedFeatures: FeatureType[];
availableFeatures: FeatureType[];
onChange: (features: FeatureType[]) => void;
align?: 'start' | 'center' | 'end';
buttonLabel?: string;
};
export const FeatureFilterPopover = ({
selectedFeatures,
availableFeatures,
onChange,
align = 'start',
buttonLabel = 'Features',
}: FeatureFilterPopoverProps) => {
const handleFeatureToggle = useCallback(
(feature: FeatureType, checked: boolean) => {
if (checked) {
onChange([...new Set([...selectedFeatures, feature])]);
} else {
onChange(selectedFeatures.filter(enabled => enabled !== feature));
}
},
[onChange, selectedFeatures]
);
const handleClearFeatures = useCallback(() => {
onChange([]);
}, [onChange]);
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2 lg:px-3 space-x-1"
>
<span>{buttonLabel}</span>
{selectedFeatures.length > 0 ? (
<span className="text-xs text-muted-foreground">
({selectedFeatures.length})
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent
align={align}
className="w-[240px] p-2 flex flex-col gap-2"
>
<div className="text-xs font-medium px-1">Filter by feature</div>
<div className="flex flex-col gap-1 max-h-64 overflow-auto">
{availableFeatures.map(feature => (
<label
key={feature}
className="flex items-center gap-2 px-1 py-1.5 cursor-pointer"
>
<Checkbox
checked={selectedFeatures.includes(feature)}
onCheckedChange={checked =>
handleFeatureToggle(feature, !!checked)
}
/>
<span className="text-sm truncate">{feature}</span>
</label>
))}
</div>
<div className="flex justify-end gap-2 px-1">
<Button
variant="ghost"
size="sm"
onClick={handleClearFeatures}
disabled={selectedFeatures.length === 0}
>
Clear
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,91 @@
import { Checkbox } from '@affine/admin/components/ui/checkbox';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback } from 'react';
import { cn } from '../../utils';
type FeatureToggleListProps = {
features: FeatureType[];
selected: FeatureType[];
onChange: (features: FeatureType[]) => void;
control?: 'checkbox' | 'switch';
controlPosition?: 'left' | 'right';
showSeparators?: boolean;
className?: string;
};
export const FeatureToggleList = ({
features,
selected,
onChange,
control = 'checkbox',
controlPosition = 'left',
showSeparators = false,
className,
}: FeatureToggleListProps) => {
const Control = control === 'switch' ? Switch : Checkbox;
const handleToggle = useCallback(
(feature: FeatureType, checked: boolean) => {
if (checked) {
onChange([...new Set([...selected, feature])]);
} else {
onChange(selected.filter(item => item !== feature));
}
},
[onChange, selected]
);
if (!features.length) {
return (
<div
className={cn(className, 'px-3 py-2 text-xs')}
style={{ color: cssVarV2('text/secondary') }}
>
No configurable features.
</div>
);
}
return (
<div className={className}>
{features.map((feature, index) => (
<div key={feature}>
<Label
className={cn(
'cursor-pointer',
controlPosition === 'right'
? 'flex items-center justify-between p-3 text-[15px] gap-2 font-medium leading-6 overflow-hidden'
: 'flex items-center gap-2 px-3 py-2 text-sm'
)}
>
{controlPosition === 'left' ? (
<>
<Control
checked={selected.includes(feature)}
onCheckedChange={checked => handleToggle(feature, !!checked)}
/>
<span className="truncate">{feature}</span>
</>
) : (
<>
<span className="overflow-hidden text-ellipsis" title={feature}>
{feature}
</span>
<Control
checked={selected.includes(feature)}
onCheckedChange={checked => handleToggle(feature, !!checked)}
/>
</>
)}
</Label>
{showSeparators && index < features.length - 1 && <Separator />}
</div>
))}
</div>
);
};
@@ -0,0 +1,98 @@
import { Button, type ButtonProps } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { Input } from '@affine/admin/components/ui/input';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
interface TypeConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: ReactNode;
targetText: string;
inputPlaceholder?: string;
cancelText?: string;
confirmText?: string;
confirmButtonVariant?: ButtonProps['variant'];
onConfirm: () => void;
onClose?: () => void;
}
export const TypeConfirmDialog = ({
open,
onOpenChange,
title,
description,
targetText,
inputPlaceholder = 'Please type to confirm',
cancelText = 'Cancel',
confirmText = 'Confirm',
confirmButtonVariant = 'destructive',
onConfirm,
onClose,
}: TypeConfirmDialogProps) => {
const [input, setInput] = useState('');
const handleInput = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
},
[]
);
useEffect(() => {
if (!open) {
setInput('');
}
}, [open]);
const handleClose = () => {
onOpenChange(false);
onClose?.();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Input
type="text"
value={input}
onChange={handleInput}
placeholder={inputPlaceholder}
className="placeholder:opacity-50 mt-4 h-9"
/>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClose}
>
{cancelText}
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={input !== targetText}
size="sm"
variant={confirmButtonVariant}
>
{confirmText}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
-4
View File
@@ -228,10 +228,6 @@
} }
}, },
"flags": { "flags": {
"earlyAccessControl": {
"type": "Boolean",
"desc": "Only allow users with early access features to access the app"
},
"allowGuestDemoWorkspace": { "allowGuestDemoWorkspace": {
"type": "Boolean", "type": "Boolean",
"desc": "Whether allow guest users to create demo workspaces." "desc": "Whether allow guest users to create demo workspaces."
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
@@ -66,6 +66,9 @@ export const useColumns = ({
return [ return [
{ {
id: 'select', id: 'select',
meta: {
className: 'w-[40px] flex-shrink-0',
},
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={ checked={
@@ -127,6 +130,9 @@ export const useColumns = ({
}, },
{ {
accessorKey: 'info', accessorKey: 'info',
meta: {
className: 'w-[250px] flex-shrink-0',
},
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
className="text-xs" className="text-xs"
@@ -190,7 +196,7 @@ export const useColumns = ({
<DataTableColumnHeader <DataTableColumnHeader
className="text-xs max-md:hidden" className="text-xs max-md:hidden"
column={column} column={column}
title="UUID" title="User Detail"
/> />
), ),
cell: ({ row: { original: user } }) => ( cell: ({ row: { original: user } }) => (
@@ -233,12 +239,40 @@ export const useColumns = ({
textFalse="Email Not Verified" textFalse="Email Not Verified"
/> />
</div> </div>
<div className="flex flex-wrap gap-2 items-center">
{user.features.length ? (
user.features.map(feature => (
<span
key={feature}
className="rounded px-2 py-0.5 text-xs h-5 border inline-flex items-center"
style={{
borderRadius: '4px',
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
{feature}
</span>
))
) : (
<span
style={{
color: cssVarV2('text/secondary'),
}}
>
No features
</span>
)}
</div>
</div> </div>
</div> </div>
), ),
}, },
{ {
id: 'actions', id: 'actions',
meta: {
className: 'w-[80px]',
},
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
className="text-xs" className="text-xs"
@@ -16,11 +16,11 @@ import {
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { DiscardChanges } from '../../../components/shared/discard-changes';
import { useRightPanel } from '../../panel/context'; import { useRightPanel } from '../../panel/context';
import type { UserType } from '../schema'; import type { UserType } from '../schema';
import { DeleteAccountDialog } from './delete-account'; import { DeleteAccountDialog } from './delete-account';
import { DisableAccountDialog } from './disable-account'; import { DisableAccountDialog } from './disable-account';
import { DiscardChanges } from './discard-changes';
import { EnableAccountDialog } from './enable-account'; import { EnableAccountDialog } from './enable-account';
import { ResetPasswordDialog } from './reset-password'; import { ResetPasswordDialog } from './reset-password';
import { import {
@@ -41,7 +41,14 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
const [disableDialogOpen, setDisableDialogOpen] = useState(false); const [disableDialogOpen, setDisableDialogOpen] = useState(false);
const [enableDialogOpen, setEnableDialogOpen] = useState(false); const [enableDialogOpen, setEnableDialogOpen] = useState(false);
const [discardDialogOpen, setDiscardDialogOpen] = useState(false); const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const { openPanel, isOpen, closePanel, setPanelContent } = useRightPanel(); const {
openPanel,
isOpen,
closePanel,
setPanelContent,
hasDirtyChanges,
setHasDirtyChanges,
} = useRightPanel();
const deleteUser = useDeleteUser(); const deleteUser = useDeleteUser();
const disableUser = useDisableUser(); const disableUser = useDisableUser();
@@ -118,44 +125,42 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
setEnableDialogOpen(false); setEnableDialogOpen(false);
}, []); }, []);
const handleDiscardChangesCancel = useCallback(() => {
setDiscardDialogOpen(false);
}, []);
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
setHasDirtyChanges(false);
setPanelContent( setPanelContent(
<UpdateUserForm <UpdateUserForm
user={user} user={user}
onComplete={closePanel} onComplete={closePanel}
onResetPassword={openResetPasswordDialog} onResetPassword={openResetPasswordDialog}
onDeleteAccount={openDeleteDialog} onDeleteAccount={openDeleteDialog}
onDirtyChange={setHasDirtyChanges}
/> />
); );
if (discardDialogOpen) {
handleDiscardChangesCancel();
}
if (!isOpen) {
openPanel(); openPanel();
}
}, [ }, [
closePanel, closePanel,
discardDialogOpen,
handleDiscardChangesCancel,
isOpen,
openDeleteDialog, openDeleteDialog,
openPanel, openPanel,
openResetPasswordDialog, openResetPasswordDialog,
setPanelContent, setPanelContent,
user, user,
setHasDirtyChanges,
]); ]);
const handleEdit = useCallback(() => { const handleEdit = useCallback(() => {
if (isOpen) { if (hasDirtyChanges) {
setDiscardDialogOpen(true); setDiscardDialogOpen(true);
} else { return;
handleConfirm();
} }
}, [handleConfirm, isOpen]); setHasDirtyChanges(false);
handleConfirm();
}, [handleConfirm, hasDirtyChanges, setHasDirtyChanges]);
const handleDiscardConfirm = useCallback(() => {
setDiscardDialogOpen(false);
setHasDirtyChanges(false);
handleConfirm();
}, [handleConfirm, setHasDirtyChanges]);
return ( return (
<div className="flex justify-end items-center"> <div className="flex justify-end items-center">
@@ -242,8 +247,8 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
<DiscardChanges <DiscardChanges
open={discardDialogOpen} open={discardDialogOpen}
onOpenChange={setDiscardDialogOpen} onOpenChange={setDiscardDialogOpen}
onClose={handleDiscardChangesCancel} onClose={() => setDiscardDialogOpen(false)}
onConfirm={handleConfirm} onConfirm={handleDiscardConfirm}
/> />
</div> </div>
); );
@@ -1,126 +1,99 @@
import { Button } from '@affine/admin/components/ui/button'; import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input'; import { Input } from '@affine/admin/components/ui/input';
import { useQuery } from '@affine/admin/use-query'; import type { FeatureType } from '@affine/graphql';
import { getUserByEmailQuery } from '@affine/graphql';
import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc'; import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc';
import type { Table } from '@tanstack/react-table'; import type { Table } from '@tanstack/react-table';
import type { Dispatch, SetStateAction } from 'react';
import { import {
startTransition, type ChangeEvent,
type Dispatch,
type SetStateAction,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useState, useState,
} from 'react'; } from 'react';
import { DiscardChanges } from '../../../components/shared/discard-changes';
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
import { useServerConfig } from '../../common';
import { useRightPanel } from '../../panel/context'; import { useRightPanel } from '../../panel/context';
import type { UserType } from '../schema'; import type { UserType } from '../schema';
import { DiscardChanges } from './discard-changes';
import { ExportUsersDialog } from './export-users-dialog'; import { ExportUsersDialog } from './export-users-dialog';
import { ImportUsersDialog } from './import-users'; import { ImportUsersDialog } from './import-users';
import { CreateUserForm } from './user-form'; import { CreateUserForm } from './user-form';
interface DataTableToolbarProps<TData> { interface DataTableToolbarProps<TData> {
data: TData[];
usersCount: number;
selectedUsers: UserType[]; selectedUsers: UserType[];
setDataTable: (data: TData[]) => void;
setRowCount: (rowCount: number) => void;
setMemoUsers: Dispatch<SetStateAction<UserType[]>>;
table?: Table<TData>; table?: Table<TData>;
} keyword: string;
onKeywordChange: Dispatch<SetStateAction<string>>;
const useSearch = () => { selectedFeatures: FeatureType[];
const [value, setValue] = useState(''); onFeaturesChange: Dispatch<SetStateAction<FeatureType[]>>;
const { data } = useQuery({
query: getUserByEmailQuery,
variables: { email: value },
});
const result = useMemo(() => data?.userByEmail, [data]);
return {
result,
query: setValue,
};
};
function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
} }
export function DataTableToolbar<TData>({ export function DataTableToolbar<TData>({
data,
usersCount,
selectedUsers, selectedUsers,
setDataTable,
setRowCount,
setMemoUsers,
table, table,
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
}: DataTableToolbarProps<TData>) { }: DataTableToolbarProps<TData>) {
const [value, setValue] = useState(''); const [value, setValue] = useState(keyword);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [exportDialogOpen, setExportDialogOpen] = useState(false); const [exportDialogOpen, setExportDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const debouncedValue = useDebouncedValue(value, 1000); const debouncedValue = useDebouncedValue(value, 500);
const { setPanelContent, openPanel, closePanel, isOpen } = useRightPanel(); const {
const { result, query } = useSearch(); setPanelContent,
openPanel,
closePanel,
isOpen,
hasDirtyChanges,
setHasDirtyChanges,
} = useRightPanel();
const serverConfig = useServerConfig();
const availableFeatures = serverConfig.availableUserFeatures ?? [];
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
setPanelContent(<CreateUserForm onComplete={closePanel} />); setPanelContent(
<CreateUserForm
onComplete={closePanel}
onDirtyChange={setHasDirtyChanges}
/>
);
if (dialogOpen) { if (dialogOpen) {
setDialogOpen(false); setDialogOpen(false);
} }
if (!isOpen) { if (!isOpen) {
openPanel(); openPanel();
} }
}, [setPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
useEffect(() => {
query(debouncedValue);
}, [debouncedValue, query]);
useEffect(() => {
startTransition(() => {
if (!debouncedValue) {
setDataTable(data);
setRowCount(usersCount);
} else if (result) {
setMemoUsers(prev => [...new Set([...prev, result])]);
setDataTable([result as TData]);
setRowCount(1);
} else {
setDataTable([]);
setRowCount(0);
}
});
}, [ }, [
data, setPanelContent,
debouncedValue, closePanel,
result, dialogOpen,
setDataTable, isOpen,
setMemoUsers, openPanel,
setRowCount, setHasDirtyChanges,
usersCount,
]); ]);
const onValueChange = useCallback( useEffect(() => {
(e: { currentTarget: { value: SetStateAction<string> } }) => { setValue(keyword);
}, [keyword]);
useEffect(() => {
onKeywordChange(debouncedValue.trim());
}, [debouncedValue, onKeywordChange]);
const onValueChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value); setValue(e.currentTarget.value);
}, []);
const handleFeatureToggle = useCallback(
(features: FeatureType[]) => {
onFeaturesChange(features);
}, },
[] [onFeaturesChange]
); );
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
@@ -128,11 +101,11 @@ export function DataTableToolbar<TData>({
}, []); }, []);
const handleOpenConfirm = useCallback(() => { const handleOpenConfirm = useCallback(() => {
if (isOpen) { if (hasDirtyChanges) {
return setDialogOpen(true); return setDialogOpen(true);
} }
return handleConfirm(); return handleConfirm();
}, [handleConfirm, isOpen]); }, [handleConfirm, hasDirtyChanges]);
const handleExportUsers = useCallback(() => { const handleExportUsers = useCallback(() => {
if (!table) return; if (!table) return;
@@ -192,9 +165,15 @@ export function DataTableToolbar<TData>({
</div> </div>
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2"> <div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
<FeatureFilterPopover
selectedFeatures={selectedFeatures}
availableFeatures={availableFeatures}
onChange={handleFeatureToggle}
align="end"
/>
<div className="flex"> <div className="flex">
<Input <Input
placeholder="Search Email" placeholder="Search Email / UUID"
value={value} value={value}
onChange={onValueChange} onChange={onValueChange}
className="h-8 w-[150px] lg:w-[250px]" className="h-8 w-[150px] lg:w-[250px]"
@@ -1,25 +1,13 @@
import { import type { FeatureType } from '@affine/graphql';
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import type { import type {
ColumnDef, ColumnDef,
ColumnFiltersState,
PaginationState, PaginationState,
} from '@tanstack/react-table'; RowSelectionState,
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { SharedDataTable } from '../../../components/shared/data-table';
import type { UserType } from '../schema'; import type { UserType } from '../schema';
import { DataTablePagination } from './data-table-pagination';
import { DataTableToolbar } from './data-table-toolbar'; import { DataTableToolbar } from './data-table-toolbar';
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
@@ -28,7 +16,10 @@ interface DataTableProps<TData, TValue> {
pagination: PaginationState; pagination: PaginationState;
usersCount: number; usersCount: number;
selectedUsers: UserType[]; selectedUsers: UserType[];
setMemoUsers: Dispatch<SetStateAction<UserType[]>>; keyword: string;
onKeywordChange: Dispatch<SetStateAction<string>>;
selectedFeatures: FeatureType[];
onFeaturesChange: Dispatch<SetStateAction<FeatureType[]>>;
onPaginationChange: Dispatch< onPaginationChange: Dispatch<
SetStateAction<{ SetStateAction<{
pageIndex: number; pageIndex: number;
@@ -43,139 +34,46 @@ export function DataTable<TData extends { id: string }, TValue>({
pagination, pagination,
usersCount, usersCount,
selectedUsers, selectedUsers,
setMemoUsers, keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
onPaginationChange, onPaginationChange,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [tableData, setTableData] = useState(data); useEffect(() => {
const [rowCount, setRowCount] = useState(usersCount); setRowSelection({});
const table = useReactTable({ }, [keyword, selectedFeatures]);
data: tableData,
columns, useEffect(() => {
getCoreRowModel: getCoreRowModel(), const selection: Record<string, boolean> = {};
getRowId: row => row.id, selectedUsers.forEach(user => {
manualPagination: true, selection[user.id] = true;
rowCount: rowCount,
enableFilters: true,
onPaginationChange: onPaginationChange,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onColumnFiltersChange: setColumnFilters,
state: {
pagination,
rowSelection,
columnFilters,
},
}); });
setRowSelection(selection);
useEffect(() => { }, [selectedUsers]);
setTableData(data);
}, [data]);
useEffect(() => {
setRowCount(usersCount);
}, [usersCount]);
return ( return (
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto"> <SharedDataTable
<DataTableToolbar columns={columns}
setDataTable={setTableData}
data={data} data={data}
usersCount={usersCount} totalCount={usersCount}
pagination={pagination}
onPaginationChange={onPaginationChange}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
resetFiltersDeps={[keyword, selectedFeatures]}
renderToolbar={table => (
<DataTableToolbar
table={table} table={table}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
setRowCount={setRowCount} keyword={keyword}
setMemoUsers={setMemoUsers} onKeywordChange={onKeywordChange}
selectedFeatures={selectedFeatures}
onFeaturesChange={onFeaturesChange}
/> />
<div className="rounded-md border h-full flex flex-col overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} className="flex items-center">
{headerGroup.headers.map(header => {
let columnClassName = '';
if (header.id === 'select') {
columnClassName = 'w-[40px] flex-shrink-0';
} else if (header.id === 'info') {
columnClassName = 'flex-1';
} else if (header.id === 'property') {
columnClassName = 'flex-1';
} else if (header.id === 'actions') {
columnClassName =
'w-[40px] flex-shrink-0 justify-center mr-6';
}
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={`${columnClassName} py-2 text-xs flex items-center h-9`}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)} )}
</TableHead> />
);
})}
</TableRow>
))}
</TableHeader>
</Table>
<div className="overflow-auto flex-1">
<Table>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map(row => (
<TableRow key={row.id} className="flex items-center">
{row.getVisibleCells().map(cell => {
let columnClassName = '';
if (cell.column.id === 'select') {
columnClassName = 'w-[40px] flex-shrink-0';
} else if (cell.column.id === 'info') {
columnClassName = 'flex-1';
} else if (cell.column.id === 'property') {
columnClassName = 'flex-1';
} else if (cell.column.id === 'actions') {
columnClassName =
'w-[40px] flex-shrink-0 justify-center mr-6';
}
return (
<TableCell
key={cell.id}
className={`${columnClassName} flex items-center`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
); );
} }
@@ -1,14 +1,4 @@
import { Button } from '@affine/admin/components/ui/button'; import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { Input } from '@affine/admin/components/ui/input';
import { useCallback, useEffect, useState } from 'react';
export const DeleteAccountDialog = ({ export const DeleteAccountDialog = ({
email, email,
@@ -23,55 +13,23 @@ export const DeleteAccountDialog = ({
onDelete: () => void; onDelete: () => void;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) => { }) => {
const [input, setInput] = useState('');
const handleInput = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
},
[setInput]
);
useEffect(() => {
if (!open) {
setInput('');
}
}, [open]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <TypeConfirmDialog
<DialogContent className="sm:max-w-[460px]"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>Delete Account ?</DialogTitle> title="Delete Account ?"
<DialogDescription> description={
<>
<span className="font-bold">{email}</span> will be permanently <span className="font-bold">{email}</span> will be permanently
deleted. This operation is irreversible. Please proceed with deleted. This operation is irreversible. Please proceed with caution.
caution. </>
</DialogDescription> }
</DialogHeader> targetText={email}
<Input inputPlaceholder="Please type email to confirm"
type="text" confirmText="Delete"
value={input} confirmButtonVariant="destructive"
onChange={handleInput} onConfirm={onDelete}
placeholder="Please type email to confirm" onClose={onClose}
className="placeholder:opacity-50 mt-4 h-9"
/> />
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
type="button"
onClick={onDelete}
size="sm"
variant="destructive"
disabled={input !== email}
>
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };
@@ -1,14 +1,4 @@
import { Button } from '@affine/admin/components/ui/button'; import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { Input } from '@affine/admin/components/ui/input';
import { useCallback, useEffect, useState } from 'react';
export const DisableAccountDialog = ({ export const DisableAccountDialog = ({
email, email,
@@ -23,55 +13,24 @@ export const DisableAccountDialog = ({
onDisable: () => void; onDisable: () => void;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) => { }) => {
const [input, setInput] = useState('');
const handleInput = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
},
[setInput]
);
useEffect(() => {
if (!open) {
setInput('');
}
}, [open]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <TypeConfirmDialog
<DialogContent className="sm:max-w-[460px]"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>Disable Account ?</DialogTitle> title="Disable Account ?"
<DialogDescription> description={
<>
The data associated with <span className="font-bold">{email}</span>{' '} The data associated with <span className="font-bold">{email}</span>{' '}
will be deleted and cannot be used for logging in. This operation is will be deleted and cannot be used for logging in. This operation is
irreversible. Please proceed with caution. irreversible. Please proceed with caution.
</DialogDescription> </>
</DialogHeader> }
<Input targetText={email}
type="text" inputPlaceholder="Please type email to confirm"
value={input} confirmText="Disable"
onChange={handleInput} confirmButtonVariant="destructive"
placeholder="Please type email to confirm" onConfirm={onDisable}
className="placeholder:opacity-50 mt-4 h-9" onClose={onClose}
/> />
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
type="button"
onClick={onDisable}
disabled={input !== email}
size="sm"
variant="destructive"
>
Disable
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };
@@ -1,44 +0,0 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
export const DiscardChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle>Discard Changes</DialogTitle>
<DialogDescription className="leading-6 text-[15px]">
Changes to this user will not be saved.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button type="button" onClick={onConfirm} variant="destructive">
<span>Discard</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,12 +1,4 @@
import { Button } from '@affine/admin/components/ui/button'; import { ConfirmDialog } from '../../../components/shared/confirm-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
export const EnableAccountDialog = ({ export const EnableAccountDialog = ({
open, open,
@@ -22,27 +14,21 @@ export const EnableAccountDialog = ({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) => { }) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <ConfirmDialog
<DialogContent className="sm:w-[460px]"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle className="leading-7">Enable Account</DialogTitle> title="Enable Account"
<DialogDescription className="leading-6"> description={
<>
Are you sure you want to enable the account? After enabling the Are you sure you want to enable the account? After enabling the
account, the <span className="font-bold">{email}</span> email can be account, the <span className="font-bold">{email}</span> email can be
used to log in. used to log in.
</DialogDescription> </>
</DialogHeader> }
<DialogFooter className="mt-6"> confirmText="Enable"
<div className="flex justify-end gap-2 items-center w-full"> confirmButtonVariant="default"
<Button type="button" onClick={onClose} variant="outline"> onConfirm={onConfirm}
<span>Cancel</span> onClose={onClose}
</Button> />
<Button type="button" onClick={onConfirm} variant="default">
<span>Enable</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };
@@ -2,7 +2,6 @@ import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input'; import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label'; import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator'; import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql'; import type { FeatureType } from '@affine/graphql';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { ChevronRightIcon } from 'lucide-react'; import { ChevronRightIcon } from 'lucide-react';
@@ -10,6 +9,7 @@ import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
import { useServerConfig } from '../../common'; import { useServerConfig } from '../../common';
import { RightPanelHeader } from '../../header'; import { RightPanelHeader } from '../../header';
import type { UserInput, UserType } from '../schema'; import type { UserInput, UserType } from '../schema';
@@ -24,6 +24,7 @@ type UserFormProps = {
onValidate: (user: Partial<UserInput>) => boolean; onValidate: (user: Partial<UserInput>) => boolean;
actions?: React.ReactNode; actions?: React.ReactNode;
showOption?: boolean; showOption?: boolean;
onDirtyChange?: (dirty: boolean) => void;
}; };
function UserForm({ function UserForm({
@@ -34,6 +35,7 @@ function UserForm({
onValidate, onValidate,
actions, actions,
showOption, showOption,
onDirtyChange,
}: UserFormProps) { }: UserFormProps) {
const serverConfig = useServerConfig(); const serverConfig = useServerConfig();
@@ -67,6 +69,24 @@ function UserForm({
return onValidate(changes); return onValidate(changes);
}, [onValidate, changes]); }, [onValidate, changes]);
useEffect(() => {
const normalize = (value: Partial<UserInput>) => ({
name: value.name ?? '',
email: value.email ?? '',
password: value.password ?? '',
features: [...(value.features ?? [])].sort(),
});
const current = normalize(changes);
const baseline = normalize(defaultUser);
const dirty =
(current.name !== baseline.name ||
current.email !== baseline.email ||
current.password !== baseline.password ||
current.features.join(',') !== baseline.features.join(',')) &&
!!onDirtyChange;
onDirtyChange?.(dirty);
}, [changes, defaultUser, onDirtyChange]);
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
if (!canSave) { if (!canSave) {
return; return;
@@ -77,14 +97,9 @@ function UserForm({
setChanges(defaultUser); setChanges(defaultUser);
}, [canSave, changes, defaultUser, onConfirm]); }, [canSave, changes, defaultUser, onConfirm]);
const onFeatureChanged = useCallback( const handleFeaturesChange = useCallback(
(feature: FeatureType, checked: boolean) => { (features: FeatureType[]) => {
setField('features', (features = []) => { setField('features', features);
if (checked) {
return [...features, feature];
}
return features.filter(f => f !== feature);
});
}, },
[setField] [setField]
); );
@@ -138,52 +153,21 @@ function UserForm({
)} )}
</div> </div>
<div className="border rounded-md"> <FeatureToggleList
{serverConfig.availableUserFeatures.map((feature, i) => ( className="border rounded-md"
<div key={feature}> features={serverConfig.availableUserFeatures}
<ToggleItem selected={changes.features ?? []}
name={feature} onChange={handleFeaturesChange}
checked={changes.features?.includes(feature) ?? false} control="switch"
onChange={onFeatureChanged} controlPosition="right"
showSeparators={true}
/> />
{i < serverConfig.availableUserFeatures.length - 1 && (
<Separator />
)}
</div>
))}
</div>
{actions} {actions}
</div> </div>
</div> </div>
); );
} }
function ToggleItem({
name,
checked,
onChange,
}: {
name: FeatureType;
checked: boolean;
onChange: (name: FeatureType, value: boolean) => void;
}) {
const onToggle = useCallback(
(checked: boolean) => {
onChange(name, checked);
},
[name, onChange]
);
return (
<Label className="flex items-center justify-between p-3 text-[15px] gap-2 font-medium leading-6 overflow-hidden">
<span className="overflow-hidden text-ellipsis" title={name}>
{name}
</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</Label>
);
}
function InputItem({ function InputItem({
label, label,
field, field,
@@ -241,7 +225,13 @@ const validateUpdateUser = (user: Partial<UserInput>) => {
return !!user.name || !!user.email; return !!user.name || !!user.email;
}; };
export function CreateUserForm({ onComplete }: { onComplete: () => void }) { export function CreateUserForm({
onComplete,
onDirtyChange,
}: {
onComplete: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { create, creating } = useCreateUser(); const { create, creating } = useCreateUser();
const serverConfig = useServerConfig(); const serverConfig = useServerConfig();
const passwordLimits = serverConfig.credentialsRequirement.password; const passwordLimits = serverConfig.credentialsRequirement.password;
@@ -278,6 +268,7 @@ export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
onConfirm={handleCreateUser} onConfirm={handleCreateUser}
onValidate={validateCreateUser} onValidate={validateCreateUser}
showOption={true} showOption={true}
onDirtyChange={onDirtyChange}
/> />
); );
} }
@@ -287,11 +278,13 @@ export function UpdateUserForm({
onResetPassword, onResetPassword,
onDeleteAccount, onDeleteAccount,
onComplete, onComplete,
onDirtyChange,
}: { }: {
user: UserType; user: UserType;
onResetPassword: () => void; onResetPassword: () => void;
onDeleteAccount: () => void; onDeleteAccount: () => void;
onComplete: () => void; onComplete: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const { update, updating } = useUpdateUser(); const { update, updating } = useUpdateUser();
@@ -321,6 +314,7 @@ export function UpdateUserForm({
onClose={onComplete} onClose={onComplete}
onConfirm={onUpdateUser} onConfirm={onUpdateUser}
onValidate={validateUpdateUser} onValidate={validateUpdateUser}
onDirtyChange={onDirtyChange}
actions={ actions={
<> <>
<Button <Button
@@ -1,3 +1,4 @@
import { FeatureType } from '@affine/graphql';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Header } from '../header'; import { Header } from '../header';
@@ -7,7 +8,12 @@ import type { UserType } from './schema';
import { useUserList } from './use-user-list'; import { useUserList } from './use-user-list';
export function AccountPage() { export function AccountPage() {
const { users, pagination, setPagination, usersCount } = useUserList(); const [keyword, setKeyword] = useState('');
const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]);
const { users, pagination, setPagination, usersCount } = useUserList({
keyword,
features: featureFilters,
});
// Remember the user temporarily, because userList is paginated on the server side,can't get all users at once. // Remember the user temporarily, because userList is paginated on the server side,can't get all users at once.
const [memoUsers, setMemoUsers] = useState<UserType[]>([]); const [memoUsers, setMemoUsers] = useState<UserType[]>([]);
@@ -17,9 +23,20 @@ export function AccountPage() {
const columns = useColumns({ setSelectedUserIds }); const columns = useColumns({ setSelectedUserIds });
useEffect(() => { useEffect(() => {
setMemoUsers(prev => [...new Set([...prev, ...users])]); setMemoUsers(prev => {
const map = new Map(prev.map(user => [user.id, user]));
users.forEach(user => {
map.set(user.id, user);
});
return Array.from(map.values());
});
}, [users]); }, [users]);
useEffect(() => {
setMemoUsers([]);
setSelectedUserIds(new Set<string>());
}, [featureFilters, keyword]);
const selectedUsers = useMemo(() => { const selectedUsers = useMemo(() => {
return memoUsers.filter(user => selectedUserIds.has(user.id)); return memoUsers.filter(user => selectedUserIds.has(user.id));
}, [selectedUserIds, memoUsers]); }, [selectedUserIds, memoUsers]);
@@ -35,7 +52,10 @@ export function AccountPage() {
usersCount={usersCount} usersCount={usersCount}
onPaginationChange={setPagination} onPaginationChange={setPagination}
selectedUsers={selectedUsers} selectedUsers={selectedUsers}
setMemoUsers={setMemoUsers} keyword={keyword}
onKeywordChange={setKeyword}
selectedFeatures={featureFilters}
onFeaturesChange={setFeatureFilters}
/> />
</div> </div>
); );
@@ -1,23 +1,46 @@
import { useQuery } from '@affine/admin/use-query'; import { useQuery } from '@affine/admin/use-query';
import { listUsersQuery } from '@affine/graphql'; import { FeatureType, listUsersQuery } from '@affine/graphql';
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
export const useUserList = () => { export const useUserList = (filter?: {
keyword?: string;
features?: FeatureType[];
}) => {
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}); });
const filterKey = useMemo(
() =>
`${filter?.keyword ?? ''}-${[...(filter?.features ?? [])]
.sort()
.join(',')}`,
[filter?.features, filter?.keyword]
);
useEffect(() => {
setPagination(prev => ({ ...prev, pageIndex: 0 }));
}, [filterKey]);
const { const {
data: { users, usersCount }, data: { users, usersCount },
} = useQuery({ } = useQuery(
{
query: listUsersQuery, query: listUsersQuery,
variables: { variables: {
filter: { filter: {
first: pagination.pageSize, first: pagination.pageSize,
skip: pagination.pageIndex * pagination.pageSize, skip: pagination.pageIndex * pagination.pageSize,
keyword: filter?.keyword || undefined,
features:
filter?.features && filter.features.length > 0
? filter.features
: undefined,
}, },
}, },
}); },
{ keepPreviousData: true }
);
return { return {
users, users,
@@ -1,44 +0,0 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
export const DiscardChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">Discard Changes</DialogTitle>
<DialogDescription className="leading-6">
Changes to this prompt will not be saved.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button type="button" onClick={onConfirm} variant="destructive">
<span>Discard</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -3,8 +3,8 @@ import { Separator } from '@affine/admin/components/ui/separator';
import type { CopilotPromptMessageRole } from '@affine/graphql'; import type { CopilotPromptMessageRole } from '@affine/graphql';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { DiscardChanges } from '../../components/shared/discard-changes';
import { useRightPanel } from '../panel/context'; import { useRightPanel } from '../panel/context';
import { DiscardChanges } from './discard-changes';
import { EditPrompt } from './edit-prompt'; import { EditPrompt } from './edit-prompt';
import { usePrompt } from './use-prompt'; import { usePrompt } from './use-prompt';
+29 -7
View File
@@ -8,8 +8,9 @@ import { cn } from '@affine/admin/utils';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { AlignJustifyIcon } from 'lucide-react'; import { AlignJustifyIcon } from 'lucide-react';
import type { PropsWithChildren, ReactNode, RefObject } from 'react'; import type { PropsWithChildren, ReactNode, RefObject } from 'react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels'; import type { ImperativePanelHandle } from 'react-resizable-panels';
import { useLocation } from 'react-router-dom';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
import { import {
@@ -31,12 +32,16 @@ import {
} from './panel/context'; } from './panel/context';
export function Layout({ children }: PropsWithChildren) { export function Layout({ children }: PropsWithChildren) {
const [rightPanelContent, setRightPanelContent] = useState<ReactNode>(null); const [rightPanelContent, setRightPanelContentState] =
useState<ReactNode>(null);
const [leftPanelContent, setLeftPanelContent] = useState<ReactNode>(null); const [leftPanelContent, setLeftPanelContent] = useState<ReactNode>(null);
const [leftOpen, setLeftOpen] = useState(false); const [leftOpen, setLeftOpen] = useState(false);
const [rightOpen, setRightOpen] = useState(false); const [rightOpen, setRightOpen] = useState(false);
const [rightPanelHasDirtyChanges, setRightPanelHasDirtyChanges] =
useState(false);
const rightPanelRef = useRef<ImperativePanelHandle>(null); const rightPanelRef = useRef<ImperativePanelHandle>(null);
const leftPanelRef = useRef<ImperativePanelHandle>(null); const leftPanelRef = useRef<ImperativePanelHandle>(null);
const location = useLocation();
const [activeTab, setActiveTab] = useState(''); const [activeTab, setActiveTab] = useState('');
const [activeSubTab, setActiveSubTab] = useState('server'); const [activeSubTab, setActiveSubTab] = useState('server');
@@ -88,6 +93,14 @@ export function Layout({ children }: PropsWithChildren) {
setRightOpen(false); setRightOpen(false);
}, [rightPanelRef]); }, [rightPanelRef]);
const handleSetRightPanelContent = useCallback(
(content: ReactNode) => {
setRightPanelHasDirtyChanges(false);
setRightPanelContentState(content);
},
[setRightPanelContentState, setRightPanelHasDirtyChanges]
);
const openRightPanel = useCallback(() => { const openRightPanel = useCallback(() => {
handleRightExpand(); handleRightExpand();
rightPanelRef.current?.expand(); rightPanelRef.current?.expand();
@@ -98,7 +111,8 @@ export function Layout({ children }: PropsWithChildren) {
handleRightCollapse(); handleRightCollapse();
rightPanelRef.current?.collapse(); rightPanelRef.current?.collapse();
setRightOpen(false); setRightOpen(false);
}, [handleRightCollapse]); setRightPanelHasDirtyChanges(false);
}, [handleRightCollapse, setRightPanelHasDirtyChanges]);
const toggleRightPanel = useCallback( const toggleRightPanel = useCallback(
() => () =>
@@ -108,6 +122,12 @@ export function Layout({ children }: PropsWithChildren) {
[closeRightPanel, openRightPanel] [closeRightPanel, openRightPanel]
); );
// auto close right panel when route changes
useEffect(() => {
handleSetRightPanelContent(null);
closeRightPanel();
}, [location.pathname, closeRightPanel, handleSetRightPanelContent]);
return ( return (
<PanelContext.Provider <PanelContext.Provider
value={{ value={{
@@ -122,10 +142,12 @@ export function Layout({ children }: PropsWithChildren) {
rightPanel: { rightPanel: {
isOpen: rightOpen, isOpen: rightOpen,
panelContent: rightPanelContent, panelContent: rightPanelContent,
setPanelContent: setRightPanelContent, setPanelContent: handleSetRightPanelContent,
togglePanel: toggleRightPanel, togglePanel: toggleRightPanel,
openPanel: openRightPanel, openPanel: openRightPanel,
closePanel: closeRightPanel, closePanel: closeRightPanel,
hasDirtyChanges: rightPanelHasDirtyChanges,
setHasDirtyChanges: setRightPanelHasDirtyChanges,
}, },
}} }}
> >
@@ -140,7 +162,7 @@ export function Layout({ children }: PropsWithChildren) {
}} }}
> >
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<div className="flex"> <div className="flex h-screen w-full overflow-hidden">
<ResizablePanelGroup direction="horizontal"> <ResizablePanelGroup direction="horizontal">
<LeftPanel <LeftPanel
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>} panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
@@ -278,7 +300,7 @@ export const RightPanel = ({
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<SheetContent side="right" className="p-0" withoutCloseButton> <SheetContent side="right" className="p-0" withoutCloseButton>
{panelContent} <div className="h-full overflow-y-auto">{panelContent}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );
@@ -297,7 +319,7 @@ export const RightPanel = ({
onCollapse={onCollapse} onCollapse={onCollapse}
className="border-l max-w-96" className="border-l max-w-96"
> >
{panelContent} <div className="h-full overflow-y-auto">{panelContent}</div>
</ResizablePanel> </ResizablePanel>
); );
}; };
@@ -2,6 +2,7 @@ import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils'; import { cn } from '@affine/admin/utils';
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc'; import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { LayoutDashboardIcon } from 'lucide-react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { ServerVersion } from './server-version'; import { ServerVersion } from './server-version';
@@ -80,7 +81,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
> >
<nav <nav
className={cn( className={cn(
'flex flex-1 flex-col gap-1 px-2 flex-grow overflow-hidden', 'flex flex-1 flex-col gap-1 px-2 flex-grow overflow-y-auto overflow-x-hidden',
isCollapsed && 'items-center px-0 gap-1 overflow-visible' isCollapsed && 'items-center px-0 gap-1 overflow-visible'
)} )}
> >
@@ -90,6 +91,12 @@ export function Nav({ isCollapsed = false }: NavProps) {
label="Accounts" label="Accounts"
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
/> />
<NavItem
to="/admin/workspaces"
icon={<LayoutDashboardIcon size={18} />}
label="Workspaces"
isCollapsed={isCollapsed}
/>
{/* <NavItem {/* <NavItem
to="/admin/ai" to="/admin/ai"
icon={<AiOutlineIcon fontSize={20} />} icon={<AiOutlineIcon fontSize={20} />}
@@ -1,7 +1,9 @@
import { import {
createContext, createContext,
type Dispatch,
type ReactNode, type ReactNode,
type RefObject, type RefObject,
type SetStateAction,
useContext, useContext,
} from 'react'; } from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels'; import type { ImperativePanelHandle } from 'react-resizable-panels';
@@ -15,9 +17,14 @@ export type SinglePanelContextType = {
closePanel: () => void; closePanel: () => void;
}; };
export type RightPanelContextType = SinglePanelContextType & {
hasDirtyChanges: boolean;
setHasDirtyChanges: Dispatch<SetStateAction<boolean>>;
};
export interface PanelContextType { export interface PanelContextType {
leftPanel: SinglePanelContextType; leftPanel: SinglePanelContextType;
rightPanel: SinglePanelContextType; rightPanel: RightPanelContextType;
} }
export type ResizablePanelProps = { export type ResizablePanelProps = {
@@ -0,0 +1,172 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import { AccountIcon, LinkIcon } from '@blocksuite/icons/rc';
import type { ColumnDef } from '@tanstack/react-table';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useMemo } from 'react';
import type { WorkspaceListItem } from '../schema';
import { formatBytes } from '../utils';
import { DataTableRowActions } from './data-table-row-actions';
export const useColumns = () => {
const columns: ColumnDef<WorkspaceListItem>[] = useMemo(() => {
return [
{
accessorKey: 'workspace',
header: () => <div className="text-xs font-medium">Workspace</div>,
cell: ({ row }) => {
const workspace = row.original;
return (
<div className="flex flex-col gap-1 max-w-[40vw] min-w-0 overflow-hidden">
<div className="flex items-center gap-2 text-sm font-medium overflow-hidden">
<span className="truncate">
{workspace.name || workspace.id}
</span>
{workspace.public ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded border"
style={{
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
<LinkIcon fontSize={14} />
Public
</span>
) : null}
</div>
<div
className="text-xs font-mono truncate w-full"
style={{ color: cssVarV2('text/secondary') }}
>
{workspace.id}
</div>
<div className="flex flex-wrap gap-2 text-[11px]">
{workspace.features.length ? (
workspace.features.map(feature => (
<span
key={feature}
className="px-2 py-0.5 rounded border"
style={{
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
{feature}
</span>
))
) : (
<span style={{ color: cssVarV2('text/secondary') }}>
No features
</span>
)}
</div>
</div>
);
},
},
{
accessorKey: 'owner',
header: () => <div className="text-xs font-medium">Owner</div>,
cell: ({ row }) => {
const owner = row.original.owner;
if (!owner) {
return (
<div
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Unknown
</div>
);
}
return (
<div className="flex items-center gap-3 min-w-[180px] min-w-0">
<Avatar className="w-9 h-9">
<AvatarImage src={owner.avatarUrl ?? undefined} />
<AvatarFallback>
<AccountIcon fontSize={16} />
</AvatarFallback>
</Avatar>
<div className="flex flex-col overflow-hidden min-w-0">
<div className="text-sm font-medium truncate">{owner.name}</div>
<div
className="text-xs truncate"
style={{ color: cssVarV2('text/secondary') }}
>
{owner.email}
</div>
</div>
</div>
);
},
},
{
accessorKey: 'usage',
header: () => <div className="text-xs font-medium">Usage</div>,
cell: ({ row }) => {
const ws = row.original;
return (
<div className="flex flex-col gap-1 text-xs">
<div className="flex gap-3">
<span>Snapshot {formatBytes(ws.snapshotSize)}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
({ws.snapshotCount})
</span>
</div>
<div className="flex gap-3">
<span>Blobs {formatBytes(ws.blobSize)}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
({ws.blobCount})
</span>
</div>
</div>
);
},
},
{
accessorKey: 'members',
header: () => <div className="text-xs font-medium">Members</div>,
cell: ({ row }) => {
const ws = row.original;
return (
<div className="flex flex-col text-xs gap-1">
<div className="flex gap-2">
<span className="font-medium">{ws.memberCount}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
members
</span>
</div>
<div className="flex gap-2">
<span className="font-medium">{ws.publicPageCount}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
shared pages
</span>
</div>
</div>
);
},
},
{
id: 'actions',
meta: {
className: 'w-[80px] justify-end',
},
header: () => (
<div className="text-xs font-medium text-right">Actions</div>
),
cell: ({ row }) => (
<div className="flex justify-end w-full">
<DataTableRowActions workspace={row.original} />
</div>
),
},
];
}, []);
return columns;
};
@@ -0,0 +1,76 @@
import { Button } from '@affine/admin/components/ui/button';
import { EditIcon } from '@blocksuite/icons/rc';
import { useCallback, useState } from 'react';
import { DiscardChanges } from '../../../components/shared/discard-changes';
import { useRightPanel } from '../../panel/context';
import type { WorkspaceListItem } from '../schema';
import { WorkspacePanel } from './workspace-panel';
export function DataTableRowActions({
workspace,
}: {
workspace: WorkspaceListItem;
}) {
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const {
setPanelContent,
openPanel,
isOpen,
closePanel,
hasDirtyChanges,
setHasDirtyChanges,
} = useRightPanel();
const handleConfirm = useCallback(() => {
setHasDirtyChanges(false);
setPanelContent(
<WorkspacePanel workspaceId={workspace.id} onClose={closePanel} />
);
if (!isOpen) {
openPanel();
}
}, [
closePanel,
isOpen,
openPanel,
setHasDirtyChanges,
setPanelContent,
workspace.id,
]);
const handleEdit = useCallback(() => {
if (hasDirtyChanges) {
setDiscardDialogOpen(true);
return;
}
handleConfirm();
}, [handleConfirm, hasDirtyChanges]);
const handleDiscardConfirm = useCallback(() => {
setDiscardDialogOpen(false);
setHasDirtyChanges(false);
handleConfirm();
}, [handleConfirm, setHasDirtyChanges]);
return (
<>
<Button
variant="ghost"
size="sm"
className="px-2 h-8 flex items-center gap-2"
onClick={handleEdit}
>
<EditIcon fontSize={18} />
<span>Edit</span>
</Button>
<DiscardChanges
open={discardDialogOpen}
onOpenChange={setDiscardDialogOpen}
onClose={() => setDiscardDialogOpen(false)}
onConfirm={handleDiscardConfirm}
description="Changes to this workspace will not be saved."
/>
</>
);
}
@@ -0,0 +1,121 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import type { Table } from '@tanstack/react-table';
import {
type ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../../components/ui/popover';
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
import { useServerConfig } from '../../common';
interface DataTableToolbarProps<TData> {
table?: Table<TData>;
keyword: string;
onKeywordChange: (keyword: string) => void;
selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void;
sort: AdminWorkspaceSort | undefined;
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
}
const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [
{ value: AdminWorkspaceSort.SnapshotSize, label: 'Snapshot size' },
{ value: AdminWorkspaceSort.BlobCount, label: 'Blob count' },
{ value: AdminWorkspaceSort.BlobSize, label: 'Blob size' },
{ value: AdminWorkspaceSort.CreatedAt, label: 'Created time' },
];
export function DataTableToolbar<TData>({
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
sort,
onSortChange,
}: DataTableToolbarProps<TData>) {
const [value, setValue] = useState(keyword);
const debouncedValue = useDebouncedValue(value, 400);
const serverConfig = useServerConfig();
const availableFeatures = serverConfig.availableWorkspaceFeatures ?? [];
useEffect(() => {
setValue(keyword);
}, [keyword]);
useEffect(() => {
onKeywordChange(debouncedValue.trim());
}, [debouncedValue, onKeywordChange]);
const onValueChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value);
}, []);
const handleSortChange = useCallback(
(value: AdminWorkspaceSort) => {
onSortChange(value);
},
[onSortChange]
);
const selectedSortLabel = useMemo(
() =>
sortOptions.find(option => option.value === sort)?.label ??
'Created time',
[sort]
);
return (
<div className="flex items-center justify-between gap-y-2 gap-x-4 flex-wrap">
<FeatureFilterPopover
selectedFeatures={selectedFeatures}
availableFeatures={availableFeatures}
onChange={onFeaturesChange}
align="start"
/>
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 px-2 lg:px-3">
Sort: {selectedSortLabel}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-2">
<div className="flex flex-col gap-1">
{sortOptions.map(option => (
<Button
key={option.value}
variant="ghost"
className="justify-start"
size="sm"
onClick={() => handleSortChange(option.value)}
>
{option.label}
</Button>
))}
</div>
</PopoverContent>
</Popover>
<div className="flex">
<Input
placeholder="Search Workspace / Owner"
value={value}
onChange={onValueChange}
className="h-8 w-[150px] lg:w-[250px]"
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import type { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
import type { Dispatch, SetStateAction } from 'react';
import { SharedDataTable } from '../../../components/shared/data-table';
import { DataTableToolbar } from './data-table-toolbar';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pagination: PaginationState;
workspacesCount: number;
keyword: string;
onKeywordChange: (value: string) => void;
selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void;
sort: AdminWorkspaceSort | undefined;
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
onPaginationChange: Dispatch<
SetStateAction<{
pageIndex: number;
pageSize: number;
}>
>;
}
export function DataTable<TData extends { id: string }, TValue>({
columns,
data,
pagination,
workspacesCount,
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
sort,
onSortChange,
onPaginationChange,
}: DataTableProps<TData, TValue>) {
return (
<SharedDataTable
columns={columns}
data={data}
totalCount={workspacesCount}
pagination={pagination}
onPaginationChange={onPaginationChange}
resetFiltersDeps={[keyword, selectedFeatures, sort]}
renderToolbar={table => (
<DataTableToolbar
table={table}
keyword={keyword}
onKeywordChange={onKeywordChange}
selectedFeatures={selectedFeatures}
onFeaturesChange={onFeaturesChange}
sort={sort}
onSortChange={onSortChange}
/>
)}
/>
);
}
@@ -0,0 +1,357 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import {
adminUpdateWorkspaceMutation,
adminWorkspaceQuery,
adminWorkspacesQuery,
FeatureType,
} from '@affine/graphql';
import { AccountIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
import { useMutateQueryResource, useMutation } from '../../../use-mutation';
import { useQuery } from '../../../use-query';
import { useServerConfig } from '../../common';
import { RightPanelHeader } from '../../header';
import { useRightPanel } from '../../panel/context';
import type { WorkspaceDetail } from '../schema';
import { formatBytes } from '../utils';
export function WorkspacePanel({
workspaceId,
onClose,
}: {
workspaceId: string;
onClose: () => void;
}) {
const { data } = useQuery({
query: adminWorkspaceQuery,
variables: {
id: workspaceId,
memberSkip: 0,
memberTake: 20,
},
});
const workspace = data.adminWorkspace;
if (!workspace) {
return (
<div className="flex flex-col h-full">
<RightPanelHeader
title="Workspace"
handleClose={onClose}
handleConfirm={onClose}
canSave={false}
/>
<div
className="p-6 text-sm"
style={{ color: cssVarV2('text/secondary') }}
>
Workspace not found.
</div>
</div>
);
}
return <WorkspacePanelContent workspace={workspace} onClose={onClose} />;
}
function WorkspacePanelContent({
workspace,
onClose,
}: {
workspace: WorkspaceDetail;
onClose: () => void;
}) {
const serverConfig = useServerConfig();
const { setHasDirtyChanges } = useRightPanel();
const revalidate = useMutateQueryResource();
const { trigger: updateWorkspace, isMutating } = useMutation({
mutation: adminUpdateWorkspaceMutation,
});
const normalizedWorkspace = useMemo(
() => ({
features: [...workspace.features],
flags: {
public: workspace.public,
enableAi: workspace.enableAi,
enableUrlPreview: workspace.enableUrlPreview,
enableDocEmbedding: workspace.enableDocEmbedding,
name: workspace.name ?? '',
},
}),
[workspace]
);
const [featureSelection, setFeatureSelection] = useState<FeatureType[]>(
normalizedWorkspace.features
);
const [flags, setFlags] = useState(normalizedWorkspace.flags);
const [baseline, setBaseline] = useState(normalizedWorkspace);
useEffect(() => {
setFeatureSelection(normalizedWorkspace.features);
setFlags(normalizedWorkspace.flags);
setBaseline(normalizedWorkspace);
}, [normalizedWorkspace]);
const hasChanges = useMemo(() => {
return (
flags.public !== baseline.flags.public ||
flags.enableAi !== baseline.flags.enableAi ||
flags.enableUrlPreview !== baseline.flags.enableUrlPreview ||
flags.enableDocEmbedding !== baseline.flags.enableDocEmbedding ||
flags.name !== baseline.flags.name ||
featureSelection.length !== baseline.features.length ||
featureSelection.some(f => !baseline.features.includes(f))
);
}, [baseline, featureSelection, flags]);
useEffect(() => {
setHasDirtyChanges(hasChanges);
}, [hasChanges, setHasDirtyChanges]);
const handleFeaturesChange = useCallback((features: FeatureType[]) => {
setFeatureSelection(features);
}, []);
const handleSave = useCallback(() => {
const update = async () => {
try {
await updateWorkspace({
input: {
id: workspace.id,
public: flags.public,
enableAi: flags.enableAi,
enableUrlPreview: flags.enableUrlPreview,
enableDocEmbedding: flags.enableDocEmbedding,
name: flags.name || null,
features: featureSelection,
},
});
await Promise.all([
revalidate(adminWorkspacesQuery),
revalidate(adminWorkspaceQuery, vars => vars?.id === workspace.id),
]);
toast.success('Workspace updated successfully');
setBaseline({
flags: { ...flags },
features: [...featureSelection],
});
setHasDirtyChanges(false);
onClose();
} catch (e) {
toast.error(`Failed to update workspace: ${(e as Error).message}`);
}
};
update().catch(() => {});
}, [
featureSelection,
flags,
onClose,
revalidate,
setBaseline,
setHasDirtyChanges,
updateWorkspace,
workspace.id,
]);
const memberList = workspace.members ?? [];
return (
<div className="flex flex-col h-full">
<RightPanelHeader
title="Update Workspace"
handleClose={onClose}
handleConfirm={handleSave}
canSave={hasChanges && !isMutating}
/>
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
<div className="border rounded-md p-3 space-y-2">
<div
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Workspace ID
</div>
<div className="text-sm font-mono break-all">{workspace.id}</div>
<div className="flex flex-col gap-1">
<Label
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Name
</Label>
<Input
value={flags.name}
onChange={e =>
setFlags(prev => ({ ...prev, name: e.target.value }))
}
placeholder="Workspace name"
/>
</div>
</div>
<div className="border rounded-md">
<FlagItem
label="Public"
description="Allow public access to workspace pages"
checked={flags.public}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, public: value }))
}
/>
<Separator />
<FlagItem
label="Enable AI"
description="Allow AI features in this workspace"
checked={flags.enableAi}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableAi: value }))
}
/>
<Separator />
<FlagItem
label="Enable URL Preview"
description="Allow URL previews in shared pages"
checked={flags.enableUrlPreview}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableUrlPreview: value }))
}
/>
<Separator />
<FlagItem
label="Enable Doc Embedding"
description="Allow document embedding for search"
checked={flags.enableDocEmbedding}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableDocEmbedding: value }))
}
/>
</div>
<div className="border rounded-md p-3 space-y-3">
<div className="text-sm font-medium">Features</div>
<FeatureToggleList
features={serverConfig.availableWorkspaceFeatures ?? []}
selected={featureSelection}
onChange={handleFeaturesChange}
className="grid grid-cols-1 gap-2"
control="checkbox"
controlPosition="left"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<MetricCard
label="Snapshot Size"
value={formatBytes(workspace.snapshotSize)}
/>
<MetricCard
label="Snapshot Count"
value={`${workspace.snapshotCount}`}
/>
<MetricCard
label="Blob Size"
value={formatBytes(workspace.blobSize)}
/>
<MetricCard label="Blob Count" value={`${workspace.blobCount}`} />
<MetricCard label="Members" value={`${workspace.memberCount}`} />
<MetricCard
label="Shared Pages"
value={`${workspace.publicPageCount}`}
/>
</div>
<div className="border rounded-md">
<div className="px-3 py-2 text-sm font-medium">Members</div>
<Separator />
<div className="flex flex-col divide-y">
{memberList.length === 0 ? (
<div
className="px-3 py-3 text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
No members.
</div>
) : (
memberList.map(member => (
<div
key={member.id}
className="flex items-center gap-3 px-3 py-2"
>
<Avatar className="w-9 h-9">
<AvatarImage src={member.avatarUrl ?? undefined} />
<AvatarFallback>
<AccountIcon fontSize={16} />
</AvatarFallback>
</Avatar>
<div className="flex flex-col overflow-hidden">
<div className="text-sm font-medium truncate">
{member.name || member.email}
</div>
<div
className="text-xs truncate"
style={{ color: cssVarV2('text/secondary') }}
>
{member.email}
</div>
</div>
<div className="ml-auto text-xs px-2 py-1 rounded border">
{member.role}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
function FlagItem({
label,
description,
checked,
onCheckedChange,
}: {
label: string;
description: string;
checked: boolean;
onCheckedChange: (value: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-2 p-3">
<div className="flex flex-col">
<div className="text-sm font-medium">{label}</div>
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
{description}
</div>
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="border rounded-md p-3 flex flex-col gap-1">
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
{label}
</div>
<div className="text-sm font-semibold">{value}</div>
</div>
);
}
@@ -0,0 +1,46 @@
import { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import { useState } from 'react';
import { Header } from '../header';
import { useColumns } from './components/columns';
import { DataTable } from './components/data-table';
import { useWorkspaceList } from './use-workspace-list';
export function WorkspacePage() {
const [keyword, setKeyword] = useState('');
const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]);
const [sort, setSort] = useState<AdminWorkspaceSort | undefined>(
AdminWorkspaceSort.CreatedAt
);
const { workspaces, pagination, setPagination, workspacesCount } =
useWorkspaceList({
keyword,
features: featureFilters,
orderBy: sort,
});
const columns = useColumns();
return (
<div className="h-screen flex-1 flex-col flex">
<Header title="Workspaces" />
<DataTable
data={workspaces}
columns={columns}
pagination={pagination}
workspacesCount={workspacesCount}
onPaginationChange={setPagination}
keyword={keyword}
onKeywordChange={setKeyword}
selectedFeatures={featureFilters}
onFeaturesChange={setFeatureFilters}
sort={sort}
onSortChange={setSort}
/>
</div>
);
}
export { WorkspacePage as Component };
@@ -0,0 +1,17 @@
import type {
AdminUpdateWorkspaceMutation,
AdminWorkspaceQuery,
AdminWorkspacesQuery,
FeatureType,
} from '@affine/graphql';
export type WorkspaceListItem = AdminWorkspacesQuery['adminWorkspaces'][0];
export type WorkspaceDetail = NonNullable<
AdminWorkspaceQuery['adminWorkspace']
>;
export type WorkspaceMember = WorkspaceDetail['members'][0];
export type WorkspaceUpdateInput =
AdminUpdateWorkspaceMutation['adminUpdateWorkspace'];
export type WorkspaceFeatureFilter = FeatureType[];
@@ -0,0 +1,80 @@
import { useQuery } from '@affine/admin/use-query';
import {
adminWorkspacesCountQuery,
AdminWorkspaceSort,
adminWorkspacesQuery,
FeatureType,
} from '@affine/graphql';
import { useEffect, useMemo, useState } from 'react';
export const useWorkspaceList = (filter?: {
keyword?: string;
features?: FeatureType[];
orderBy?: AdminWorkspaceSort;
}) => {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const filterKey = useMemo(
() =>
`${filter?.keyword ?? ''}-${[...(filter?.features ?? [])]
.sort()
.join(',')}-${filter?.orderBy ?? ''}`,
[filter?.features, filter?.keyword, filter?.orderBy]
);
useEffect(() => {
setPagination(prev => ({ ...prev, pageIndex: 0 }));
}, [filterKey]);
const variables = useMemo(
() => ({
filter: {
first: pagination.pageSize,
skip: pagination.pageIndex * pagination.pageSize,
keyword: filter?.keyword || undefined,
features:
filter?.features && filter.features.length > 0
? filter.features
: undefined,
orderBy: filter?.orderBy,
},
}),
[
filter?.features,
filter?.keyword,
filter?.orderBy,
pagination.pageIndex,
pagination.pageSize,
]
);
const { data: listData } = useQuery(
{
query: adminWorkspacesQuery,
variables,
},
{
keepPreviousData: true,
}
);
const { data: countData } = useQuery(
{
query: adminWorkspacesCountQuery,
variables,
},
{
keepPreviousData: true,
}
);
return {
workspaces: listData?.adminWorkspaces ?? [],
workspacesCount: countData?.adminWorkspacesCount ?? 0,
pagination,
setPagination,
};
};
@@ -0,0 +1,14 @@
export function formatBytes(bytes: number) {
if (!bytes) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const digits = value >= 10 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unitIndex]}`;
}
-4
View File
@@ -8543,10 +8543,6 @@ export function useAFFiNEI18N(): {
* `You are trying to sign in by a different method than you signed up with.` * `You are trying to sign in by a different method than you signed up with.`
*/ */
["error.WRONG_SIGN_IN_METHOD"](): string; ["error.WRONG_SIGN_IN_METHOD"](): string;
/**
* `You don't have early access permission. Visit https://community.affine.pro/c/insider-general/ for more information.`
*/
["error.EARLY_ACCESS_REQUIRED"](): string;
/** /**
* `You are not allowed to sign up.` * `You are not allowed to sign up.`
*/ */
@@ -2103,7 +2103,6 @@
"error.INVALID_PASSWORD_LENGTH": "يجب أن تتراوح كلمة المرور بين {{min}} و {{max}} حرف", "error.INVALID_PASSWORD_LENGTH": "يجب أن تتراوح كلمة المرور بين {{min}} و {{max}} حرف",
"error.PASSWORD_REQUIRED": "كلمة المرور مطلوبة.", "error.PASSWORD_REQUIRED": "كلمة المرور مطلوبة.",
"error.WRONG_SIGN_IN_METHOD": "أنت تحاول تسجيل الدخول بطريقة مختلفة عن تلك التي قمت بالتسجيل باستخدامها.", "error.WRONG_SIGN_IN_METHOD": "أنت تحاول تسجيل الدخول بطريقة مختلفة عن تلك التي قمت بالتسجيل باستخدامها.",
"error.EARLY_ACCESS_REQUIRED": "ليس لديك إذن الوصول المبكر. قم بزيارة https://community.affine.pro/c/insider-general/ للحصول على مزيد من المعلومات.",
"error.SIGN_UP_FORBIDDEN": "لا يُسمح لك بالتسجيل.", "error.SIGN_UP_FORBIDDEN": "لا يُسمح لك بالتسجيل.",
"error.EMAIL_TOKEN_NOT_FOUND": "لم يتم العثور على رمز البريد الإلكتروني المقدم.", "error.EMAIL_TOKEN_NOT_FOUND": "لم يتم العثور على رمز البريد الإلكتروني المقدم.",
"error.INVALID_EMAIL_TOKEN": "تم توفير رمز بريد إلكتروني غير صالح.", "error.INVALID_EMAIL_TOKEN": "تم توفير رمز بريد إلكتروني غير صالح.",
@@ -2054,7 +2054,6 @@
"error.DOC_IS_NOT_PUBLIC": "El document no és públic.", "error.DOC_IS_NOT_PUBLIC": "El document no és públic.",
"error.DOC_NOT_FOUND": "Document {{docId}} sota l'Espai {{spaceId}} no trobat.", "error.DOC_NOT_FOUND": "Document {{docId}} sota l'Espai {{spaceId}} no trobat.",
"error.DOC_UPDATE_BLOCKED": "Document {{docId}} sota Espai {{spaceId}} està bloquejat per a actualitzacions.", "error.DOC_UPDATE_BLOCKED": "Document {{docId}} sota Espai {{spaceId}} està bloquejat per a actualitzacions.",
"error.EARLY_ACCESS_REQUIRED": "No tens permís d'accés anticipat. Visita https://community.affine.pro/c/insider-general/ per a més informació.",
"error.EMAIL_ALREADY_USED": "Aquest correu electrònic ja ha estat registrat.", "error.EMAIL_ALREADY_USED": "Aquest correu electrònic ja ha estat registrat.",
"error.EMAIL_SERVICE_NOT_CONFIGURED": "El servei de correu no està configurat.", "error.EMAIL_SERVICE_NOT_CONFIGURED": "El servei de correu no està configurat.",
"error.EMAIL_TOKEN_NOT_FOUND": "El token de correu electrònic proporcionat no es troba.", "error.EMAIL_TOKEN_NOT_FOUND": "El token de correu electrònic proporcionat no es troba.",
@@ -2126,7 +2126,6 @@
"error.INVALID_PASSWORD_LENGTH": "Das Passwort muss zwischen {{min}} und {{max}} Zeichen lang sein", "error.INVALID_PASSWORD_LENGTH": "Das Passwort muss zwischen {{min}} und {{max}} Zeichen lang sein",
"error.PASSWORD_REQUIRED": "Passwort ist erforderlich.", "error.PASSWORD_REQUIRED": "Passwort ist erforderlich.",
"error.WRONG_SIGN_IN_METHOD": "Du versuchst, dich mit einer anderen Methode anzumelden, als du dich registriert hast.", "error.WRONG_SIGN_IN_METHOD": "Du versuchst, dich mit einer anderen Methode anzumelden, als du dich registriert hast.",
"error.EARLY_ACCESS_REQUIRED": "Du hast keine Berechtigung für den frühen Zugriff. Besuche https://community.affine.pro/c/insider-general/ für weitere Informationen.",
"error.SIGN_UP_FORBIDDEN": "Du darfst dich nicht anmelden.", "error.SIGN_UP_FORBIDDEN": "Du darfst dich nicht anmelden.",
"error.EMAIL_TOKEN_NOT_FOUND": "Der bereitgestellte E-Mail-Token wurde nicht gefunden.", "error.EMAIL_TOKEN_NOT_FOUND": "Der bereitgestellte E-Mail-Token wurde nicht gefunden.",
"error.INVALID_EMAIL_TOKEN": "Ein ungültiger E-Mail-Token wurde bereitgestellt.", "error.INVALID_EMAIL_TOKEN": "Ein ungültiger E-Mail-Token wurde bereitgestellt.",
@@ -2103,7 +2103,6 @@
"error.INVALID_PASSWORD_LENGTH": "Ο κωδικός πρόσβασης πρέπει να είναι μεταξύ {{min}} και {{max}} χαρακτήρων", "error.INVALID_PASSWORD_LENGTH": "Ο κωδικός πρόσβασης πρέπει να είναι μεταξύ {{min}} και {{max}} χαρακτήρων",
"error.PASSWORD_REQUIRED": "Ο κωδικός πρόσβασης είναι απαραίτητος.", "error.PASSWORD_REQUIRED": "Ο κωδικός πρόσβασης είναι απαραίτητος.",
"error.WRONG_SIGN_IN_METHOD": "Προσπαθείτε να συνδεθείτε με διαφορετική μέθοδο από αυτήν που έχετε εγγραφεί.", "error.WRONG_SIGN_IN_METHOD": "Προσπαθείτε να συνδεθείτε με διαφορετική μέθοδο από αυτήν που έχετε εγγραφεί.",
"error.EARLY_ACCESS_REQUIRED": "Δεν έχετε άδεια πρώιμης πρόσβασης. Επισκεφθείτε το https://community.affine.pro/c/insider-general/ για περισσότερες πληροφορίες.",
"error.SIGN_UP_FORBIDDEN": "Δεν επιτρέπεται να εγγραφείτε.", "error.SIGN_UP_FORBIDDEN": "Δεν επιτρέπεται να εγγραφείτε.",
"error.EMAIL_TOKEN_NOT_FOUND": "Δεν βρέθηκε το token ηλεκτρονικού ταχυδρομείου που παρασχέθηκε.", "error.EMAIL_TOKEN_NOT_FOUND": "Δεν βρέθηκε το token ηλεκτρονικού ταχυδρομείου που παρασχέθηκε.",
"error.INVALID_EMAIL_TOKEN": "Παρασχέθηκε μη έγκυρο token ηλεκτρονικού ταχυδρομείου.", "error.INVALID_EMAIL_TOKEN": "Παρασχέθηκε μη έγκυρο token ηλεκτρονικού ταχυδρομείου.",
@@ -2137,7 +2137,6 @@
"error.INVALID_PASSWORD_LENGTH": "Password must be between {{min}} and {{max}} characters", "error.INVALID_PASSWORD_LENGTH": "Password must be between {{min}} and {{max}} characters",
"error.PASSWORD_REQUIRED": "Password is required.", "error.PASSWORD_REQUIRED": "Password is required.",
"error.WRONG_SIGN_IN_METHOD": "You are trying to sign in by a different method than you signed up with.", "error.WRONG_SIGN_IN_METHOD": "You are trying to sign in by a different method than you signed up with.",
"error.EARLY_ACCESS_REQUIRED": "You don't have early access permission. Visit https://community.affine.pro/c/insider-general/ for more information.",
"error.SIGN_UP_FORBIDDEN": "You are not allowed to sign up.", "error.SIGN_UP_FORBIDDEN": "You are not allowed to sign up.",
"error.EMAIL_TOKEN_NOT_FOUND": "The email token provided is not found.", "error.EMAIL_TOKEN_NOT_FOUND": "The email token provided is not found.",
"error.INVALID_EMAIL_TOKEN": "An invalid email token provided.", "error.INVALID_EMAIL_TOKEN": "An invalid email token provided.",
@@ -2103,7 +2103,6 @@
"error.INVALID_PASSWORD_LENGTH": "La contraseña debe tener entre {{min}} y {{max}} caracteres", "error.INVALID_PASSWORD_LENGTH": "La contraseña debe tener entre {{min}} y {{max}} caracteres",
"error.PASSWORD_REQUIRED": "Se requiere contraseña.", "error.PASSWORD_REQUIRED": "Se requiere contraseña.",
"error.WRONG_SIGN_IN_METHOD": "Está intentando iniciar sesión con un método diferente al que utilizó para registrarse.", "error.WRONG_SIGN_IN_METHOD": "Está intentando iniciar sesión con un método diferente al que utilizó para registrarse.",
"error.EARLY_ACCESS_REQUIRED": "No tiene permiso de acceso anticipado. Visite https://community.affine.pro/c/insider-general/ para más información.",
"error.SIGN_UP_FORBIDDEN": "No tiene permitido registrarse.", "error.SIGN_UP_FORBIDDEN": "No tiene permitido registrarse.",
"error.EMAIL_TOKEN_NOT_FOUND": "El token de correo electrónico proporcionado no se encuentra.", "error.EMAIL_TOKEN_NOT_FOUND": "El token de correo electrónico proporcionado no se encuentra.",
"error.INVALID_EMAIL_TOKEN": "Se ha proporcionado un token de correo electrónico inválido.", "error.INVALID_EMAIL_TOKEN": "Se ha proporcionado un token de correo electrónico inválido.",
@@ -2103,7 +2103,6 @@
"error.INVALID_PASSWORD_LENGTH": "رمز عبور باید بین {{min}} و {{max}} کاراکتر باشد", "error.INVALID_PASSWORD_LENGTH": "رمز عبور باید بین {{min}} و {{max}} کاراکتر باشد",
"error.PASSWORD_REQUIRED": "وارد کردن رمز عبور ضروری است.", "error.PASSWORD_REQUIRED": "وارد کردن رمز عبور ضروری است.",
"error.WRONG_SIGN_IN_METHOD": "شما در حال تلاش برای ورود با روشی متفاوت از روشی هستید که با آن ثبت‌نام کرده‌اید.", "error.WRONG_SIGN_IN_METHOD": "شما در حال تلاش برای ورود با روشی متفاوت از روشی هستید که با آن ثبت‌نام کرده‌اید.",
"error.EARLY_ACCESS_REQUIRED": "شما اجازه دسترسی زودهنگام ندارید. برای اطلاعات بیشتر به https://community.affine.pro/c/insider-general/ مراجعه کنید.",
"error.SIGN_UP_FORBIDDEN": "اجازه ثبت نام ندارید.", "error.SIGN_UP_FORBIDDEN": "اجازه ثبت نام ندارید.",
"error.EMAIL_TOKEN_NOT_FOUND": "توکن ایمیل ارائه شده یافت نشد.", "error.EMAIL_TOKEN_NOT_FOUND": "توکن ایمیل ارائه شده یافت نشد.",
"error.INVALID_EMAIL_TOKEN": "یک توکن ایمیل نامعتبر ارائه شده است.", "error.INVALID_EMAIL_TOKEN": "یک توکن ایمیل نامعتبر ارائه شده است.",
@@ -2135,7 +2135,6 @@
"error.INVALID_PASSWORD_LENGTH": "Le mot de passe doit être entre {{min}} et {{max}} caractères", "error.INVALID_PASSWORD_LENGTH": "Le mot de passe doit être entre {{min}} et {{max}} caractères",
"error.PASSWORD_REQUIRED": "Le mot de passe est requis.", "error.PASSWORD_REQUIRED": "Le mot de passe est requis.",
"error.WRONG_SIGN_IN_METHOD": "Vous essayez de vous connecter par une méthode différente de celle que vous avez utilisée pour vous inscrire.", "error.WRONG_SIGN_IN_METHOD": "Vous essayez de vous connecter par une méthode différente de celle que vous avez utilisée pour vous inscrire.",
"error.EARLY_ACCESS_REQUIRED": "Vous n'avez pas l'autorisation d'accès anticipé. Visitez https://community.affine.pro/c/insider-general/ pour plus d'informations.",
"error.SIGN_UP_FORBIDDEN": "Vous n'êtes pas autorisé à vous inscrire.", "error.SIGN_UP_FORBIDDEN": "Vous n'êtes pas autorisé à vous inscrire.",
"error.EMAIL_TOKEN_NOT_FOUND": "Le jeton d'email fourni est introuvable.", "error.EMAIL_TOKEN_NOT_FOUND": "Le jeton d'email fourni est introuvable.",
"error.INVALID_EMAIL_TOKEN": "Un jeton d'email invalide a été fourni.", "error.INVALID_EMAIL_TOKEN": "Un jeton d'email invalide a été fourni.",
@@ -2135,7 +2135,6 @@
"error.INVALID_PASSWORD_LENGTH": "La password deve essere compresa tra {{min}} e {{max}} caratteri", "error.INVALID_PASSWORD_LENGTH": "La password deve essere compresa tra {{min}} e {{max}} caratteri",
"error.PASSWORD_REQUIRED": "La password è obbligatoria.", "error.PASSWORD_REQUIRED": "La password è obbligatoria.",
"error.WRONG_SIGN_IN_METHOD": "Stai cercando di accedere con un metodo diverso da quello con cui ti sei registrato.", "error.WRONG_SIGN_IN_METHOD": "Stai cercando di accedere con un metodo diverso da quello con cui ti sei registrato.",
"error.EARLY_ACCESS_REQUIRED": "Non hai il permesso di accesso anticipato. Visita https://community.affine.pro/c/insider-general/ per ulteriori informazioni.",
"error.SIGN_UP_FORBIDDEN": "Non ti è permesso registrarti.", "error.SIGN_UP_FORBIDDEN": "Non ti è permesso registrarti.",
"error.EMAIL_TOKEN_NOT_FOUND": "Il token email fornito non è stato trovato.", "error.EMAIL_TOKEN_NOT_FOUND": "Il token email fornito non è stato trovato.",
"error.INVALID_EMAIL_TOKEN": "È stato fornito un token email non valido.", "error.INVALID_EMAIL_TOKEN": "È stato fornito un token email non valido.",
@@ -2103,7 +2103,6 @@
"error.INVALID_PASSWORD_LENGTH": "パスワードは{{min}}から{{max}}文字の間である必要があります", "error.INVALID_PASSWORD_LENGTH": "パスワードは{{min}}から{{max}}文字の間である必要があります",
"error.PASSWORD_REQUIRED": "パスワードが必要です。", "error.PASSWORD_REQUIRED": "パスワードが必要です。",
"error.WRONG_SIGN_IN_METHOD": "サインアップ時と異なる方法でサインインしようとしています。", "error.WRONG_SIGN_IN_METHOD": "サインアップ時と異なる方法でサインインしようとしています。",
"error.EARLY_ACCESS_REQUIRED": "早期アクセスの権限がありません。詳細はhttps://community.affine.pro/c/insider-general/をご覧ください。",
"error.SIGN_UP_FORBIDDEN": "サインアップは許可されていません。", "error.SIGN_UP_FORBIDDEN": "サインアップは許可されていません。",
"error.EMAIL_TOKEN_NOT_FOUND": "提供されたメールトークンは見つかりません。", "error.EMAIL_TOKEN_NOT_FOUND": "提供されたメールトークンは見つかりません。",
"error.INVALID_EMAIL_TOKEN": "無効なメールトークンが提供されました。", "error.INVALID_EMAIL_TOKEN": "無効なメールトークンが提供されました。",
@@ -2123,7 +2123,6 @@
"error.INVALID_PASSWORD_LENGTH": "비밀번호는 {{min}}자에서 {{max}}자 사이여야 합니다", "error.INVALID_PASSWORD_LENGTH": "비밀번호는 {{min}}자에서 {{max}}자 사이여야 합니다",
"error.PASSWORD_REQUIRED": "비밀번호가 필요합니다.", "error.PASSWORD_REQUIRED": "비밀번호가 필요합니다.",
"error.WRONG_SIGN_IN_METHOD": "가입한 방법과 다른 방법으로 로그인하려고 합니다.", "error.WRONG_SIGN_IN_METHOD": "가입한 방법과 다른 방법으로 로그인하려고 합니다.",
"error.EARLY_ACCESS_REQUIRED": "얼리 액세스 권한이 없습니다. 자세한 내용은 https://community.affine.pro/c/insider-general/을 방문하세요.",
"error.SIGN_UP_FORBIDDEN": "회원가입이 허용되지 않습니다.", "error.SIGN_UP_FORBIDDEN": "회원가입이 허용되지 않습니다.",
"error.EMAIL_TOKEN_NOT_FOUND": "제공된 이메일 토큰을 찾을 수 없습니다.", "error.EMAIL_TOKEN_NOT_FOUND": "제공된 이메일 토큰을 찾을 수 없습니다.",
"error.INVALID_EMAIL_TOKEN": "잘못된 이메일 토큰이 제공되었습니다.", "error.INVALID_EMAIL_TOKEN": "잘못된 이메일 토큰이 제공되었습니다.",
@@ -2136,7 +2136,6 @@
"error.INVALID_PASSWORD_LENGTH": "Hasło musi mieć od {{min}} do {{max}} znaków", "error.INVALID_PASSWORD_LENGTH": "Hasło musi mieć od {{min}} do {{max}} znaków",
"error.PASSWORD_REQUIRED": "Hasło jest wymagane.", "error.PASSWORD_REQUIRED": "Hasło jest wymagane.",
"error.WRONG_SIGN_IN_METHOD": "Próbujesz się zalogować inną metodą, niż użyta przy rejestracji.", "error.WRONG_SIGN_IN_METHOD": "Próbujesz się zalogować inną metodą, niż użyta przy rejestracji.",
"error.EARLY_ACCESS_REQUIRED": "Nie masz uprawnień do wcześniejszego dostępu. Odwiedź https://community.affine.pro/c/insider-general/ po więcej informacji.",
"error.SIGN_UP_FORBIDDEN": "Nie masz zezwolenia na rejestrację.", "error.SIGN_UP_FORBIDDEN": "Nie masz zezwolenia na rejestrację.",
"error.EMAIL_TOKEN_NOT_FOUND": "Nie znaleziono podanego tokena e-mail.", "error.EMAIL_TOKEN_NOT_FOUND": "Nie znaleziono podanego tokena e-mail.",
"error.INVALID_EMAIL_TOKEN": "Podano nieprawidłowy token e-mail.", "error.INVALID_EMAIL_TOKEN": "Podano nieprawidłowy token e-mail.",
@@ -2103,7 +2103,6 @@
"error.INVALID_PASSWORD_LENGTH": "A senha deve ter entre {{min}} e {{max}} caracteres", "error.INVALID_PASSWORD_LENGTH": "A senha deve ter entre {{min}} e {{max}} caracteres",
"error.PASSWORD_REQUIRED": "A senha é obrigatória.", "error.PASSWORD_REQUIRED": "A senha é obrigatória.",
"error.WRONG_SIGN_IN_METHOD": "Você está tentando entrar por um método diferente do qual foi registrado.", "error.WRONG_SIGN_IN_METHOD": "Você está tentando entrar por um método diferente do qual foi registrado.",
"error.EARLY_ACCESS_REQUIRED": "Você não tem permissão para acesso antecipado. Visite https://community.affine.pro/c/insider-general/ para mais informações.",
"error.SIGN_UP_FORBIDDEN": "Você não tem permissão para se inscrever.", "error.SIGN_UP_FORBIDDEN": "Você não tem permissão para se inscrever.",
"error.EMAIL_TOKEN_NOT_FOUND": "Token de email fornecido não encontrado.", "error.EMAIL_TOKEN_NOT_FOUND": "Token de email fornecido não encontrado.",
"error.INVALID_EMAIL_TOKEN": "Um token de email inválido foi fornecido.", "error.INVALID_EMAIL_TOKEN": "Um token de email inválido foi fornecido.",
@@ -2134,7 +2134,6 @@
"error.INVALID_PASSWORD_LENGTH": "Пароль должен быть длиной от {{min}} до {{max}} символов", "error.INVALID_PASSWORD_LENGTH": "Пароль должен быть длиной от {{min}} до {{max}} символов",
"error.PASSWORD_REQUIRED": "Требуется пароль.", "error.PASSWORD_REQUIRED": "Требуется пароль.",
"error.WRONG_SIGN_IN_METHOD": "Вы пытаетесь войти другим способом, чем регистрировались.", "error.WRONG_SIGN_IN_METHOD": "Вы пытаетесь войти другим способом, чем регистрировались.",
"error.EARLY_ACCESS_REQUIRED": "У вас нет разрешения на предварительный доступ. Посетите https://community.affine.pro/c/insider-general/ для получения дополнительной информации.",
"error.SIGN_UP_FORBIDDEN": "Вам не разрешено регистрироваться.", "error.SIGN_UP_FORBIDDEN": "Вам не разрешено регистрироваться.",
"error.EMAIL_TOKEN_NOT_FOUND": "Предоставленный токен электронной почты не найден.", "error.EMAIL_TOKEN_NOT_FOUND": "Предоставленный токен электронной почты не найден.",
"error.INVALID_EMAIL_TOKEN": "Предоставлен неверный токен электронной почты.", "error.INVALID_EMAIL_TOKEN": "Предоставлен неверный токен электронной почты.",
@@ -2110,7 +2110,6 @@
"error.INVALID_PASSWORD_LENGTH": "Lösenord måste vara mellan {{min}} och {{max}} tecken", "error.INVALID_PASSWORD_LENGTH": "Lösenord måste vara mellan {{min}} och {{max}} tecken",
"error.PASSWORD_REQUIRED": "Lösenord krävs.", "error.PASSWORD_REQUIRED": "Lösenord krävs.",
"error.WRONG_SIGN_IN_METHOD": "Du försöker logga in med en annan metod än den du registrerade dig med.", "error.WRONG_SIGN_IN_METHOD": "Du försöker logga in med en annan metod än den du registrerade dig med.",
"error.EARLY_ACCESS_REQUIRED": "Du har inte behörighet för tidig åtkomst. Besök https://community.affine.pro/c/insider-general/ för mer information.",
"error.SIGN_UP_FORBIDDEN": "Du får inte registrera dig.", "error.SIGN_UP_FORBIDDEN": "Du får inte registrera dig.",
"error.EMAIL_TOKEN_NOT_FOUND": "Det angivna e-postmyntet hittades inte.", "error.EMAIL_TOKEN_NOT_FOUND": "Det angivna e-postmyntet hittades inte.",
"error.INVALID_EMAIL_TOKEN": "Ett ogiltigt e-postmynt tillhandahölls.", "error.INVALID_EMAIL_TOKEN": "Ett ogiltigt e-postmynt tillhandahölls.",
@@ -2103,7 +2103,6 @@
"error.INVALID_PASSWORD_LENGTH": "Пароль повинен бути від {{min}} до {{max}} символів", "error.INVALID_PASSWORD_LENGTH": "Пароль повинен бути від {{min}} до {{max}} символів",
"error.PASSWORD_REQUIRED": "Пароль є обов'язковим.", "error.PASSWORD_REQUIRED": "Пароль є обов'язковим.",
"error.WRONG_SIGN_IN_METHOD": "Ви намагаєтеся увійти іншим способом, ніж реєструвалися.", "error.WRONG_SIGN_IN_METHOD": "Ви намагаєтеся увійти іншим способом, ніж реєструвалися.",
"error.EARLY_ACCESS_REQUIRED": "Ви не маєте дозволу на ранній доступ. Відвідайте https://community.affine.pro/c/insider-general/ для отримання додаткової інформації.",
"error.SIGN_UP_FORBIDDEN": "Вам не дозволено реєструватися.", "error.SIGN_UP_FORBIDDEN": "Вам не дозволено реєструватися.",
"error.EMAIL_TOKEN_NOT_FOUND": "Токен електронної пошти не знайдено.", "error.EMAIL_TOKEN_NOT_FOUND": "Токен електронної пошти не знайдено.",
"error.INVALID_EMAIL_TOKEN": "Надано недійсний токен електронної пошти.", "error.INVALID_EMAIL_TOKEN": "Надано недійсний токен електронної пошти.",
@@ -2135,7 +2135,6 @@
"error.INVALID_PASSWORD_LENGTH": "密码长度必须在{{min}}到{{max}}个字符之间", "error.INVALID_PASSWORD_LENGTH": "密码长度必须在{{min}}到{{max}}个字符之间",
"error.PASSWORD_REQUIRED": "密码为必填项。", "error.PASSWORD_REQUIRED": "密码为必填项。",
"error.WRONG_SIGN_IN_METHOD": "您正在尝试使用与注册时不同的方法登录。", "error.WRONG_SIGN_IN_METHOD": "您正在尝试使用与注册时不同的方法登录。",
"error.EARLY_ACCESS_REQUIRED": "您没有提前访问权限。访问 https://community.affine.pro/c/insider-general/ 获取更多信息。",
"error.SIGN_UP_FORBIDDEN": "您不被允许注册。", "error.SIGN_UP_FORBIDDEN": "您不被允许注册。",
"error.EMAIL_TOKEN_NOT_FOUND": "提供的电子邮件令牌未找到。", "error.EMAIL_TOKEN_NOT_FOUND": "提供的电子邮件令牌未找到。",
"error.INVALID_EMAIL_TOKEN": "提供了无效的电子邮件令牌。", "error.INVALID_EMAIL_TOKEN": "提供了无效的电子邮件令牌。",
@@ -2103,7 +2103,6 @@
"error.INVALID_PASSWORD_LENGTH": "密碼長度必須在{{min}}到{{max}}個字符之間", "error.INVALID_PASSWORD_LENGTH": "密碼長度必須在{{min}}到{{max}}個字符之間",
"error.PASSWORD_REQUIRED": "密碼為必填項。", "error.PASSWORD_REQUIRED": "密碼為必填項。",
"error.WRONG_SIGN_IN_METHOD": "您正在嘗試使用與註冊時不同的方法登錄。", "error.WRONG_SIGN_IN_METHOD": "您正在嘗試使用與註冊時不同的方法登錄。",
"error.EARLY_ACCESS_REQUIRED": "您沒有提前訪問權限。訪問 https://community.affine.pro/c/insider-general/ 獲取更多信息。",
"error.SIGN_UP_FORBIDDEN": "您不被允許註冊。", "error.SIGN_UP_FORBIDDEN": "您不被允許註冊。",
"error.EMAIL_TOKEN_NOT_FOUND": "提供的電子郵件令牌未找到。", "error.EMAIL_TOKEN_NOT_FOUND": "提供的電子郵件令牌未找到。",
"error.INVALID_EMAIL_TOKEN": "提供了無效的電子郵件令牌。", "error.INVALID_EMAIL_TOKEN": "提供了無效的電子郵件令牌。",
+3
View File
@@ -12,6 +12,7 @@ export const ROUTES = {
auth: '/admin/auth', auth: '/admin/auth',
setup: '/admin/setup', setup: '/admin/setup',
accounts: '/admin/accounts', accounts: '/admin/accounts',
workspaces: '/admin/workspaces',
ai: '/admin/ai', ai: '/admin/ai',
settings: { index: '/admin/settings', module: '/admin/settings/:module' }, settings: { index: '/admin/settings', module: '/admin/settings/:module' },
about: '/admin/about', about: '/admin/about',
@@ -28,6 +29,7 @@ export const RELATIVE_ROUTES = {
auth: 'auth', auth: 'auth',
setup: 'setup', setup: 'setup',
accounts: 'accounts', accounts: 'accounts',
workspaces: 'workspaces',
ai: 'ai', ai: 'ai',
settings: { index: 'settings', module: ':module' }, settings: { index: 'settings', module: ':module' },
about: 'about', about: 'about',
@@ -42,6 +44,7 @@ const admin = () => '/admin';
admin.auth = () => '/admin/auth'; admin.auth = () => '/admin/auth';
admin.setup = () => '/admin/setup'; admin.setup = () => '/admin/setup';
admin.accounts = () => '/admin/accounts'; admin.accounts = () => '/admin/accounts';
admin.workspaces = () => '/admin/workspaces';
admin.ai = () => '/admin/ai'; admin.ai = () => '/admin/ai';
const admin_settings = () => '/admin/settings'; const admin_settings = () => '/admin/settings';
admin_settings.module = (params: { module: string }) => admin_settings.module = (params: { module: string }) =>
-23
View File
@@ -113,13 +113,6 @@ export async function createRandomUser(): Promise<{
password: '123456', password: '123456',
}; };
const result = await runPrisma(async client => { const result = await runPrisma(async client => {
const featureId = await client.feature
.findFirst({
where: { name: 'free_plan_v1' },
select: { id: true },
})
.then(f => f!.id);
await client.user.create({ await client.user.create({
data: { data: {
...user, ...user,
@@ -129,7 +122,6 @@ export async function createRandomUser(): Promise<{
create: { create: {
reason: 'created by test case', reason: 'created by test case',
activated: true, activated: true,
featureId,
name: 'free_plan_v1', name: 'free_plan_v1',
type: 1, type: 1,
}, },
@@ -189,19 +181,6 @@ export async function createRandomAIUser(): Promise<{
password: '123456', password: '123456',
}; };
const result = await runPrisma(async client => { const result = await runPrisma(async client => {
const freeFeatureId = await client.feature
.findFirst({
where: { name: 'free_plan_v1' },
select: { id: true },
})
.then(f => f!.id);
const aiFeatureId = await client.feature
.findFirst({
where: { name: 'unlimited_copilot' },
select: { id: true },
})
.then(f => f!.id);
await client.user.create({ await client.user.create({
data: { data: {
...user, ...user,
@@ -212,14 +191,12 @@ export async function createRandomAIUser(): Promise<{
{ {
reason: 'created by test case', reason: 'created by test case',
activated: true, activated: true,
featureId: freeFeatureId,
name: 'free_plan_v1', name: 'free_plan_v1',
type: 1, type: 1,
}, },
{ {
reason: 'created by test case', reason: 'created by test case',
activated: true, activated: true,
featureId: aiFeatureId,
name: 'unlimited_copilot', name: 'unlimited_copilot',
type: 0, type: 0,
}, },
+18 -2
View File
@@ -510,6 +510,7 @@ export function createNodeTargetConfig(
pkg: Package, pkg: Package,
entry: string entry: string
): Omit<webpack.Configuration, 'name'> & { name: string } { ): Omit<webpack.Configuration, 'name'> & { name: string } {
const buildConfig = getBuildConfigFromEnv(pkg);
return { return {
name: entry, name: entry,
context: ProjectRoot.value, context: ProjectRoot.value,
@@ -541,7 +542,7 @@ export function createNodeTargetConfig(
}, },
externalsPresets: { node: true }, externalsPresets: { node: true },
node: { __dirname: false, __filename: false }, node: { __dirname: false, __filename: false },
mode: 'none', mode: buildConfig.debug ? 'development' : 'production',
devtool: 'source-map', devtool: 'source-map',
resolve: { resolve: {
symlinks: true, symlinks: true,
@@ -617,7 +618,22 @@ export function createNodeTargetConfig(
}), }),
]), ]),
stats: { errorDetails: true }, stats: { errorDetails: true },
optimization: { nodeEnv: false }, optimization: {
nodeEnv: false,
minimize: !buildConfig.debug,
minimizer: [
new TerserPlugin({
minify: TerserPlugin.swcMinify,
parallel: true,
extractComments: true,
terserOptions: {
ecma: 2020,
compress: { unused: true },
mangle: { keep_classnames: true },
},
}),
],
},
performance: { hints: false }, performance: { hints: false },
ignoreWarnings: [/^(?!CriticalDependenciesWarning$)/], ignoreWarnings: [/^(?!CriticalDependenciesWarning$)/],
}; };