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