From 95a5e941e7a24d70cc3f3effe64062f0c8834c58 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Tue, 30 Dec 2025 05:22:54 +0800 Subject: [PATCH] feat: improve admin panel (#14180) --- .docker/selfhost/schema.json | 5 - .../migration.sql | 28 ++ packages/backend/server/schema.prisma | 28 -- .../__tests__/mocks/team-workspace.mock.ts | 13 - .../server/src/__tests__/mocks/user.mock.ts | 13 - .../src/__tests__/models/feature.spec.ts | 94 ----- .../server/src/__tests__/models/user.spec.ts | 2 +- .../server/src/__tests__/utils/utils.ts | 4 - packages/backend/server/src/base/error/def.ts | 4 - .../server/src/base/error/errors.gen.ts | 7 - .../server/src/core/auth/controller.ts | 4 +- .../backend/server/src/core/auth/service.ts | 9 +- .../backend/server/src/core/config/config.ts | 5 - .../server/src/core/config/resolver.ts | 13 +- .../backend/server/src/core/config/types.ts | 7 - .../server/src/core/features/service.ts | 44 +-- .../backend/server/src/core/user/resolver.ts | 30 +- .../server/src/core/workspaces/index.ts | 2 + .../src/core/workspaces/resolvers/admin.ts | 305 +++++++++++++++ .../src/core/workspaces/resolvers/index.ts | 1 + .../data/migrations/0001-refresh-features.ts | 16 - .../1738590347632-feature-redundant.ts | 57 --- .../server/src/data/migrations/index.ts | 2 - .../server/src/models/common/feature.ts | 147 ++++---- packages/backend/server/src/models/feature.ts | 92 +---- .../backend/server/src/models/user-feature.ts | 4 +- packages/backend/server/src/models/user.ts | 82 +++- .../server/src/models/workspace-feature.ts | 4 +- .../backend/server/src/models/workspace.ts | 201 +++++++++- packages/backend/server/src/schema.gql | 80 +++- .../src/graphql/admin/admin-server-config.gql | 1 + .../graphql/admin/admin-update-workspace.gql | 25 ++ .../src/graphql/admin/admin-workspace.gql | 38 ++ .../src/graphql/admin/admin-workspaces.gql | 29 ++ .../graphql/src/graphql/admin/list-users.gql | 2 +- packages/common/graphql/src/graphql/index.ts | 109 +++++- packages/common/graphql/src/schema.ts | 243 +++++++++++- packages/frontend/admin/src/app.tsx | 7 + .../src/components/shared/confirm-dialog.tsx | 66 ++++ .../shared}/data-table-pagination.tsx | 2 +- .../src/components/shared/data-table.tsx | 166 ++++++++ .../src/components/shared/discard-changes.tsx | 28 ++ .../shared/feature-filter-popover.tsx | 91 +++++ .../components/shared/feature-toggle-list.tsx | 91 +++++ .../components/shared/type-confirm-dialog.tsx | 98 +++++ packages/frontend/admin/src/config.json | 4 - .../admin/src/hooks/use-debounced-value.ts | 17 + .../modules/accounts/components/columns.tsx | 36 +- .../components/data-table-row-actions.tsx | 47 +-- .../components/data-table-toolbar.tsx | 151 ++++---- .../accounts/components/data-table.tsx | 182 ++------- .../accounts/components/delete-account.tsx | 78 +--- .../accounts/components/disable-account.tsx | 79 +--- .../accounts/components/discard-changes.tsx | 44 --- .../accounts/components/enable-account.tsx | 48 +-- .../modules/accounts/components/user-form.tsx | 94 +++-- .../admin/src/modules/accounts/index.tsx | 26 +- .../src/modules/accounts/use-user-list.ts | 43 ++- .../admin/src/modules/ai/discard-changes.tsx | 44 --- .../frontend/admin/src/modules/ai/prompts.tsx | 2 +- .../frontend/admin/src/modules/layout.tsx | 36 +- .../frontend/admin/src/modules/nav/nav.tsx | 9 +- .../admin/src/modules/panel/context.ts | 9 +- .../modules/workspaces/components/columns.tsx | 172 +++++++++ .../components/data-table-row-actions.tsx | 76 ++++ .../components/data-table-toolbar.tsx | 121 ++++++ .../workspaces/components/data-table.tsx | 61 +++ .../workspaces/components/workspace-panel.tsx | 357 ++++++++++++++++++ .../admin/src/modules/workspaces/index.tsx | 46 +++ .../admin/src/modules/workspaces/schema.ts | 17 + .../modules/workspaces/use-workspace-list.ts | 80 ++++ .../admin/src/modules/workspaces/utils.ts | 14 + packages/frontend/i18n/src/i18n.gen.ts | 4 - packages/frontend/i18n/src/resources/ar.json | 1 - packages/frontend/i18n/src/resources/ca.json | 1 - packages/frontend/i18n/src/resources/de.json | 1 - .../frontend/i18n/src/resources/el-GR.json | 1 - packages/frontend/i18n/src/resources/en.json | 1 - packages/frontend/i18n/src/resources/es.json | 1 - packages/frontend/i18n/src/resources/fa.json | 1 - packages/frontend/i18n/src/resources/fr.json | 1 - .../frontend/i18n/src/resources/it-IT.json | 1 - packages/frontend/i18n/src/resources/ja.json | 1 - packages/frontend/i18n/src/resources/ko.json | 1 - packages/frontend/i18n/src/resources/pl.json | 1 - .../frontend/i18n/src/resources/pt-BR.json | 1 - packages/frontend/i18n/src/resources/ru.json | 1 - .../frontend/i18n/src/resources/sv-SE.json | 1 - packages/frontend/i18n/src/resources/uk.json | 1 - .../frontend/i18n/src/resources/zh-Hans.json | 1 - .../frontend/i18n/src/resources/zh-Hant.json | 1 - packages/frontend/routes/src/routes.ts | 3 + tests/kit/src/utils/cloud.ts | 23 -- tools/cli/src/webpack/index.ts | 20 +- 94 files changed, 3146 insertions(+), 1114 deletions(-) create mode 100644 packages/backend/server/migrations/20251229174000_update_feature/migration.sql create mode 100644 packages/backend/server/src/core/workspaces/resolvers/admin.ts delete mode 100644 packages/backend/server/src/data/migrations/0001-refresh-features.ts delete mode 100644 packages/backend/server/src/data/migrations/1738590347632-feature-redundant.ts create mode 100644 packages/common/graphql/src/graphql/admin/admin-update-workspace.gql create mode 100644 packages/common/graphql/src/graphql/admin/admin-workspace.gql create mode 100644 packages/common/graphql/src/graphql/admin/admin-workspaces.gql create mode 100644 packages/frontend/admin/src/components/shared/confirm-dialog.tsx rename packages/frontend/admin/src/{modules/accounts/components => components/shared}/data-table-pagination.tsx (98%) create mode 100644 packages/frontend/admin/src/components/shared/data-table.tsx create mode 100644 packages/frontend/admin/src/components/shared/discard-changes.tsx create mode 100644 packages/frontend/admin/src/components/shared/feature-filter-popover.tsx create mode 100644 packages/frontend/admin/src/components/shared/feature-toggle-list.tsx create mode 100644 packages/frontend/admin/src/components/shared/type-confirm-dialog.tsx create mode 100644 packages/frontend/admin/src/hooks/use-debounced-value.ts delete mode 100644 packages/frontend/admin/src/modules/accounts/components/discard-changes.tsx delete mode 100644 packages/frontend/admin/src/modules/ai/discard-changes.tsx create mode 100644 packages/frontend/admin/src/modules/workspaces/components/columns.tsx create mode 100644 packages/frontend/admin/src/modules/workspaces/components/data-table-row-actions.tsx create mode 100644 packages/frontend/admin/src/modules/workspaces/components/data-table-toolbar.tsx create mode 100644 packages/frontend/admin/src/modules/workspaces/components/data-table.tsx create mode 100644 packages/frontend/admin/src/modules/workspaces/components/workspace-panel.tsx create mode 100644 packages/frontend/admin/src/modules/workspaces/index.tsx create mode 100644 packages/frontend/admin/src/modules/workspaces/schema.ts create mode 100644 packages/frontend/admin/src/modules/workspaces/use-workspace-list.ts create mode 100644 packages/frontend/admin/src/modules/workspaces/utils.ts diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index 8366e071f..7c25d6184 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -611,11 +611,6 @@ "type": "object", "description": "Configuration for flags module", "properties": { - "earlyAccessControl": { - "type": "boolean", - "description": "Only allow users with early access features to access the app\n@default false", - "default": false - }, "allowGuestDemoWorkspace": { "type": "boolean", "description": "Whether allow guest users to create demo workspaces.\n@default true", diff --git a/packages/backend/server/migrations/20251229174000_update_feature/migration.sql b/packages/backend/server/migrations/20251229174000_update_feature/migration.sql new file mode 100644 index 000000000..b57055cbb --- /dev/null +++ b/packages/backend/server/migrations/20251229174000_update_feature/migration.sql @@ -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"; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 25d2569ff..0a4c55cc0 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -225,32 +225,9 @@ model WorkspaceDocUserRole { @@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 { id Int @id @default(autoincrement()) 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. // so it's safe to assert it a non-null value. name String @default("") @map("name") @db.VarChar @@ -261,19 +238,16 @@ model UserFeature { expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) activated Boolean @default(false) - feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([name]) - @@index([featureId]) @@map("user_features") } model WorkspaceFeature { id Int @id @default(autoincrement()) 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. // so it's safe to assert it a non-null value. name String @default("") @map("name") @db.VarChar @@ -286,12 +260,10 @@ model WorkspaceFeature { activated Boolean @default(false) 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) @@index([workspaceId]) @@index([name]) - @@index([featureId]) @@map("workspace_features") } diff --git a/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts b/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts index 338c0825d..ad7b79c68 100644 --- a/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts @@ -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({ data: { workspaceId: id, - featureId: feature.id, reason: 'test', activated: true, name: Feature.TeamPlan, diff --git a/packages/backend/server/src/__tests__/mocks/user.mock.ts b/packages/backend/server/src/__tests__/mocks/user.mock.ts index c75af8f77..195dcc62a 100644 --- a/packages/backend/server/src/__tests__/mocks/user.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/user.mock.ts @@ -27,23 +27,10 @@ export class MockUser extends Mocker { }); 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]; await this.db.userFeature.create({ data: { userId: user.id, - featureId: featureRecord.id, name: feature, type: config.type, reason: 'test', diff --git a/packages/backend/server/src/__tests__/models/feature.spec.ts b/packages/backend/server/src/__tests__/models/feature.spec.ts index 8137ea9b5..16a04a1a3 100644 --- a/packages/backend/server/src/__tests__/models/feature.spec.ts +++ b/packages/backend/server/src/__tests__/models/feature.spec.ts @@ -1,6 +1,5 @@ import ava, { TestFn } from 'ava'; -import { FeatureType } from '../../models'; import { FeatureModel } from '../../models/feature'; 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', }); }); - -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', - } - ); -}); diff --git a/packages/backend/server/src/__tests__/models/user.spec.ts b/packages/backend/server/src/__tests__/models/user.spec.ts index 5b5cd5fc9..1f8707d5d 100644 --- a/packages/backend/server/src/__tests__/models/user.spec.ts +++ b/packages/backend/server/src/__tests__/models/user.spec.ts @@ -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.deepEqual( users.map(user => user.email), diff --git a/packages/backend/server/src/__tests__/utils/utils.ts b/packages/backend/server/src/__tests__/utils/utils.ts index efd00699a..adbe1353f 100644 --- a/packages/backend/server/src/__tests__/utils/utils.ts +++ b/packages/backend/server/src/__tests__/utils/utils.ts @@ -1,10 +1,7 @@ import { INestApplicationContext, LogLevel } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; import { PrismaClient } from '@prisma/client'; import whywhywhy from 'why-is-node-running'; -import { RefreshFeatures0001 } from '../../data/migrations/0001-refresh-features'; - export const TEST_LOG_LEVEL: LogLevel = (process.env.TEST_LOG_LEVEL as LogLevel) ?? 'fatal'; @@ -27,7 +24,6 @@ async function flushDB(client: PrismaClient) { export async function initTestingDB(context: INestApplicationContext) { const db = context.get(PrismaClient, { strict: false }); await flushDB(db); - await RefreshFeatures0001.up(db, context.get(ModuleRef)); } export async function sleep(ms: number) { diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index bc34c4994..3e1b3504e 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -375,10 +375,6 @@ export const USER_FRIENDLY_ERRORS = { message: '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: { type: 'action_forbidden', message: `You are not allowed to sign up.`, diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index 88fbe46e4..d927e870b 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -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 { constructor(message?: string) { super('action_forbidden', 'sign_up_forbidden', message); @@ -1146,7 +1140,6 @@ export enum ErrorNames { INVALID_PASSWORD_LENGTH, PASSWORD_REQUIRED, WRONG_SIGN_IN_METHOD, - EARLY_ACCESS_REQUIRED, SIGN_UP_FORBIDDEN, EMAIL_TOKEN_NOT_FOUND, INVALID_EMAIL_TOKEN, diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index 0df276c9a..90d3e0a5e 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -15,10 +15,10 @@ import { import type { Request, Response } from 'express'; import { + ActionForbidden, Cache, Config, CryptoHelper, - EarlyAccessRequired, EmailTokenNotFound, InvalidAuthState, InvalidEmail, @@ -120,7 +120,7 @@ export class AuthController { validators.assertValidEmail(credential.email); const canSignIn = await this.auth.canSignIn(credential.email); if (!canSignIn) { - throw new EarlyAccessRequired(); + throw new ActionForbidden(); } if (credential.password) { diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 90ae50421..eb12dbd9d 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -4,7 +4,6 @@ import { assign, pick } from 'lodash-es'; import { Config, SignUpForbidden } from '../../base'; import { Models, type User, type UserSession } from '../../models'; -import { FeatureService } from '../features'; import { Mailer } from '../mail/mailer'; import { createDevUsers } from './dev'; import type { CurrentUser } from './session'; @@ -44,8 +43,7 @@ export class AuthService implements OnApplicationBootstrap { constructor( private readonly config: Config, private readonly models: Models, - private readonly mailer: Mailer, - private readonly feature: FeatureService + private readonly mailer: Mailer ) {} async onApplicationBootstrap() { @@ -54,8 +52,9 @@ export class AuthService implements OnApplicationBootstrap { } } - async canSignIn(email: string) { - return await this.feature.canEarlyAccess(email); + async canSignIn(_email: string) { + // may add more sign-in check later + return true; } /** diff --git a/packages/backend/server/src/core/config/config.ts b/packages/backend/server/src/core/config/config.ts index 8ecdbef68..166e408d7 100644 --- a/packages/backend/server/src/core/config/config.ts +++ b/packages/backend/server/src/core/config/config.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { defineModuleConfig } from '../../base'; export interface ServerFlags { - earlyAccessControl: boolean; allowGuestDemoWorkspace: boolean; } @@ -72,10 +71,6 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi }); defineModuleConfig('flags', { - earlyAccessControl: { - desc: 'Only allow users with early access features to access the app', - default: false, - }, allowGuestDemoWorkspace: { desc: 'Whether allow guest users to create demo workspaces.', default: true, diff --git a/packages/backend/server/src/core/config/resolver.ts b/packages/backend/server/src/core/config/resolver.ts index c100c90ef..003973b72 100644 --- a/packages/backend/server/src/core/config/resolver.ts +++ b/packages/backend/server/src/core/config/resolver.ts @@ -14,7 +14,7 @@ import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars'; import { Config, URLHelper } from '../../base'; import { Namespace } from '../../env'; -import { Feature } from '../../models'; +import { Feature, type WorkspaceFeatureName } from '../../models'; import { CurrentUser, Public } from '../auth'; import { Admin } from '../common'; import { AvailableUserFeatureConfig } from '../features'; @@ -75,7 +75,7 @@ export class ServerConfigResolver { name: this.config.server.name ?? (env.selfhosted - ? 'AFFiNE Selfhosted Cloud' + ? 'AFFiNE SelfHosted Cloud' : env.namespaces.canary ? 'AFFiNE Canary Cloud' : env.namespaces.beta @@ -85,8 +85,6 @@ export class ServerConfigResolver { baseUrl: this.url.requestBaseUrl, type: env.DEPLOYMENT_TYPE, features: this.server.features, - // TODO(@fengmk2): remove this field after the feature 0.25.0 is released - allowGuestDemoWorkspace: this.config.flags.allowGuestDemoWorkspace, }; } @@ -170,6 +168,13 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig { override availableUserFeatures() { return super.availableUserFeatures(); } + + @ResolveField(() => [Feature], { + description: 'Workspace features available for admin configuration', + }) + availableWorkspaceFeatures(): WorkspaceFeatureName[] { + return ['unlimited_workspace', 'team_plan_v1']; + } } @InputType() diff --git a/packages/backend/server/src/core/config/types.ts b/packages/backend/server/src/core/config/types.ts index 69ad7c448..15c0f97c5 100644 --- a/packages/backend/server/src/core/config/types.ts +++ b/packages/backend/server/src/core/config/types.ts @@ -40,11 +40,4 @@ export class ServerConfigType { @Field(() => [ServerFeature], { description: 'enabled server features' }) features!: ServerFeature[]; - - @Field(() => Boolean, { - description: 'Whether allow guest users to create demo workspaces.', - deprecationReason: - 'This field is deprecated, please use `features` instead. Will be removed in 0.25.0', - }) - allowGuestDemoWorkspace!: boolean; } diff --git a/packages/backend/server/src/core/features/service.ts b/packages/backend/server/src/core/features/service.ts index 2452a52c0..97bebb0b3 100644 --- a/packages/backend/server/src/core/features/service.ts +++ b/packages/backend/server/src/core/features/service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Config } from '../../base'; import { Models } from '../../models'; const STAFF = ['@toeverything.info', '@affine.pro']; @@ -14,10 +13,7 @@ export enum EarlyAccessType { export class FeatureService { protected logger = new Logger(FeatureService.name); - constructor( - private readonly config: Config, - private readonly models: Models - ) {} + constructor(private readonly models: Models) {} // ======== Admin ======== isStaff(email: string) { @@ -38,27 +34,6 @@ export class FeatureService { } // ======== Early Access ======== - async addEarlyAccess( - userId: string, - type: EarlyAccessType = EarlyAccessType.App - ) { - return this.models.userFeature.add( - userId, - type === EarlyAccessType.App ? 'early_access' : 'ai_early_access', - 'Early access user' - ); - } - - async removeEarlyAccess( - userId: string, - type: EarlyAccessType = EarlyAccessType.App - ) { - return this.models.userFeature.remove( - userId, - type === EarlyAccessType.App ? 'early_access' : 'ai_early_access' - ); - } - async isEarlyAccessUser( userId: string, type: EarlyAccessType = EarlyAccessType.App @@ -68,21 +43,4 @@ export class FeatureService { type === EarlyAccessType.App ? 'early_access' : 'ai_early_access' ); } - - async canEarlyAccess( - email: string, - type: EarlyAccessType = EarlyAccessType.App - ) { - const earlyAccessControlEnabled = this.config.flags.earlyAccessControl; - - if (earlyAccessControlEnabled && !this.isStaff(email)) { - const user = await this.models.user.getUserByEmail(email); - if (!user) { - return false; - } - return this.isEarlyAccessUser(user.id, type); - } else { - return true; - } - } } diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index 8bee67565..1e53585fc 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -22,7 +22,12 @@ import { Throttle, UserNotFound, } from '../../base'; -import { Models, UserSettingsSchema } from '../../models'; +import { + Feature, + Models, + UserFeatureName, + UserSettingsSchema, +} from '../../models'; import { Public } from '../auth/guard'; import { sessionUser } from '../auth/service'; import { CurrentUser } from '../auth/session'; @@ -194,6 +199,12 @@ class ListUserInput { @Field(() => Int, { nullable: true, defaultValue: 20 }) first!: number; + + @Field(() => String, { nullable: true }) + keyword?: string; + + @Field(() => [Feature], { nullable: true }) + features?: Feature[]; } @InputType() @@ -242,8 +253,14 @@ export class UserManagementResolver { @Query(() => Int, { description: 'Get users count', }) - async usersCount(): Promise { - return this.db.user.count(); + async usersCount( + @Args({ name: 'filter', type: () => ListUserInput, nullable: true }) + input?: ListUserInput + ): Promise { + return this.models.user.count({ + keyword: input?.keyword ?? null, + features: (input?.features as UserFeatureName[]) ?? null, + }); } @Query(() => [UserType], { @@ -252,7 +269,12 @@ export class UserManagementResolver { async users( @Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput ): Promise { - 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); } diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index 98e8ce0f3..2f7f3c5b6 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -19,6 +19,7 @@ import { WorkspaceMemberResolver, WorkspaceResolver, } from './resolvers'; +import { AdminWorkspaceResolver } from './resolvers/admin'; import { WorkspaceService } from './service'; @Module({ @@ -43,6 +44,7 @@ import { WorkspaceService } from './service'; WorkspaceBlobResolver, WorkspaceService, WorkspaceEvents, + AdminWorkspaceResolver, ], exports: [WorkspaceService], }) diff --git a/packages/backend/server/src/core/workspaces/resolvers/admin.ts b/packages/backend/server/src/core/workspaces/resolvers/admin.ts new file mode 100644 index 000000000..370cbce92 --- /dev/null +++ b/packages/backend/server/src/core/workspaces/resolvers/admin.ts @@ -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 { + 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'; + } + } +} diff --git a/packages/backend/server/src/core/workspaces/resolvers/index.ts b/packages/backend/server/src/core/workspaces/resolvers/index.ts index 4bbae693f..127f6d029 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/index.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/index.ts @@ -1,3 +1,4 @@ +export * from './admin'; export * from './blob'; export * from './doc'; export * from './history'; diff --git a/packages/backend/server/src/data/migrations/0001-refresh-features.ts b/packages/backend/server/src/data/migrations/0001-refresh-features.ts deleted file mode 100644 index 42e16193a..000000000 --- a/packages/backend/server/src/data/migrations/0001-refresh-features.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1738590347632-feature-redundant.ts b/packages/backend/server/src/data/migrations/1738590347632-feature-redundant.ts deleted file mode 100644 index cf6c01f7f..000000000 --- a/packages/backend/server/src/data/migrations/1738590347632-feature-redundant.ts +++ /dev/null @@ -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 - } -} diff --git a/packages/backend/server/src/data/migrations/index.ts b/packages/backend/server/src/data/migrations/index.ts index 314c09663..a32a49749 100644 --- a/packages/backend/server/src/data/migrations/index.ts +++ b/packages/backend/server/src/data/migrations/index.ts @@ -1,9 +1,7 @@ -export * from './0001-refresh-features'; export * from './1698398506533-guid'; export * from './1703756315970-unamed-account'; export * from './1721299086340-refresh-unnamed-user'; export * from './1732861452428-migrate-invite-status'; export * from './1733125339942-universal-subscription'; -export * from './1738590347632-feature-redundant'; export * from './1745211351719-create-indexer-tables'; export * from './1751966744168-correct-session-update-time'; diff --git a/packages/backend/server/src/models/common/feature.ts b/packages/backend/server/src/models/common/feature.ts index 1fc5117fa..ff2d2cc2b 100644 --- a/packages/backend/server/src/models/common/feature.ts +++ b/packages/backend/server/src/models/common/feature.ts @@ -57,7 +57,7 @@ export enum Feature { // TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`? 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_copilot: EMPTY_CONFIG, ai_early_access: EMPTY_CONFIG, @@ -88,86 +88,81 @@ export type FeatureConfig = z.infer< (typeof FeaturesShapes)[T] >; +const FreeFeature = { + type: FeatureType.Quota, + configs: { + // quota name + name: 'Free', + blobLimit: 10 * OneMB, + businessBlobLimit: 100 * OneMB, + storageQuota: 10 * OneGB, + historyPeriod: 7 * OneDay, + memberLimit: 3, + copilotActionLimit: 10, + }, +} as const; + +const ProFeature = { + type: FeatureType.Quota, + configs: { + name: 'Pro', + blobLimit: 100 * OneMB, + storageQuota: 100 * OneGB, + historyPeriod: 30 * OneDay, + memberLimit: 10, + copilotActionLimit: 10, + }, +} as const; + +const LifetimeProFeature = { + type: FeatureType.Quota, + configs: { + name: 'Lifetime Pro', + blobLimit: 100 * OneMB, + storageQuota: 1024 * OneGB, + historyPeriod: 30 * OneDay, + memberLimit: 10, + copilotActionLimit: 10, + }, +} as const; + +const TeamFeature = { + type: FeatureType.Quota, + configs: { + name: 'Team Workspace', + blobLimit: 500 * OneMB, + storageQuota: 100 * OneGB, + seatQuota: 20 * OneGB, + historyPeriod: 30 * OneDay, + memberLimit: 1, + }, +} as const; + +const WhitelistFeature = { + type: FeatureType.Feature, + configs: { whitelist: [] }, +} as const; + +const EmptyFeature = { + type: FeatureType.Feature, + configs: {}, +} as const; + export const FeatureConfigs: { [K in FeatureName]: { type: FeatureType; configs: FeatureConfig; - deprecatedVersion: number; }; } = { - free_plan_v1: { - type: FeatureType.Quota, - deprecatedVersion: 4, - configs: { - // quota name - name: 'Free', - blobLimit: 10 * OneMB, - businessBlobLimit: 100 * OneMB, - storageQuota: 10 * OneGB, - historyPeriod: 7 * OneDay, - memberLimit: 3, - copilotActionLimit: 10, - }, - }, - pro_plan_v1: { - type: FeatureType.Quota, - deprecatedVersion: 2, - configs: { - name: 'Pro', - blobLimit: 100 * OneMB, - storageQuota: 100 * OneGB, - historyPeriod: 30 * OneDay, - memberLimit: 10, - copilotActionLimit: 10, - }, - }, - lifetime_pro_plan_v1: { - type: FeatureType.Quota, - deprecatedVersion: 1, - configs: { - name: 'Lifetime Pro', - blobLimit: 100 * OneMB, - storageQuota: 1024 * OneGB, - historyPeriod: 30 * OneDay, - memberLimit: 10, - copilotActionLimit: 10, - }, - }, - team_plan_v1: { - type: FeatureType.Quota, - deprecatedVersion: 1, - configs: { - name: 'Team Workspace', - blobLimit: 500 * OneMB, - storageQuota: 100 * OneGB, - seatQuota: 20 * OneGB, - historyPeriod: 30 * OneDay, - memberLimit: 1, - }, - }, - early_access: { - type: FeatureType.Feature, - deprecatedVersion: 2, - configs: { whitelist: [] }, - }, - unlimited_workspace: { - 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: {}, + 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, }; diff --git a/packages/backend/server/src/models/feature.ts b/packages/backend/server/src/models/feature.ts index 3f79a700f..32813486a 100644 --- a/packages/backend/server/src/models/feature.ts +++ b/packages/backend/server/src/models/feature.ts @@ -1,6 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; -import { Feature } from '@prisma/client'; import { z } from 'zod'; import { BaseModel } from './base'; @@ -12,12 +10,6 @@ import { FeatureType, } 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() export class FeatureModel extends BaseModel { async get(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 */ async try_get_unchecked(name: T) { - const feature = await this.db.feature.findFirst({ - where: { name }, - }); + const config = FeatureConfigs[name]; + if (!config) { + return null; + } - return feature as Omit & { - configs: Record; + return { + 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 */ async get_unchecked(name: T) { 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) { throw new Error(`Feature ${name} not found`); } @@ -82,67 +75,4 @@ export class FeatureModel extends BaseModel { getFeatureType(name: FeatureName): FeatureType { return FeatureConfigs[name].type; } - - @Transactional() - private async upsert( - name: T, - configs: FeatureConfig, - 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 }; - } - - 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); - } - } - } } diff --git a/packages/backend/server/src/models/user-feature.ts b/packages/backend/server/src/models/user-feature.ts index 8904521db..27c402e1d 100644 --- a/packages/backend/server/src/models/user-feature.ts +++ b/packages/backend/server/src/models/user-feature.ts @@ -77,7 +77,8 @@ export class UserFeatureModel extends BaseModel { } 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({ where: { userId, @@ -93,7 +94,6 @@ export class UserFeatureModel extends BaseModel { const userFeature = await this.db.userFeature.create({ data: { userId, - featureId: feature.id, name, type: this.models.feature.getFeatureType(name), activated: true, diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts index afcbdbf27..1a768b766 100644 --- a/packages/backend/server/src/models/user.ts +++ b/packages/backend/server/src/models/user.ts @@ -13,7 +13,12 @@ import { WrongSignInMethod, } 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'; type CreateUserInput = Omit & { name?: string }; @@ -313,23 +318,78 @@ export class UserModel extends BaseModel { }); } - async pagination(skip: number = 0, take: number = 20, after?: Date) { - return this.db.user.findMany({ - where: { - createdAt: { - gt: after, + 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({ + where, orderBy: { createdAt: 'asc', }, - skip, - take, + skip: options.skip, + take: options.take, }); } - async count() { - return this.db.user.count(); + async count( + options: { + keyword?: string | null; + features?: UserFeatureName[] | null; + after?: Date; + } = {} + ) { + const where = this.buildListWhere(options); + return this.db.user.count({ where }); } // #region ConnectedAccount diff --git a/packages/backend/server/src/models/workspace-feature.ts b/packages/backend/server/src/models/workspace-feature.ts index b066f3466..becb1e78f 100644 --- a/packages/backend/server/src/models/workspace-feature.ts +++ b/packages/backend/server/src/models/workspace-feature.ts @@ -133,7 +133,8 @@ export class WorkspaceFeatureModel extends BaseModel { reason: string, overrides?: Partial> ) { - 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({ where: { @@ -178,7 +179,6 @@ export class WorkspaceFeatureModel extends BaseModel { workspaceFeature = await this.db.workspaceFeature.create({ data: { workspaceId, - featureId: feature.id, name, type: this.models.feature.getFeatureType(name), activated: true, diff --git a/packages/backend/server/src/models/workspace.ts b/packages/backend/server/src/models/workspace.ts index 8d871d03c..6bf7528f2 100644 --- a/packages/backend/server/src/models/workspace.ts +++ b/packages/backend/server/src/models/workspace.ts @@ -1,9 +1,58 @@ import { Injectable } from '@nestjs/common'; 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 { 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 { interface Events { @@ -130,4 +179,154 @@ export class WorkspaceModel extends BaseModel { return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1'); } // #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` + 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 } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 97c3b05c4..af5ebae6f 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -31,6 +31,55 @@ input AddContextFileInput { 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 { nodes: [SearchNodeObjectType!]! } @@ -740,7 +789,6 @@ enum ErrorNames { DOC_IS_NOT_PUBLIC DOC_NOT_FOUND DOC_UPDATE_BLOCKED - EARLY_ACCESS_REQUIRED EMAIL_ALREADY_USED EMAIL_SERVICE_NOT_CONFIGURED EMAIL_TOKEN_NOT_FOUND @@ -1161,10 +1209,20 @@ type LimitedUserType { } input ListUserInput { + features: [FeatureType!] first: Int = 20 + keyword: String skip: Int = 0 } +input ListWorkspaceInput { + features: [FeatureType!] + first: Int! = 20 + keyword: String + orderBy: AdminWorkspaceSort + skip: Int! = 0 +} + type ListedBlob { createdAt: String! key: String! @@ -1249,6 +1307,9 @@ type Mutation { """Update workspace embedding files""" addWorkspaceEmbeddingFiles(blob: Upload!, workspaceId: String!): CopilotWorkspaceFile! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! + + """Update workspace flags and features for admin""" + adminUpdateWorkspace(input: AdminUpdateWorkspaceInput!): AdminWorkspace approveMember(userId: String!, workspaceId: String!): Boolean! """Ban an user""" @@ -1613,6 +1674,15 @@ type PublicUserType { type Query { 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""" appConfig: JSONObject! @@ -1660,7 +1730,7 @@ type Query { users(filter: ListUserInput!): [UserType!]! """Get users count""" - usersCount: Int! + usersCount(filter: ListUserInput): Int! """Get workspace by id""" workspace(id: String!): WorkspaceType! @@ -1884,15 +1954,15 @@ enum SearchTable { } 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""" availableUpgrade: ReleaseVersionType """Features for user that can be configured""" availableUserFeatures: [FeatureType!]! + """Workspace features available for admin configuration""" + availableWorkspaceFeatures: [FeatureType!]! + """server base url""" baseUrl: String! diff --git a/packages/common/graphql/src/graphql/admin/admin-server-config.gql b/packages/common/graphql/src/graphql/admin/admin-server-config.gql index 6873c7bfa..43fc13d30 100644 --- a/packages/common/graphql/src/graphql/admin/admin-server-config.gql +++ b/packages/common/graphql/src/graphql/admin/admin-server-config.gql @@ -19,5 +19,6 @@ query adminServerConfig { url } availableUserFeatures + availableWorkspaceFeatures } } diff --git a/packages/common/graphql/src/graphql/admin/admin-update-workspace.gql b/packages/common/graphql/src/graphql/admin/admin-update-workspace.gql new file mode 100644 index 000000000..1c472942e --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/admin-update-workspace.gql @@ -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 + } +} diff --git a/packages/common/graphql/src/graphql/admin/admin-workspace.gql b/packages/common/graphql/src/graphql/admin/admin-workspace.gql new file mode 100644 index 000000000..201dfec56 --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/admin-workspace.gql @@ -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 + } + } +} diff --git a/packages/common/graphql/src/graphql/admin/admin-workspaces.gql b/packages/common/graphql/src/graphql/admin/admin-workspaces.gql new file mode 100644 index 000000000..fc05396dd --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/admin-workspaces.gql @@ -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) +} diff --git a/packages/common/graphql/src/graphql/admin/list-users.gql b/packages/common/graphql/src/graphql/admin/list-users.gql index ce6c42055..d58023acc 100644 --- a/packages/common/graphql/src/graphql/admin/list-users.gql +++ b/packages/common/graphql/src/graphql/admin/list-users.gql @@ -9,5 +9,5 @@ query listUsers($filter: ListUserInput!) { emailVerified avatarUrl } - usersCount + usersCount(filter: $filter) } diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 48a8aa599..e5980bfa1 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -127,12 +127,119 @@ export const adminServerConfigQuery = { url } availableUserFeatures + availableWorkspaceFeatures } } ${passwordLimitsFragment} ${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 = { id: 'createChangePasswordUrlMutation' as const, op: 'createChangePasswordUrl', @@ -287,7 +394,7 @@ export const listUsersQuery = { emailVerified avatarUrl } - usersCount + usersCount(filter: $filter) }`, }; diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index d49f9b2e3..ffdf9fda5 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -67,6 +67,62 @@ export interface AddContextFileInput { contextId: Scalars['String']['input']; } +export interface AdminUpdateWorkspaceInput { + avatarKey?: InputMaybe; + enableAi?: InputMaybe; + enableDocEmbedding?: InputMaybe; + enableUrlPreview?: InputMaybe; + features?: InputMaybe>; + id: Scalars['String']['input']; + name?: InputMaybe; + public?: InputMaybe; +} + +export interface AdminWorkspace { + __typename?: 'AdminWorkspace'; + avatarKey: Maybe; + 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; + id: Scalars['String']['output']; + memberCount: Scalars['Int']['output']; + /** Members of workspace */ + members: Array; + name: Maybe; + owner: Maybe; + public: Scalars['Boolean']['output']; + publicPageCount: Scalars['Int']['output']; + snapshotCount: Scalars['Int']['output']; + snapshotSize: Scalars['SafeInt']['output']; +} + +export interface AdminWorkspaceMembersArgs { + query?: InputMaybe; + skip?: InputMaybe; + take?: InputMaybe; +} + +export interface AdminWorkspaceMember { + __typename?: 'AdminWorkspaceMember'; + avatarUrl: Maybe; + 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 { __typename?: 'AggregateBucketHitsObjectType'; nodes: Array; @@ -922,7 +978,6 @@ export 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 = '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', @@ -1338,10 +1393,20 @@ export interface LimitedUserType { } export interface ListUserInput { + features?: InputMaybe>; first?: InputMaybe; + keyword?: InputMaybe; skip?: InputMaybe; } +export interface ListWorkspaceInput { + features?: InputMaybe>; + first?: Scalars['Int']['input']; + keyword?: InputMaybe; + orderBy?: InputMaybe; + skip?: Scalars['Int']['input']; +} + export interface ListedBlob { __typename?: 'ListedBlob'; createdAt: Scalars['String']['output']; @@ -1423,6 +1488,8 @@ export interface Mutation { /** Update workspace embedding files */ addWorkspaceEmbeddingFiles: CopilotWorkspaceFile; addWorkspaceFeature: Scalars['Boolean']['output']; + /** Update workspace flags and features for admin */ + adminUpdateWorkspace: Maybe; approveMember: Scalars['Boolean']['output']; /** Ban an user */ banUser: UserType; @@ -1614,6 +1681,10 @@ export interface MutationAddWorkspaceFeatureArgs { workspaceId: Scalars['String']['input']; } +export interface MutationAdminUpdateWorkspaceArgs { + input: AdminUpdateWorkspaceInput; +} + export interface MutationApproveMemberArgs { userId: Scalars['String']['input']; workspaceId: Scalars['String']['input']; @@ -2216,6 +2287,12 @@ export interface PublicUserType { export interface Query { __typename?: 'Query'; accessTokens: Array; + /** Get workspace detail for admin */ + adminWorkspace: Maybe; + /** List workspaces for admin */ + adminWorkspaces: Array; + /** Workspaces count for admin */ + adminWorkspacesCount: Scalars['Int']['output']; /** get the whole app configuration */ appConfig: Scalars['JSONObject']['output']; /** Apply updates to a doc using LLM and return the merged markdown. */ @@ -2268,6 +2345,18 @@ export interface Query { workspaces: Array; } +export interface QueryAdminWorkspaceArgs { + id: Scalars['String']['input']; +} + +export interface QueryAdminWorkspacesArgs { + filter: ListWorkspaceInput; +} + +export interface QueryAdminWorkspacesCountArgs { + filter: ListWorkspaceInput; +} + export interface QueryApplyDocUpdatesArgs { docId: Scalars['String']['input']; op: Scalars['String']['input']; @@ -2315,6 +2404,10 @@ export interface QueryUsersArgs { filter: ListUserInput; } +export interface QueryUsersCountArgs { + filter?: InputMaybe; +} + export interface QueryWorkspaceArgs { id: Scalars['String']['input']; } @@ -2533,15 +2626,12 @@ export enum SearchTable { export interface 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 */ availableUpgrade: Maybe; /** Features for user that can be configured */ availableUserFeatures: Array; + /** Workspace features available for admin configuration */ + availableWorkspaceFeatures: Array; /** server base url */ baseUrl: Scalars['String']['output']; /** credentials requirement */ @@ -3200,6 +3290,7 @@ export type AdminServerConfigQuery = { type: ServerDeploymentType; initialized: boolean; availableUserFeatures: Array; + availableWorkspaceFeatures: Array; credentialsRequirement: { __typename?: 'CredentialsRequirementType'; 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; + 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; + memberTake?: InputMaybe; + memberQuery?: InputMaybe; +}>; + +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; + 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; + 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<{ callbackUrl: Scalars['String']['input']; userId: Scalars['String']['input']; @@ -6519,6 +6730,21 @@ export type Queries = variables: AdminServerConfigQueryVariables; response: AdminServerConfigQuery; } + | { + name: 'adminWorkspaceQuery'; + variables: AdminWorkspaceQueryVariables; + response: AdminWorkspaceQuery; + } + | { + name: 'adminWorkspacesQuery'; + variables: AdminWorkspacesQueryVariables; + response: AdminWorkspacesQuery; + } + | { + name: 'adminWorkspacesCountQuery'; + variables: AdminWorkspacesCountQueryVariables; + response: AdminWorkspacesCountQuery; + } | { name: 'appConfigQuery'; variables: AppConfigQueryVariables; @@ -6886,6 +7112,11 @@ export type Mutations = variables: RevokeUserAccessTokenMutationVariables; response: RevokeUserAccessTokenMutation; } + | { + name: 'adminUpdateWorkspaceMutation'; + variables: AdminUpdateWorkspaceMutationVariables; + response: AdminUpdateWorkspaceMutation; + } | { name: 'createChangePasswordUrlMutation'; variables: CreateChangePasswordUrlMutationVariables; diff --git a/packages/frontend/admin/src/app.tsx b/packages/frontend/admin/src/app.tsx index 320f46151..d5fa6c670 100644 --- a/packages/frontend/admin/src/app.tsx +++ b/packages/frontend/admin/src/app.tsx @@ -23,6 +23,9 @@ export const Setup = lazy( export const Accounts = lazy( () => import(/* webpackChunkName: "accounts" */ './modules/accounts') ); +export const Workspaces = lazy( + () => import(/* webpackChunkName: "workspaces" */ './modules/workspaces') +); export const AI = lazy( () => import(/* webpackChunkName: "ai" */ './modules/ai') ); @@ -91,6 +94,10 @@ export const App = () => { } /> }> } /> + } + /> } /> } /> 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 ( + + + + {title} + + {description} + + + +
+ + +
+
+
+
+ ); +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-pagination.tsx b/packages/frontend/admin/src/components/shared/data-table-pagination.tsx similarity index 98% rename from packages/frontend/admin/src/modules/accounts/components/data-table-pagination.tsx rename to packages/frontend/admin/src/components/shared/data-table-pagination.tsx index f5f9f59db..abba64209 100644 --- a/packages/frontend/admin/src/modules/accounts/components/data-table-pagination.tsx +++ b/packages/frontend/admin/src/components/shared/data-table-pagination.tsx @@ -111,7 +111,7 @@ export function DataTablePagination({ + + +
Filter by feature
+
+ {availableFeatures.map(feature => ( + + ))} +
+
+ +
+
+ + ); +}; diff --git a/packages/frontend/admin/src/components/shared/feature-toggle-list.tsx b/packages/frontend/admin/src/components/shared/feature-toggle-list.tsx new file mode 100644 index 000000000..3cf4c0c51 --- /dev/null +++ b/packages/frontend/admin/src/components/shared/feature-toggle-list.tsx @@ -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 ( +
+ No configurable features. +
+ ); + } + + return ( +
+ {features.map((feature, index) => ( +
+ + {showSeparators && index < features.length - 1 && } +
+ ))} +
+ ); +}; diff --git a/packages/frontend/admin/src/components/shared/type-confirm-dialog.tsx b/packages/frontend/admin/src/components/shared/type-confirm-dialog.tsx new file mode 100644 index 000000000..6c760c76d --- /dev/null +++ b/packages/frontend/admin/src/components/shared/type-confirm-dialog.tsx @@ -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) => { + setInput(event.target.value); + }, + [] + ); + + useEffect(() => { + if (!open) { + setInput(''); + } + }, [open]); + + const handleClose = () => { + onOpenChange(false); + onClose?.(); + }; + + return ( + + + + {title} + {description} + + + +
+ + +
+
+
+
+ ); +}; diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index ba0252b85..4288088c6 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -228,10 +228,6 @@ } }, "flags": { - "earlyAccessControl": { - "type": "Boolean", - "desc": "Only allow users with early access features to access the app" - }, "allowGuestDemoWorkspace": { "type": "Boolean", "desc": "Whether allow guest users to create demo workspaces." diff --git a/packages/frontend/admin/src/hooks/use-debounced-value.ts b/packages/frontend/admin/src/hooks/use-debounced-value.ts new file mode 100644 index 000000000..8679ed2ea --- /dev/null +++ b/packages/frontend/admin/src/hooks/use-debounced-value.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/packages/frontend/admin/src/modules/accounts/components/columns.tsx b/packages/frontend/admin/src/modules/accounts/components/columns.tsx index a72d793cb..cc916eb0d 100644 --- a/packages/frontend/admin/src/modules/accounts/components/columns.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/columns.tsx @@ -66,6 +66,9 @@ export const useColumns = ({ return [ { id: 'select', + meta: { + className: 'w-[40px] flex-shrink-0', + }, header: ({ table }) => ( ( ), cell: ({ row: { original: user } }) => ( @@ -233,12 +239,40 @@ export const useColumns = ({ textFalse="Email Not Verified" /> +
+ {user.features.length ? ( + user.features.map(feature => ( + + {feature} + + )) + ) : ( + + No features + + )} +
), }, { id: 'actions', + meta: { + className: 'w-[80px]', + }, header: ({ column }) => ( { - setDiscardDialogOpen(false); - }, []); - const handleConfirm = useCallback(() => { + setHasDirtyChanges(false); setPanelContent( ); - if (discardDialogOpen) { - handleDiscardChangesCancel(); - } - if (!isOpen) { - openPanel(); - } + openPanel(); }, [ closePanel, - discardDialogOpen, - handleDiscardChangesCancel, - isOpen, openDeleteDialog, openPanel, openResetPasswordDialog, setPanelContent, user, + setHasDirtyChanges, ]); const handleEdit = useCallback(() => { - if (isOpen) { + if (hasDirtyChanges) { setDiscardDialogOpen(true); - } else { - handleConfirm(); + return; } - }, [handleConfirm, isOpen]); + setHasDirtyChanges(false); + handleConfirm(); + }, [handleConfirm, hasDirtyChanges, setHasDirtyChanges]); + + const handleDiscardConfirm = useCallback(() => { + setDiscardDialogOpen(false); + setHasDirtyChanges(false); + handleConfirm(); + }, [handleConfirm, setHasDirtyChanges]); return (
@@ -242,8 +247,8 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) { setDiscardDialogOpen(false)} + onConfirm={handleDiscardConfirm} />
); diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx index cdeaf31d2..2c9357ecd 100644 --- a/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx @@ -1,126 +1,99 @@ import { Button } from '@affine/admin/components/ui/button'; import { Input } from '@affine/admin/components/ui/input'; -import { useQuery } from '@affine/admin/use-query'; -import { getUserByEmailQuery } from '@affine/graphql'; +import type { FeatureType } from '@affine/graphql'; import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc'; import type { Table } from '@tanstack/react-table'; -import type { Dispatch, SetStateAction } from 'react'; import { - startTransition, + type ChangeEvent, + type Dispatch, + type SetStateAction, useCallback, useEffect, - useMemo, useState, } 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 type { UserType } from '../schema'; -import { DiscardChanges } from './discard-changes'; import { ExportUsersDialog } from './export-users-dialog'; import { ImportUsersDialog } from './import-users'; import { CreateUserForm } from './user-form'; interface DataTableToolbarProps { - data: TData[]; - usersCount: number; selectedUsers: UserType[]; - setDataTable: (data: TData[]) => void; - setRowCount: (rowCount: number) => void; - setMemoUsers: Dispatch>; table?: Table; -} - -const useSearch = () => { - const [value, setValue] = useState(''); - const { data } = useQuery({ - query: getUserByEmailQuery, - variables: { email: value }, - }); - - const result = useMemo(() => data?.userByEmail, [data]); - - return { - result, - query: setValue, - }; -}; - -function useDebouncedValue(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - return () => { - clearTimeout(handler); - }; - }, [value, delay]); - - return debouncedValue; + keyword: string; + onKeywordChange: Dispatch>; + selectedFeatures: FeatureType[]; + onFeaturesChange: Dispatch>; } export function DataTableToolbar({ - data, - usersCount, selectedUsers, - setDataTable, - setRowCount, - setMemoUsers, table, + keyword, + onKeywordChange, + selectedFeatures, + onFeaturesChange, }: DataTableToolbarProps) { - const [value, setValue] = useState(''); + const [value, setValue] = useState(keyword); const [dialogOpen, setDialogOpen] = useState(false); const [exportDialogOpen, setExportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); - const debouncedValue = useDebouncedValue(value, 1000); - const { setPanelContent, openPanel, closePanel, isOpen } = useRightPanel(); - const { result, query } = useSearch(); + const debouncedValue = useDebouncedValue(value, 500); + const { + setPanelContent, + openPanel, + closePanel, + isOpen, + hasDirtyChanges, + setHasDirtyChanges, + } = useRightPanel(); + const serverConfig = useServerConfig(); + const availableFeatures = serverConfig.availableUserFeatures ?? []; const handleConfirm = useCallback(() => { - setPanelContent(); + setPanelContent( + + ); if (dialogOpen) { setDialogOpen(false); } if (!isOpen) { 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, - debouncedValue, - result, - setDataTable, - setMemoUsers, - setRowCount, - usersCount, + setPanelContent, + closePanel, + dialogOpen, + isOpen, + openPanel, + setHasDirtyChanges, ]); - const onValueChange = useCallback( - (e: { currentTarget: { value: SetStateAction } }) => { - setValue(e.currentTarget.value); + useEffect(() => { + setValue(keyword); + }, [keyword]); + + useEffect(() => { + onKeywordChange(debouncedValue.trim()); + }, [debouncedValue, onKeywordChange]); + + const onValueChange = useCallback((e: ChangeEvent) => { + setValue(e.currentTarget.value); + }, []); + + const handleFeatureToggle = useCallback( + (features: FeatureType[]) => { + onFeaturesChange(features); }, - [] + [onFeaturesChange] ); const handleCancel = useCallback(() => { @@ -128,11 +101,11 @@ export function DataTableToolbar({ }, []); const handleOpenConfirm = useCallback(() => { - if (isOpen) { + if (hasDirtyChanges) { return setDialogOpen(true); } return handleConfirm(); - }, [handleConfirm, isOpen]); + }, [handleConfirm, hasDirtyChanges]); const handleExportUsers = useCallback(() => { if (!table) return; @@ -192,9 +165,15 @@ export function DataTableToolbar({
+
{ @@ -28,7 +16,10 @@ interface DataTableProps { pagination: PaginationState; usersCount: number; selectedUsers: UserType[]; - setMemoUsers: Dispatch>; + keyword: string; + onKeywordChange: Dispatch>; + selectedFeatures: FeatureType[]; + onFeaturesChange: Dispatch>; onPaginationChange: Dispatch< SetStateAction<{ pageIndex: number; @@ -43,139 +34,46 @@ export function DataTable({ pagination, usersCount, selectedUsers, - setMemoUsers, + keyword, + onKeywordChange, + selectedFeatures, + onFeaturesChange, onPaginationChange, }: DataTableProps) { - const [rowSelection, setRowSelection] = useState({}); - const [columnFilters, setColumnFilters] = useState([]); - - const [tableData, setTableData] = useState(data); - const [rowCount, setRowCount] = useState(usersCount); - const table = useReactTable({ - data: tableData, - columns, - getCoreRowModel: getCoreRowModel(), - getRowId: row => row.id, - manualPagination: true, - rowCount: rowCount, - enableFilters: true, - onPaginationChange: onPaginationChange, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - onColumnFiltersChange: setColumnFilters, - state: { - pagination, - rowSelection, - columnFilters, - }, - }); + const [rowSelection, setRowSelection] = useState({}); useEffect(() => { - setTableData(data); - }, [data]); + setRowSelection({}); + }, [keyword, selectedFeatures]); useEffect(() => { - setRowCount(usersCount); - }, [usersCount]); + const selection: Record = {}; + selectedUsers.forEach(user => { + selection[user.id] = true; + }); + setRowSelection(selection); + }, [selectedUsers]); return ( -
- -
- - - {table.getHeaderGroups().map(headerGroup => ( - - {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 ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ); - })} - - ))} - -
- -
- - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map(row => ( - - {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 ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - - No results. - - - )} - -
-
-
- - -
+ ( + + )} + /> ); } diff --git a/packages/frontend/admin/src/modules/accounts/components/delete-account.tsx b/packages/frontend/admin/src/modules/accounts/components/delete-account.tsx index cf2c475fd..3a62757a5 100644 --- a/packages/frontend/admin/src/modules/accounts/components/delete-account.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/delete-account.tsx @@ -1,14 +1,4 @@ -import { Button } 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 { useCallback, useEffect, useState } from 'react'; +import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog'; export const DeleteAccountDialog = ({ email, @@ -23,55 +13,23 @@ export const DeleteAccountDialog = ({ onDelete: () => void; onOpenChange: (open: boolean) => void; }) => { - const [input, setInput] = useState(''); - const handleInput = useCallback( - (event: React.ChangeEvent) => { - setInput(event.target.value); - }, - [setInput] - ); - - useEffect(() => { - if (!open) { - setInput(''); - } - }, [open]); - return ( - - - - Delete Account ? - - {email} will be permanently - deleted. This operation is irreversible. Please proceed with - caution. - - - - -
- - -
-
-
-
+ + {email} will be permanently + deleted. This operation is irreversible. Please proceed with caution. + + } + targetText={email} + inputPlaceholder="Please type email to confirm" + confirmText="Delete" + confirmButtonVariant="destructive" + onConfirm={onDelete} + onClose={onClose} + /> ); }; diff --git a/packages/frontend/admin/src/modules/accounts/components/disable-account.tsx b/packages/frontend/admin/src/modules/accounts/components/disable-account.tsx index c00b440cf..76b4975f5 100644 --- a/packages/frontend/admin/src/modules/accounts/components/disable-account.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/disable-account.tsx @@ -1,14 +1,4 @@ -import { Button } 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 { useCallback, useEffect, useState } from 'react'; +import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog'; export const DisableAccountDialog = ({ email, @@ -23,55 +13,24 @@ export const DisableAccountDialog = ({ onDisable: () => void; onOpenChange: (open: boolean) => void; }) => { - const [input, setInput] = useState(''); - const handleInput = useCallback( - (event: React.ChangeEvent) => { - setInput(event.target.value); - }, - [setInput] - ); - - useEffect(() => { - if (!open) { - setInput(''); - } - }, [open]); - return ( - - - - Disable Account ? - - The data associated with {email}{' '} - will be deleted and cannot be used for logging in. This operation is - irreversible. Please proceed with caution. - - - - -
- - -
-
-
-
+ + The data associated with {email}{' '} + will be deleted and cannot be used for logging in. This operation is + irreversible. Please proceed with caution. + + } + targetText={email} + inputPlaceholder="Please type email to confirm" + confirmText="Disable" + confirmButtonVariant="destructive" + onConfirm={onDisable} + onClose={onClose} + /> ); }; diff --git a/packages/frontend/admin/src/modules/accounts/components/discard-changes.tsx b/packages/frontend/admin/src/modules/accounts/components/discard-changes.tsx deleted file mode 100644 index 18cb6eb94..000000000 --- a/packages/frontend/admin/src/modules/accounts/components/discard-changes.tsx +++ /dev/null @@ -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 ( - - - - Discard Changes - - Changes to this user will not be saved. - - - -
- - -
-
-
-
- ); -}; diff --git a/packages/frontend/admin/src/modules/accounts/components/enable-account.tsx b/packages/frontend/admin/src/modules/accounts/components/enable-account.tsx index 2e7a54798..97351bd6a 100644 --- a/packages/frontend/admin/src/modules/accounts/components/enable-account.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/enable-account.tsx @@ -1,12 +1,4 @@ -import { Button } from '@affine/admin/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@affine/admin/components/ui/dialog'; +import { ConfirmDialog } from '../../../components/shared/confirm-dialog'; export const EnableAccountDialog = ({ open, @@ -22,27 +14,21 @@ export const EnableAccountDialog = ({ onOpenChange: (open: boolean) => void; }) => { return ( - - - - Enable Account - - Are you sure you want to enable the account? After enabling the - account, the {email} email can be - used to log in. - - - -
- - -
-
-
-
+ + Are you sure you want to enable the account? After enabling the + account, the {email} email can be + used to log in. + + } + confirmText="Enable" + confirmButtonVariant="default" + onConfirm={onConfirm} + onClose={onClose} + /> ); }; diff --git a/packages/frontend/admin/src/modules/accounts/components/user-form.tsx b/packages/frontend/admin/src/modules/accounts/components/user-form.tsx index 5b0aa079e..c670ee58f 100644 --- a/packages/frontend/admin/src/modules/accounts/components/user-form.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/user-form.tsx @@ -2,7 +2,6 @@ import { Button } from '@affine/admin/components/ui/button'; 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 type { FeatureType } from '@affine/graphql'; import { cssVarV2 } from '@toeverything/theme/v2'; import { ChevronRightIcon } from 'lucide-react'; @@ -10,6 +9,7 @@ import type { ChangeEvent } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { FeatureToggleList } from '../../../components/shared/feature-toggle-list'; import { useServerConfig } from '../../common'; import { RightPanelHeader } from '../../header'; import type { UserInput, UserType } from '../schema'; @@ -24,6 +24,7 @@ type UserFormProps = { onValidate: (user: Partial) => boolean; actions?: React.ReactNode; showOption?: boolean; + onDirtyChange?: (dirty: boolean) => void; }; function UserForm({ @@ -34,6 +35,7 @@ function UserForm({ onValidate, actions, showOption, + onDirtyChange, }: UserFormProps) { const serverConfig = useServerConfig(); @@ -67,6 +69,24 @@ function UserForm({ return onValidate(changes); }, [onValidate, changes]); + useEffect(() => { + const normalize = (value: Partial) => ({ + 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(() => { if (!canSave) { return; @@ -77,14 +97,9 @@ function UserForm({ setChanges(defaultUser); }, [canSave, changes, defaultUser, onConfirm]); - const onFeatureChanged = useCallback( - (feature: FeatureType, checked: boolean) => { - setField('features', (features = []) => { - if (checked) { - return [...features, feature]; - } - return features.filter(f => f !== feature); - }); + const handleFeaturesChange = useCallback( + (features: FeatureType[]) => { + setField('features', features); }, [setField] ); @@ -138,52 +153,21 @@ function UserForm({ )}
-
- {serverConfig.availableUserFeatures.map((feature, i) => ( -
- - {i < serverConfig.availableUserFeatures.length - 1 && ( - - )} -
- ))} -
+ {actions}
); } -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 ( - - ); -} - function InputItem({ label, field, @@ -241,7 +225,13 @@ const validateUpdateUser = (user: Partial) => { 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 serverConfig = useServerConfig(); const passwordLimits = serverConfig.credentialsRequirement.password; @@ -278,6 +268,7 @@ export function CreateUserForm({ onComplete }: { onComplete: () => void }) { onConfirm={handleCreateUser} onValidate={validateCreateUser} showOption={true} + onDirtyChange={onDirtyChange} /> ); } @@ -287,11 +278,13 @@ export function UpdateUserForm({ onResetPassword, onDeleteAccount, onComplete, + onDirtyChange, }: { user: UserType; onResetPassword: () => void; onDeleteAccount: () => void; onComplete: () => void; + onDirtyChange?: (dirty: boolean) => void; }) { const { update, updating } = useUpdateUser(); @@ -321,6 +314,7 @@ export function UpdateUserForm({ onClose={onComplete} onConfirm={onUpdateUser} onValidate={validateUpdateUser} + onDirtyChange={onDirtyChange} actions={ <> - - - - - - ); -}; diff --git a/packages/frontend/admin/src/modules/ai/prompts.tsx b/packages/frontend/admin/src/modules/ai/prompts.tsx index 5c97cc2ba..d867b79a2 100644 --- a/packages/frontend/admin/src/modules/ai/prompts.tsx +++ b/packages/frontend/admin/src/modules/ai/prompts.tsx @@ -3,8 +3,8 @@ import { Separator } from '@affine/admin/components/ui/separator'; import type { CopilotPromptMessageRole } from '@affine/graphql'; import { useCallback, useState } from 'react'; +import { DiscardChanges } from '../../components/shared/discard-changes'; import { useRightPanel } from '../panel/context'; -import { DiscardChanges } from './discard-changes'; import { EditPrompt } from './edit-prompt'; import { usePrompt } from './use-prompt'; diff --git a/packages/frontend/admin/src/modules/layout.tsx b/packages/frontend/admin/src/modules/layout.tsx index b53602c1b..7500f1a26 100644 --- a/packages/frontend/admin/src/modules/layout.tsx +++ b/packages/frontend/admin/src/modules/layout.tsx @@ -8,8 +8,9 @@ import { cn } from '@affine/admin/utils'; import { cssVarV2 } from '@toeverything/theme/v2'; import { AlignJustifyIcon } from 'lucide-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 { useLocation } from 'react-router-dom'; import { Button } from '../components/ui/button'; import { @@ -31,12 +32,16 @@ import { } from './panel/context'; export function Layout({ children }: PropsWithChildren) { - const [rightPanelContent, setRightPanelContent] = useState(null); + const [rightPanelContent, setRightPanelContentState] = + useState(null); const [leftPanelContent, setLeftPanelContent] = useState(null); const [leftOpen, setLeftOpen] = useState(false); const [rightOpen, setRightOpen] = useState(false); + const [rightPanelHasDirtyChanges, setRightPanelHasDirtyChanges] = + useState(false); const rightPanelRef = useRef(null); const leftPanelRef = useRef(null); + const location = useLocation(); const [activeTab, setActiveTab] = useState(''); const [activeSubTab, setActiveSubTab] = useState('server'); @@ -88,6 +93,14 @@ export function Layout({ children }: PropsWithChildren) { setRightOpen(false); }, [rightPanelRef]); + const handleSetRightPanelContent = useCallback( + (content: ReactNode) => { + setRightPanelHasDirtyChanges(false); + setRightPanelContentState(content); + }, + [setRightPanelContentState, setRightPanelHasDirtyChanges] + ); + const openRightPanel = useCallback(() => { handleRightExpand(); rightPanelRef.current?.expand(); @@ -98,7 +111,8 @@ export function Layout({ children }: PropsWithChildren) { handleRightCollapse(); rightPanelRef.current?.collapse(); setRightOpen(false); - }, [handleRightCollapse]); + setRightPanelHasDirtyChanges(false); + }, [handleRightCollapse, setRightPanelHasDirtyChanges]); const toggleRightPanel = useCallback( () => @@ -108,6 +122,12 @@ export function Layout({ children }: PropsWithChildren) { [closeRightPanel, openRightPanel] ); + // auto close right panel when route changes + useEffect(() => { + handleSetRightPanelContent(null); + closeRightPanel(); + }, [location.pathname, closeRightPanel, handleSetRightPanelContent]); + return ( @@ -140,7 +162,7 @@ export function Layout({ children }: PropsWithChildren) { }} > -
+
} @@ -278,7 +300,7 @@ export const RightPanel = ({ - {panelContent} +
{panelContent}
); @@ -297,7 +319,7 @@ export const RightPanel = ({ onCollapse={onCollapse} className="border-l max-w-96" > - {panelContent} +
{panelContent}
); }; diff --git a/packages/frontend/admin/src/modules/nav/nav.tsx b/packages/frontend/admin/src/modules/nav/nav.tsx index f48331a97..5ae031142 100644 --- a/packages/frontend/admin/src/modules/nav/nav.tsx +++ b/packages/frontend/admin/src/modules/nav/nav.tsx @@ -2,6 +2,7 @@ import { buttonVariants } from '@affine/admin/components/ui/button'; import { cn } from '@affine/admin/utils'; import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc'; import { cssVarV2 } from '@toeverything/theme/v2'; +import { LayoutDashboardIcon } from 'lucide-react'; import { NavLink } from 'react-router-dom'; import { ServerVersion } from './server-version'; @@ -80,7 +81,7 @@ export function Nav({ isCollapsed = false }: NavProps) { >