fix(admin): organize admin panel (#7840)

This commit is contained in:
forehalo
2024-08-13 05:45:02 +00:00
parent 6dea831d8a
commit 0ec1995add
45 changed files with 746 additions and 955 deletions

View File

@@ -155,7 +155,7 @@ function buildAppModule() {
.useIf(config => config.flavor.sync, WebSocketModule) .useIf(config => config.flavor.sync, WebSocketModule)
// auth // auth
.use(AuthModule) .use(UserModule, AuthModule)
// business modules // business modules
.use(DocModule) .use(DocModule)
@@ -169,7 +169,6 @@ function buildAppModule() {
ServerConfigModule, ServerConfigModule,
GqlModule, GqlModule,
StorageModule, StorageModule,
UserModule,
WorkspaceModule, WorkspaceModule,
FeatureModule, FeatureModule,
QuotaModule QuotaModule

View File

@@ -3,7 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import type { User, UserSession } from '@prisma/client'; import type { User, UserSession } from '@prisma/client';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import type { CookieOptions, Request, Response } from 'express'; import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es'; import { assign, pick } from 'lodash-es';
import { Config, EmailAlreadyUsed, MailService } from '../../fundamentals'; import { Config, EmailAlreadyUsed, MailService } from '../../fundamentals';
import { FeatureManagementService } from '../features/management'; import { FeatureManagementService } from '../features/management';
@@ -41,13 +41,11 @@ export function sessionUser(
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt' 'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt'
> & { password?: string | null } > & { password?: string | null }
): CurrentUser { ): CurrentUser {
return assign( // use pick to avoid unexpected fields
omit(user, 'password', 'registered', 'emailVerifiedAt', 'createdAt'), return assign(pick(user, 'id', 'email', 'avatarUrl', 'name'), {
{ hasPassword: user.password !== null,
hasPassword: user.password !== null, emailVerified: user.emailVerifiedAt !== null,
emailVerified: user.emailVerifiedAt !== null, });
}
);
} }
@Injectable() @Injectable()

View File

@@ -16,5 +16,5 @@ import {
], ],
}) })
export class ServerConfigModule {} export class ServerConfigModule {}
export { ADD_ENABLED_FEATURES, ServerConfigType } from './resolver'; export { ADD_ENABLED_FEATURES } from './server-feature';
export { ServerFeature } from './types'; export { ServerFeature } from './types';

View File

@@ -12,24 +12,14 @@ import {
import { PrismaClient, RuntimeConfig, RuntimeConfigType } from '@prisma/client'; import { PrismaClient, RuntimeConfig, RuntimeConfigType } from '@prisma/client';
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars'; import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { Config, DeploymentType, URLHelper } from '../../fundamentals'; import { Config, URLHelper } from '../../fundamentals';
import { Public } from '../auth'; import { Public } from '../auth';
import { Admin } from '../common'; import { Admin } from '../common';
import { FeatureType } from '../features';
import { AvailableUserFeatureConfig } from '../features/resolver';
import { ServerFlags } from './config'; import { ServerFlags } from './config';
import { ServerFeature } from './types'; import { ENABLED_FEATURES } from './server-feature';
import { ServerConfigType } from './types';
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
}
registerEnumType(ServerFeature, {
name: 'ServerFeature',
});
registerEnumType(DeploymentType, {
name: 'ServerDeploymentType',
});
@ObjectType() @ObjectType()
export class PasswordLimitsType { export class PasswordLimitsType {
@@ -45,36 +35,6 @@ export class CredentialsRequirementType {
password!: PasswordLimitsType; password!: PasswordLimitsType;
} }
@ObjectType()
export class ServerConfigType {
@Field({
description:
'server identical name could be shown as badge on user interface',
})
name!: string;
@Field({ description: 'server version' })
version!: string;
@Field({ description: 'server base url' })
baseUrl!: string;
@Field(() => DeploymentType, { description: 'server type' })
type!: DeploymentType;
/**
* @deprecated
*/
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
flavor!: string;
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
@Field({ description: 'enable telemetry' })
enableTelemetry!: boolean;
}
registerEnumType(RuntimeConfigType, { registerEnumType(RuntimeConfigType, {
name: 'RuntimeConfigType', name: 'RuntimeConfigType',
}); });
@@ -175,6 +135,20 @@ export class ServerConfigResolver {
} }
} }
@Resolver(() => ServerConfigType)
export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
constructor(config: Config) {
super(config);
}
@ResolveField(() => [FeatureType], {
description: 'Features for user that can be configured',
})
override availableUserFeatures() {
return super.availableUserFeatures();
}
}
@ObjectType() @ObjectType()
class ServerServiceConfig { class ServerServiceConfig {
@Field() @Field()

View File

@@ -0,0 +1,7 @@
import { ServerFeature } from './types';
export const ENABLED_FEATURES: Set<ServerFeature> = new Set();
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
}
export { ServerFeature };

View File

@@ -1,5 +1,47 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { DeploymentType } from '../../fundamentals';
export enum ServerFeature { export enum ServerFeature {
Copilot = 'copilot', Copilot = 'copilot',
Payment = 'payment', Payment = 'payment',
OAuth = 'oauth', OAuth = 'oauth',
} }
registerEnumType(ServerFeature, {
name: 'ServerFeature',
});
registerEnumType(DeploymentType, {
name: 'ServerDeploymentType',
});
@ObjectType()
export class ServerConfigType {
@Field({
description:
'server identical name could be shown as badge on user interface',
})
name!: string;
@Field({ description: 'server version' })
version!: string;
@Field({ description: 'server base url' })
baseUrl!: string;
@Field(() => DeploymentType, { description: 'server type' })
type!: DeploymentType;
/**
* @deprecated
*/
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
flavor!: string;
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
@Field({ description: 'enable telemetry' })
enableTelemetry!: boolean;
}

View File

@@ -2,7 +2,10 @@ import { Module } from '@nestjs/common';
import { UserModule } from '../user'; import { UserModule } from '../user';
import { EarlyAccessType, FeatureManagementService } from './management'; import { EarlyAccessType, FeatureManagementService } from './management';
import { FeatureManagementResolver } from './resolver'; import {
AdminFeatureManagementResolver,
FeatureManagementResolver,
} from './resolver';
import { FeatureService } from './service'; import { FeatureService } from './service';
/** /**
@@ -17,6 +20,7 @@ import { FeatureService } from './service';
FeatureService, FeatureService,
FeatureManagementService, FeatureManagementService,
FeatureManagementResolver, FeatureManagementResolver,
AdminFeatureManagementResolver,
], ],
exports: [FeatureService, FeatureManagementService], exports: [FeatureService, FeatureManagementService],
}) })

View File

@@ -42,10 +42,6 @@ export class FeatureManagementService {
return this.feature.addUserFeature(userId, FeatureType.Admin, 'Admin user'); return this.feature.addUserFeature(userId, FeatureType.Admin, 'Admin user');
} }
removeAdmin(userId: string) {
return this.feature.removeUserFeature(userId, FeatureType.Admin);
}
// ======== Early Access ======== // ======== Early Access ========
async addEarlyAccess( async addEarlyAccess(
userId: string, userId: string,

View File

@@ -1,21 +1,18 @@
import { import {
Args, Args,
Context,
Int,
Mutation, Mutation,
Parent, Parent,
Query,
registerEnumType, registerEnumType,
ResolveField, ResolveField,
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import { difference } from 'lodash-es';
import { UserNotFound } from '../../fundamentals'; import { Config } from '../../fundamentals';
import { sessionUser } from '../auth/service';
import { Admin } from '../common'; import { Admin } from '../common';
import { UserService } from '../user/service';
import { UserType } from '../user/types'; import { UserType } from '../user/types';
import { EarlyAccessType, FeatureManagementService } from './management'; import { EarlyAccessType, FeatureManagementService } from './management';
import { FeatureService } from './service';
import { FeatureType } from './types'; import { FeatureType } from './types';
registerEnumType(EarlyAccessType, { registerEnumType(EarlyAccessType, {
@@ -24,10 +21,7 @@ registerEnumType(EarlyAccessType, {
@Resolver(() => UserType) @Resolver(() => UserType)
export class FeatureManagementResolver { export class FeatureManagementResolver {
constructor( constructor(private readonly feature: FeatureManagementService) {}
private readonly users: UserService,
private readonly feature: FeatureManagementService
) {}
@ResolveField(() => [FeatureType], { @ResolveField(() => [FeatureType], {
name: 'features', name: 'features',
@@ -36,75 +30,48 @@ export class FeatureManagementResolver {
async userFeatures(@Parent() user: UserType) { async userFeatures(@Parent() user: UserType) {
return this.feature.getActivatedUserFeatures(user.id); return this.feature.getActivatedUserFeatures(user.id);
} }
}
@Admin() export class AvailableUserFeatureConfig {
@Mutation(() => Int) constructor(private readonly config: Config) {}
async addToEarlyAccess(
@Args('email') email: string,
@Args({ name: 'type', type: () => EarlyAccessType }) type: EarlyAccessType
): Promise<number> {
const user = await this.users.findUserByEmail(email);
if (user) {
return this.feature.addEarlyAccess(user.id, type);
} else {
const user = await this.users.createUser({
email,
registered: false,
});
return this.feature.addEarlyAccess(user.id, type);
}
}
@Admin() async availableUserFeatures() {
@Mutation(() => Int) return this.config.isSelfhosted
async removeEarlyAccess( ? [FeatureType.Admin]
@Args('email') email: string, : [FeatureType.EarlyAccess, FeatureType.AIEarlyAccess, FeatureType.Admin];
@Args({ name: 'type', type: () => EarlyAccessType }) type: EarlyAccessType }
): Promise<number> { }
const user = await this.users.findUserByEmail(email);
if (!user) { @Admin()
throw new UserNotFound(); @Resolver(() => Boolean)
} export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
return this.feature.removeEarlyAccess(user.id, type); constructor(
} config: Config,
private readonly feature: FeatureService
@Admin() ) {
@Query(() => [UserType]) super(config);
async earlyAccessUsers( }
@Context() ctx: { isAdminQuery: boolean }
): Promise<UserType[]> { @Mutation(() => [FeatureType], {
// allow query other user's subscription description: 'update user enabled feature',
ctx.isAdminQuery = true; })
return this.feature.listEarlyAccess().then(users => { async updateUserFeatures(
return users.map(sessionUser); @Args('id') id: string,
}); @Args({ name: 'features', type: () => [FeatureType] })
} features: FeatureType[]
) {
@Admin() const configurableFeatures = await this.availableUserFeatures();
@Mutation(() => Boolean)
async addAdminister(@Args('email') email: string): Promise<boolean> { const removed = difference(configurableFeatures, features);
const user = await this.users.findUserByEmail(email); await Promise.all(
features.map(feature =>
if (!user) { this.feature.addUserFeature(id, feature, 'admin panel')
throw new UserNotFound(); )
} );
await Promise.all(
await this.feature.addAdmin(user.id); removed.map(feature => this.feature.removeUserFeature(id, feature))
);
return true;
} return features;
@Admin()
@Mutation(() => Boolean)
async removeAdminister(@Args('email') email: string): Promise<boolean> {
const user = await this.users.findUserByEmail(email);
if (!user) {
throw new UserNotFound();
}
await this.feature.removeAdmin(user.id);
return true;
} }
} }

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { CannotDeleteAllAdminAccount } from '../../fundamentals';
import { WorkspaceType } from '../workspaces/types'; import { WorkspaceType } from '../workspaces/types';
import { FeatureConfigType, getFeature } from './feature'; import { FeatureConfigType, getFeature } from './feature';
import { FeatureKind, FeatureType } from './types'; import { FeatureKind, FeatureType } from './types';
@@ -81,6 +82,9 @@ export class FeatureService {
} }
async removeUserFeature(userId: string, feature: FeatureType) { async removeUserFeature(userId: string, feature: FeatureType) {
if (feature === FeatureType.Admin) {
await this.ensureNotLastAdmin(userId);
}
return this.prisma.userFeature return this.prisma.userFeature
.updateMany({ .updateMany({
where: { where: {
@@ -98,6 +102,20 @@ export class FeatureService {
.then(r => r.count); .then(r => r.count);
} }
async ensureNotLastAdmin(userId: string) {
const count = await this.prisma.userFeature.count({
where: {
userId: { not: userId },
feature: { feature: FeatureType.Admin, type: FeatureKind.Feature },
activated: true,
},
});
if (count === 0) {
throw new CannotDeleteAllAdminAccount();
}
}
/** /**
* get user's features, will included inactivated features * get user's features, will included inactivated features
* @param userId user id * @param userId user id

View File

@@ -12,7 +12,12 @@ import { PrismaClient } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { isNil, omitBy } from 'lodash-es'; import { isNil, omitBy } from 'lodash-es';
import { type FileUpload, Throttle, UserNotFound } from '../../fundamentals'; import {
CannotDeleteOwnAccount,
type FileUpload,
Throttle,
UserNotFound,
} from '../../fundamentals';
import { CurrentUser } from '../auth/current-user'; import { CurrentUser } from '../auth/current-user';
import { Public } from '../auth/guard'; import { Public } from '../auth/guard';
import { sessionUser } from '../auth/service'; import { sessionUser } from '../auth/service';
@@ -162,9 +167,6 @@ class CreateUserInput {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
name!: string | null; name!: string | null;
@Field(() => String, { nullable: true })
password!: string | null;
} }
@Admin() @Admin()
@@ -244,7 +246,6 @@ export class UserManagementResolver {
) { ) {
const { id } = await this.user.createUser({ const { id } = await this.user.createUser({
email: input.email, email: input.email,
password: input.password,
registered: true, registered: true,
}); });
@@ -255,7 +256,13 @@ export class UserManagementResolver {
@Mutation(() => DeleteAccount, { @Mutation(() => DeleteAccount, {
description: 'Delete a user account', description: 'Delete a user account',
}) })
async deleteUser(@Args('id') id: string): Promise<DeleteAccount> { async deleteUser(
@CurrentUser() user: CurrentUser,
@Args('id') id: string
): Promise<DeleteAccount> {
if (user.id === id) {
throw new CannotDeleteOwnAccount();
}
await this.user.deleteUser(id); await this.user.deleteUser(id);
return { success: true }; return { success: true };
} }
@@ -268,26 +275,22 @@ export class UserManagementResolver {
@Args('input') input: ManageUserInput @Args('input') input: ManageUserInput
): Promise<UserType> { ): Promise<UserType> {
const user = await this.db.user.findUnique({ const user = await this.db.user.findUnique({
select: { ...this.user.defaultUserSelect, password: true },
where: { id }, where: { id },
}); });
if (!user) { if (!user) {
throw new UserNotFound(); throw new UserNotFound();
} }
validators.assertValidEmail(input.email);
if (input.email !== user.email) { input = omitBy(input, isNil);
const exists = await this.db.user.findFirst({ if (Object.keys(input).length === 0) {
where: { email: input.email }, return sessionUser(user);
});
if (exists) {
throw new Error('Email already exists');
}
} }
return sessionUser( return sessionUser(
await this.user.updateUser(user.id, { await this.user.updateUser(user.id, {
name: input.name,
email: input.email, email: input.email,
name: input.name,
}) })
); );
} }

View File

@@ -194,9 +194,7 @@ export class UserService {
async updateUser( async updateUser(
id: string, id: string,
data: Omit<Prisma.UserUpdateInput, 'password'> & { data: Omit<Partial<Prisma.UserCreateInput>, 'id'>,
password?: string | null;
},
select: Prisma.UserSelect = this.defaultUserSelect select: Prisma.UserSelect = this.defaultUserSelect
) { ) {
if (data.password) { if (data.password) {
@@ -211,6 +209,23 @@ export class UserService {
data.password = await this.crypto.encryptPassword(data.password); data.password = await this.crypto.encryptPassword(data.password);
} }
if (data.email) {
validators.assertValidEmail(data.email);
const emailTaken = await this.prisma.user.count({
where: {
email: data.email,
id: {
not: id,
},
},
});
if (emailTaken) {
throw new EmailAlreadyUsed();
}
}
const user = await this.prisma.user.update({ where: { id }, data, select }); const user = await this.prisma.user.update({ where: { id }, data, select });
this.emitter.emit('user.updated', user); this.emitter.emit('user.updated', user);

View File

@@ -85,11 +85,11 @@ export class UpdateUserInput implements Partial<User> {
@InputType() @InputType()
export class ManageUserInput { export class ManageUserInput {
@Field({ description: 'User email', nullable: true })
email?: string;
@Field({ description: 'User name', nullable: true }) @Field({ description: 'User name', nullable: true })
name?: string; name?: string;
@Field({ description: 'User email' })
email!: string;
} }
declare module '../../fundamentals/event/def' { declare module '../../fundamentals/event/def' {

View File

@@ -498,4 +498,12 @@ export const USER_FRIENDLY_ERRORS = {
type: 'internal_server_error', type: 'internal_server_error',
message: 'Mailer service is not configured.', message: 'Mailer service is not configured.',
}, },
cannot_delete_all_admin_account: {
type: 'action_forbidden',
message: 'Cannot delete all admin accounts.',
},
cannot_delete_own_account: {
type: 'action_forbidden',
message: 'Cannot delete own account.',
},
} satisfies Record<string, UserFriendlyErrorOptions>; } satisfies Record<string, UserFriendlyErrorOptions>;

View File

@@ -487,6 +487,18 @@ export class MailerServiceIsNotConfigured extends UserFriendlyError {
super('internal_server_error', 'mailer_service_is_not_configured', message); super('internal_server_error', 'mailer_service_is_not_configured', message);
} }
} }
export class CannotDeleteAllAdminAccount extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'cannot_delete_all_admin_account', message);
}
}
export class CannotDeleteOwnAccount extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'cannot_delete_own_account', message);
}
}
export enum ErrorNames { export enum ErrorNames {
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST, TOO_MANY_REQUEST,
@@ -551,7 +563,9 @@ export enum ErrorNames {
COPILOT_QUOTA_EXCEEDED, COPILOT_QUOTA_EXCEEDED,
RUNTIME_CONFIG_NOT_FOUND, RUNTIME_CONFIG_NOT_FOUND,
INVALID_RUNTIME_CONFIG_TYPE, INVALID_RUNTIME_CONFIG_TYPE,
MAILER_SERVICE_IS_NOT_CONFIGURED MAILER_SERVICE_IS_NOT_CONFIGURED,
CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
CANNOT_DELETE_OWN_ACCOUNT
} }
registerEnumType(ErrorNames, { registerEnumType(ErrorNames, {
name: 'ErrorNames' name: 'ErrorNames'

View File

@@ -1,6 +1,6 @@
import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql'; import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql';
import { ServerConfigType } from '../../core/config'; import { ServerConfigType } from '../../core/config/types';
import { OAuthProviderName } from './config'; import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './register'; import { OAuthProviderFactory } from './register';

View File

@@ -152,7 +152,6 @@ input CreateCopilotPromptInput {
input CreateUserInput { input CreateUserInput {
email: String! email: String!
name: String name: String
password: String
} }
type CredentialsRequirementType { type CredentialsRequirementType {
@@ -196,11 +195,6 @@ type DocNotFoundDataType {
workspaceId: String! workspaceId: String!
} }
enum EarlyAccessType {
AI
App
}
union ErrorDataUnion = BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInWorkspaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType | WorkspaceAccessDeniedDataType | WorkspaceNotFoundDataType | WorkspaceOwnerNotFoundDataType union ErrorDataUnion = BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInWorkspaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType | WorkspaceAccessDeniedDataType | WorkspaceNotFoundDataType | WorkspaceOwnerNotFoundDataType
enum ErrorNames { enum ErrorNames {
@@ -209,6 +203,8 @@ enum ErrorNames {
AUTHENTICATION_REQUIRED AUTHENTICATION_REQUIRED
BLOB_NOT_FOUND BLOB_NOT_FOUND
BLOB_QUOTA_EXCEEDED BLOB_QUOTA_EXCEEDED
CANNOT_DELETE_ALL_ADMIN_ACCOUNT
CANNOT_DELETE_OWN_ACCOUNT
CANT_CHANGE_WORKSPACE_OWNER CANT_CHANGE_WORKSPACE_OWNER
CANT_UPDATE_LIFETIME_SUBSCRIPTION CANT_UPDATE_LIFETIME_SUBSCRIPTION
COPILOT_ACTION_TAKEN COPILOT_ACTION_TAKEN
@@ -398,7 +394,7 @@ input ListUserInput {
input ManageUserInput { input ManageUserInput {
"""User email""" """User email"""
email: String! email: String
"""User name""" """User name"""
name: String name: String
@@ -410,8 +406,6 @@ type MissingOauthQueryParameterDataType {
type Mutation { type Mutation {
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
addAdminister(email: String!): Boolean!
addToEarlyAccess(email: String!, type: EarlyAccessType!): Int!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription! cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
changeEmail(email: String!, token: String!): UserType! changeEmail(email: String!, token: String!): UserType!
@@ -456,11 +450,9 @@ type Mutation {
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean! leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean!
publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage! publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage!
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime! recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
removeAdminister(email: String!): Boolean!
"""Remove user avatar""" """Remove user avatar"""
removeAvatar: RemoveAvatar! removeAvatar: RemoveAvatar!
removeEarlyAccess(email: String!, type: EarlyAccessType!): Int!
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription! resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
revoke(userId: String!, workspaceId: String!): Boolean! revoke(userId: String!, workspaceId: String!): Boolean!
@@ -489,6 +481,9 @@ type Mutation {
"""Update a user""" """Update a user"""
updateUser(id: String!, input: ManageUserInput!): UserType! updateUser(id: String!, input: ManageUserInput!): UserType!
"""update user enabled feature"""
updateUserFeatures(features: [FeatureType!]!, id: String!): [FeatureType!]!
"""Update workspace""" """Update workspace"""
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType! updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
@@ -532,7 +527,6 @@ type Query {
"""Get current user""" """Get current user"""
currentUser: UserType currentUser: UserType
earlyAccessUsers: [UserType!]!
error(name: ErrorNames!): ErrorDataUnion! error(name: ErrorNames!): ErrorDataUnion!
"""send workspace invitation""" """send workspace invitation"""

View File

@@ -1,134 +0,0 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import { FeatureType } from '@affine/graphql';
import { CheckIcon, XIcon } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useRightPanel } from '../../layout';
import { useUserManagement } from './use-user-management';
export function CreateUserPanel() {
const { closePanel } = useRightPanel();
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [features, setFeatures] = useState<FeatureType[]>([]);
const disableSave = !name || !email;
const { createUser } = useUserManagement();
const handleConfirm = useCallback(() => {
createUser({
name,
email,
password,
features,
callback: closePanel,
});
}, [closePanel, createUser, email, features, name, password]);
const onEarlyAccessChange = useCallback(
(checked: boolean) => {
setFeatures(
checked
? [...features, FeatureType.AIEarlyAccess]
: features.filter(f => f !== FeatureType.AIEarlyAccess)
);
},
[features]
);
const onAdminChange = useCallback(
(checked: boolean) => {
setFeatures(
checked
? [...features, FeatureType.Admin]
: features.filter(f => f !== FeatureType.Admin)
);
},
[features]
);
return (
<div className="flex flex-col h-full gap-1">
<div className=" flex justify-between items-center py-[10px] px-6">
<Button
type="button"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={closePanel}
>
<XIcon size={20} />
</Button>
<span className="text-base font-medium">Create Account</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleConfirm}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<div className="p-4 flex-grow overflow-y-auto space-y-[10px]">
<div className="flex flex-col rounded-md border py-4 gap-4">
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">Name</Label>
<Input
type="text"
className="py-2 px-3 text-base font-normal"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<Separator />
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">Email</Label>
<Input
type="email"
className="py-2 px-3 ext-base font-normal"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</div>{' '}
<Separator />
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">Password</Label>
<Input
type="password"
className="py-2 px-3 ext-base font-normal"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</div>
</div>
<div className="border rounded-md">
<Label className="flex items-center justify-between px-4 py-3">
<span>Enable AI Access</span>
<Switch
checked={features.includes(FeatureType.AIEarlyAccess)}
onCheckedChange={onEarlyAccessChange}
/>
</Label>
<Separator />
<Label className="flex items-center justify-between px-4 py-3">
<span>Admin</span>
<Switch
checked={features.includes(FeatureType.Admin)}
onCheckedChange={onAdminChange}
/>
</Label>
</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import {
AvatarFallback, AvatarFallback,
AvatarImage, AvatarImage,
} from '@affine/admin/components/ui/avatar'; } from '@affine/admin/components/ui/avatar';
import type { UserType } from '@affine/graphql';
import { FeatureType } from '@affine/graphql'; import { FeatureType } from '@affine/graphql';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -15,7 +16,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { User } from '../schema';
import { DataTableRowActions } from './data-table-row-actions'; import { DataTableRowActions } from './data-table-row-actions';
const StatusItem = ({ const StatusItem = ({
@@ -51,7 +51,7 @@ const StatusItem = ({
</div> </div>
); );
export const columns: ColumnDef<User>[] = [ export const columns: ColumnDef<UserType>[] = [
{ {
accessorKey: 'info', accessorKey: 'info',
cell: ({ row }) => ( cell: ({ row }) => (
@@ -88,13 +88,13 @@ export const columns: ColumnDef<User>[] = [
}, },
{ {
accessorKey: 'property', accessorKey: 'property',
cell: ({ row }) => ( cell: ({ row: { original: user } }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex flex-col gap-2 text-xs max-md:hidden"> <div className="flex flex-col gap-2 text-xs max-md:hidden">
<div className="flex justify-end opacity-25">{row.original.id}</div> <div className="flex justify-end opacity-25">{user.id}</div>
<div className="flex gap-3 items-center justify-end"> <div className="flex gap-3 items-center justify-end">
<StatusItem <StatusItem
condition={row.original.hasPassword} condition={user.hasPassword}
IconTrue={<LockIcon size={10} />} IconTrue={<LockIcon size={10} />}
IconFalse={<UnlockIcon size={10} />} IconFalse={<UnlockIcon size={10} />}
textTrue="Password Set" textTrue="Password Set"
@@ -102,7 +102,7 @@ export const columns: ColumnDef<User>[] = [
/> />
<StatusItem <StatusItem
condition={row.original.emailVerified} condition={user.emailVerified}
IconTrue={<MailIcon size={10} />} IconTrue={<MailIcon size={10} />}
IconFalse={<MailWarningIcon size={10} />} IconFalse={<MailWarningIcon size={10} />}
textTrue="Email Verified" textTrue="Email Verified"
@@ -110,7 +110,7 @@ export const columns: ColumnDef<User>[] = [
/> />
</div> </div>
</div> </div>
<DataTableRowActions row={row} /> <DataTableRowActions user={user} />
</div> </div>
), ),
}, },

View File

@@ -6,7 +6,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@affine/admin/components/ui/dropdown-menu'; } from '@affine/admin/components/ui/dropdown-menu';
import type { Row } from '@tanstack/react-table';
import { import {
LockIcon, LockIcon,
MoreVerticalIcon, MoreVerticalIcon,
@@ -17,29 +16,26 @@ import { useCallback, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useRightPanel } from '../../layout'; import { useRightPanel } from '../../layout';
import { userSchema } from '../schema'; import type { UserType } from '../schema';
import { DeleteAccountDialog } from './delete-account'; import { DeleteAccountDialog } from './delete-account';
import { DiscardChanges } from './discard-changes'; import { DiscardChanges } from './discard-changes';
import { EditPanel } from './edit-panel';
import { ResetPasswordDialog } from './reset-password'; import { ResetPasswordDialog } from './reset-password';
import { useUserManagement } from './use-user-management'; import { useDeleteUser, useResetUserPassword } from './use-user-management';
import { UpdateUserForm } from './user-form';
interface DataTableRowActionsProps<TData> { interface DataTableRowActionsProps {
row: Row<TData>; user: UserType;
} }
export function DataTableRowActions<TData>({ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
row,
}: DataTableRowActionsProps<TData>) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false); const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
const [discardDialogOpen, setDiscardDialogOpen] = useState(false); const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const user = userSchema.parse(row.original);
const { setRightPanelContent, openPanel, isOpen, closePanel } = const { setRightPanelContent, openPanel, isOpen, closePanel } =
useRightPanel(); useRightPanel();
const { deleteUser, resetPasswordLink, onResetPassword } = const deleteUser = useDeleteUser();
useUserManagement(); const { resetPasswordLink, onResetPassword } = useResetUserPassword();
const openResetPasswordDialog = useCallback(() => { const openResetPasswordDialog = useCallback(() => {
onResetPassword(user.id, () => setResetPasswordDialogOpen(true)); onResetPassword(user.id, () => setResetPasswordDialogOpen(true));
@@ -82,8 +78,9 @@ export function DataTableRowActions<TData>({
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
setRightPanelContent( setRightPanelContent(
<EditPanel <UpdateUserForm
user={user} user={user}
onComplete={closePanel}
onResetPassword={openResetPasswordDialog} onResetPassword={openResetPasswordDialog}
onDeleteAccount={openDeleteDialog} onDeleteAccount={openDeleteDialog}
/> />
@@ -96,6 +93,7 @@ export function DataTableRowActions<TData>({
openPanel(); openPanel();
} }
}, [ }, [
closePanel,
discardDialogOpen, discardDialogOpen,
handleDiscardChangesCancel, handleDiscardChangesCancel,
isOpen, isOpen,

View File

@@ -7,8 +7,8 @@ import type { SetStateAction } from 'react';
import { startTransition, useCallback, useEffect, useState } from 'react'; import { startTransition, useCallback, useEffect, useState } from 'react';
import { useRightPanel } from '../../layout'; import { useRightPanel } from '../../layout';
import { CreateUserPanel } from './ceate-user-panel';
import { DiscardChanges } from './discard-changes'; import { DiscardChanges } from './discard-changes';
import { CreateUserForm } from './user-form';
interface DataTableToolbarProps<TData> { interface DataTableToolbarProps<TData> {
data: TData[]; data: TData[];
@@ -38,17 +38,18 @@ export function DataTableToolbar<TData>({
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const debouncedValue = useDebouncedValue(value, 500); const debouncedValue = useDebouncedValue(value, 500);
const { setRightPanelContent, openPanel, isOpen } = useRightPanel(); const { setRightPanelContent, openPanel, closePanel, isOpen } =
useRightPanel();
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
setRightPanelContent(<CreateUserPanel />); setRightPanelContent(<CreateUserForm onComplete={closePanel} />);
if (dialogOpen) { if (dialogOpen) {
setDialogOpen(false); setDialogOpen(false);
} }
if (!isOpen) { if (!isOpen) {
openPanel(); openPanel();
} }
}, [setRightPanelContent, dialogOpen, isOpen, openPanel]); }, [setRightPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
const result = useQuery({ const result = useQuery({
query: getUserByEmailQuery, query: getUserByEmailQuery,

View File

@@ -1,155 +0,0 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import { FeatureType } from '@affine/graphql';
import { CheckIcon, ChevronRightIcon, XIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useRightPanel } from '../../layout';
import type { User } from '../schema';
import { useUserManagement } from './use-user-management';
interface EditPanelProps {
user: User;
onResetPassword: () => void;
onDeleteAccount: () => void;
}
export function EditPanel({
user,
onResetPassword,
onDeleteAccount,
}: EditPanelProps) {
const { closePanel } = useRightPanel();
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
const [features, setFeatures] = useState(user.features);
const { updateUser } = useUserManagement();
const disableSave =
name === user.name && email === user.email && features === user.features;
const onConfirm = useCallback(() => {
updateUser({
userId: user.id,
name,
email,
features,
callback: closePanel,
});
}, [closePanel, email, features, name, updateUser, user.id]);
const onEarlyAccessChange = useCallback(
(checked: boolean) => {
if (checked) {
setFeatures([...features, FeatureType.AIEarlyAccess]);
} else {
setFeatures(features.filter(f => f !== FeatureType.AIEarlyAccess));
}
},
[features]
);
const onAdminChange = useCallback(
(checked: boolean) => {
if (checked) {
setFeatures([...features, FeatureType.Admin]);
} else {
setFeatures(features.filter(f => f !== FeatureType.Admin));
}
},
[features]
);
useEffect(() => {
setName(user.name);
setEmail(user.email);
setFeatures(user.features);
}, [user]);
return (
<div className="flex flex-col h-full gap-1">
<div className=" flex justify-between items-center py-[10px] px-6 ">
<Button
type="button"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={closePanel}
>
<XIcon size={20} />
</Button>
<span className="text-base font-medium">Edit Account</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={onConfirm}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<div className="p-4 flex-grow overflow-y-auto space-y-[10px]">
<div className="flex flex-col rounded-md border py-4 gap-4">
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">Name</Label>
<Input
type="text"
className="py-2 px-3 text-base font-normal"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<Separator />
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">Email</Label>
<Input
type="email"
className="py-2 px-3 ext-base font-normal"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</div>
</div>
<Button
className="w-full flex items-center justify-between text-sm font-medium px-4 py-3"
variant="outline"
onClick={onResetPassword}
>
<span>Reset Password</span>
<ChevronRightIcon size={16} />
</Button>
<div className="border rounded-md">
<Label className="flex items-center justify-between px-4 py-3">
<span>Enable AI Access</span>
<Switch
checked={features.includes(FeatureType.AIEarlyAccess)}
onCheckedChange={onEarlyAccessChange}
/>
</Label>
<Separator />
<Label className="flex items-center justify-between px-4 py-3">
<span>Admin</span>
<Switch
checked={features.includes(FeatureType.Admin)}
onCheckedChange={onAdminChange}
/>
</Label>
</div>
<Button
className="w-full text-red-500 px-4 py-3 rounded-md flex items-center justify-between text-sm font-medium hover:text-red-500"
variant="outline"
onClick={onDeleteAccount}
>
<span>Delete Account</span>
<ChevronRightIcon size={16} />
</Button>
</div>
</div>
);
}

View File

@@ -4,161 +4,103 @@ import {
useMutation, useMutation,
} from '@affine/core/hooks/use-mutation'; } from '@affine/core/hooks/use-mutation';
import { import {
addToAdminMutation,
addToEarlyAccessMutation,
createChangePasswordUrlMutation, createChangePasswordUrlMutation,
createUserMutation, createUserMutation,
deleteUserMutation, deleteUserMutation,
EarlyAccessType,
FeatureType,
listUsersQuery, listUsersQuery,
removeAdminMutation, updateAccountFeaturesMutation,
removeEarlyAccessMutation,
updateAccountMutation, updateAccountMutation,
} from '@affine/graphql'; } from '@affine/graphql';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { UserInput } from '../schema';
export const useCreateUser = () => { export const useCreateUser = () => {
const { trigger: createUser } = useMutation({ const {
trigger: createAccount,
isMutating: creating,
error,
} = useMutation({
mutation: createUserMutation, mutation: createUserMutation,
}); });
const { trigger: addToEarlyAccess } = useMutation({ const { trigger: updateAccountFeatures } = useMutation({
mutation: addToEarlyAccessMutation, mutation: updateAccountFeaturesMutation,
});
const { trigger: addToAdmin } = useMutation({
mutation: addToAdminMutation,
}); });
const revalidate = useMutateQueryResource(); const revalidate = useMutateQueryResource();
const updateFeatures = useCallback(
(email: string, features: FeatureType[]) => {
const shouldAddToAdmin = features.includes(FeatureType.Admin);
const shouldAddToAIEarlyAccess = features.includes(
FeatureType.AIEarlyAccess
);
return Promise.all([
shouldAddToAdmin && addToAdmin({ email }),
shouldAddToAIEarlyAccess &&
addToEarlyAccess({ email, type: EarlyAccessType.AI }),
]);
},
[addToAdmin, addToEarlyAccess]
);
const create = useAsyncCallback( const create = useAsyncCallback(
async ({ async ({ name, email, features }: UserInput) => {
name, try {
email, const account = await createAccount({
password, input: {
features, name,
callback, email,
}: { },
name: string;
email: string;
password: string;
features: FeatureType[];
callback?: () => void;
}) => {
await createUser({
input: {
name,
email,
password,
},
})
.then(async () => {
await updateFeatures(email, features);
await revalidate(listUsersQuery);
toast('User created successfully');
callback?.();
})
.catch(e => {
toast(e.message);
console.error(e);
}); });
await updateAccountFeatures({
userId: account.createUser.id,
features,
});
await revalidate(listUsersQuery);
toast('Account updated successfully');
} catch (e) {
toast.error('Failed to update account: ' + (e as Error).message);
}
}, },
[createUser, revalidate, updateFeatures] [createAccount, revalidate]
); );
return create; return { creating: creating || !!error, create };
}; };
interface UpdateUserProps {
userId: string;
name: string;
email: string;
features: FeatureType[];
callback?: () => void;
}
export const useUpdateUser = () => { export const useUpdateUser = () => {
const { trigger: updateAccount } = useMutation({ const {
trigger: updateAccount,
isMutating: updating,
error,
} = useMutation({
mutation: updateAccountMutation, mutation: updateAccountMutation,
}); });
const { trigger: addToEarlyAccess } = useMutation({ const { trigger: updateAccountFeatures } = useMutation({
mutation: addToEarlyAccessMutation, mutation: updateAccountFeaturesMutation,
});
const { trigger: removeEarlyAccess } = useMutation({
mutation: removeEarlyAccessMutation,
});
const { trigger: addToAdmin } = useMutation({
mutation: addToAdminMutation,
});
const { trigger: removeAdmin } = useMutation({
mutation: removeAdminMutation,
}); });
const revalidate = useMutateQueryResource(); const revalidate = useMutateQueryResource();
const updateFeatures = useCallback(
({ email, features }: { email: string; features: FeatureType[] }) => {
const shoutAddToAdmin = features.includes(FeatureType.Admin);
const shoutAddToAIEarlyAccess = features.includes(
FeatureType.AIEarlyAccess
);
return Promise.all([
shoutAddToAdmin ? addToAdmin({ email }) : removeAdmin({ email }),
shoutAddToAIEarlyAccess
? addToEarlyAccess({ email, type: EarlyAccessType.AI })
: removeEarlyAccess({ email, type: EarlyAccessType.AI }),
]);
},
[addToAdmin, addToEarlyAccess, removeAdmin, removeEarlyAccess]
);
const update = useAsyncCallback( const update = useAsyncCallback(
async ({ userId, name, email, features, callback }: UpdateUserProps) => { async ({
updateAccount({ userId,
id: userId, name,
input: { email,
name, features,
email, }: UserInput & { userId: string }) => {
}, try {
}) await updateAccount({
.then(async () => { id: userId,
await updateFeatures({ email, features }); input: {
await revalidate(listUsersQuery); name,
toast('Account updated successfully'); email,
callback?.(); },
})
.catch(e => {
toast.error('Failed to update account: ' + e.message);
}); });
await updateAccountFeatures({
userId,
features,
});
await revalidate(listUsersQuery);
toast('Account updated successfully');
} catch (e) {
toast.error('Failed to update account: ' + (e as Error).message);
}
}, },
[revalidate, updateAccount, updateFeatures] [revalidate, updateAccount]
); );
return update; return { updating: updating || !!error, update };
}; };
export const useResetUserPassword = () => { export const useResetUserPassword = () => {
@@ -217,20 +159,3 @@ export const useDeleteUser = () => {
return deleteById; return deleteById;
}; };
export const useUserManagement = () => {
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const { resetPasswordLink, onResetPassword } = useResetUserPassword();
return useMemo(() => {
return {
createUser,
updateUser,
deleteUser,
resetPasswordLink,
onResetPassword,
};
}, [createUser, deleteUser, onResetPassword, resetPasswordLink, updateUser]);
};

View File

@@ -0,0 +1,288 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql';
import { CheckIcon, ChevronRightIcon, XIcon } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useServerConfig } from '../../common';
import type { UserInput, UserType } from '../schema';
import { useCreateUser, useUpdateUser } from './use-user-management';
type UserFormProps = {
title: string;
defaultValue?: Partial<UserInput>;
onClose: () => void;
onConfirm: (user: UserInput) => void;
onValidate: (user: Partial<UserInput>) => boolean;
actions?: React.ReactNode;
};
function UserForm({
title,
defaultValue,
onClose,
onConfirm,
onValidate,
actions,
}: UserFormProps) {
const serverConfig = useServerConfig();
const [changes, setChanges] = useState<Partial<UserInput>>({
features: defaultValue?.features ?? [],
});
const setField = useCallback(
<K extends keyof UserInput>(
field: K,
value: UserInput[K] | ((prev: UserInput[K] | undefined) => UserInput[K])
) => {
setChanges(changes => ({
...changes,
[field]:
typeof value === 'function' ? value(changes[field] as any) : value,
}));
},
[]
);
const canSave = useMemo(() => {
return onValidate(changes);
}, [onValidate, changes]);
const handleConfirm = useCallback(() => {
if (!canSave) {
return;
}
// @ts-expect-error checked
onConfirm(changes);
}, [canSave, changes, onConfirm]);
const onFeatureChanged = useCallback(
(feature: FeatureType, checked: boolean) => {
setField('features', (features = []) => {
if (checked) {
return [...features, feature];
}
return features.filter(f => f !== feature);
});
},
[setField]
);
return (
<div className="flex flex-col h-full gap-1">
<div className=" flex justify-between items-center py-[10px] px-6">
<Button
type="button"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={onClose}
>
<XIcon size={20} />
</Button>
<span className="text-base font-medium">{title}</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleConfirm}
disabled={!canSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<div className="p-4 flex-grow overflow-y-auto space-y-[10px]">
<div className="flex flex-col rounded-md border py-4 gap-4">
<InputItem
label="Name"
field="name"
value={changes.name ?? defaultValue?.name}
onChange={setField}
/>
<Separator />
<InputItem
label="Email"
field="email"
value={changes.email ?? defaultValue?.email}
onChange={setField}
/>
</div>
<div className="border rounded-md">
{serverConfig.availableUserFeatures.map((feature, i) => (
<div key={feature}>
<ToggleItem
name={feature}
checked={(
changes.features ??
defaultValue?.features ??
[]
).includes(feature)}
onChange={onFeatureChanged}
/>
{i < serverConfig.availableUserFeatures.length - 1 && (
<Separator />
)}
</div>
))}
</div>
{actions}
</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 px-4 py-3">
<span>{name}</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</Label>
);
}
function InputItem({
label,
field,
value,
onChange,
}: {
label: string;
field: keyof UserInput;
value?: string;
onChange: (field: keyof UserInput, value: string) => void;
}) {
const onValueChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange(field, e.target.value);
},
[field, onChange]
);
return (
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">{label}</Label>
<Input
type="text"
className="py-2 px-3 text-base font-normal"
defaultValue={value}
onChange={onValueChange}
/>
</div>
);
}
const validateCreateUser = (user: Partial<UserInput>) => {
return !!user.name && !!user.email && !!user.features;
};
const validateUpdateUser = (user: Partial<UserInput>) => {
return !!user.name || !!user.email;
};
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
const { create, creating } = useCreateUser();
useEffect(() => {
if (creating) {
return () => {
onComplete();
};
}
return;
}, [creating, onComplete]);
return (
<UserForm
title="Create User"
onClose={onComplete}
onConfirm={create}
onValidate={validateCreateUser}
/>
);
}
export function UpdateUserForm({
user,
onResetPassword,
onDeleteAccount,
onComplete,
}: {
user: UserType;
onResetPassword: () => void;
onDeleteAccount: () => void;
onComplete: () => void;
}) {
const { update, updating } = useUpdateUser();
const onUpdateUser = useCallback(
(updates: UserInput) => {
update({
...updates,
userId: user.id,
});
},
[user, update]
);
useEffect(() => {
if (updating) {
return () => {
onComplete();
};
}
return;
}, [updating, onComplete]);
return (
<UserForm
title="Update User"
defaultValue={user}
onClose={onComplete}
onConfirm={onUpdateUser}
onValidate={validateUpdateUser}
actions={
<>
<Button
className="w-full flex items-center justify-between text-sm font-medium px-4 py-3"
variant="outline"
onClick={onResetPassword}
>
<span>Reset Password</span>
<ChevronRightIcon size={16} />
</Button>
<Button
className="w-full text-red-500 px-4 py-3 rounded-md flex items-center justify-between text-sm font-medium hover:text-red-500"
variant="outline"
onClick={onDeleteAccount}
>
<span>Delete Account</span>
<ChevronRightIcon size={16} />
</Button>
</>
}
/>
);
}

View File

@@ -37,6 +37,7 @@ export function AccountPage() {
<DataTable <DataTable
data={users} data={users}
// @ts-expect-error do not complains
columns={columns} columns={columns}
pagination={pagination} pagination={pagination}
onPaginationChange={setPagination} onPaginationChange={setPagination}

View File

@@ -1,34 +1,8 @@
import { FeatureType } from '@affine/graphql'; import type { FeatureType, ListUsersQuery } from '@affine/graphql';
import { z } from 'zod';
const featureTypeValues = Object.values(FeatureType) as [ export type UserType = ListUsersQuery['users'][0];
FeatureType, export type UserInput = {
...FeatureType[], name: string;
]; email: string;
const featureTypeEnum = z.enum(featureTypeValues); features: FeatureType[];
};
export const userSchema = z.object({
__typename: z.literal('UserType').optional(),
id: z.string(),
name: z.string(),
email: z.string(),
features: z.array(featureTypeEnum),
hasPassword: z.boolean().nullable(),
emailVerified: z.boolean(),
avatarUrl: z.string().nullable(),
quota: z
.object({
__typename: z.literal('UserQuota').optional(),
humanReadable: z.object({
__typename: z.literal('UserQuotaHumanReadable').optional(),
blobLimit: z.string(),
historyPeriod: z.string(),
memberLimit: z.string(),
name: z.string(),
storageQuota: z.string(),
}),
})
.nullable(),
});
export type User = z.infer<typeof userSchema>;

View File

@@ -2,31 +2,21 @@ 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 { useMutateQueryResource } from '@affine/core/hooks/use-mutation'; import { useMutateQueryResource } from '@affine/core/hooks/use-mutation';
import { useQuery } from '@affine/core/hooks/use-query';
import { import {
FeatureType, FeatureType,
getCurrentUserFeaturesQuery, getCurrentUserFeaturesQuery,
getUserFeaturesQuery, getUserFeaturesQuery,
serverConfigQuery,
} from '@affine/graphql'; } from '@affine/graphql';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useCurrentUser, useServerConfig } from '../common';
import logo from './logo.svg'; import logo from './logo.svg';
export function Auth() { export function Auth() {
const { const currentUser = useCurrentUser();
data: { currentUser }, const serverConfig = useServerConfig();
} = useQuery({
query: getCurrentUserFeaturesQuery,
});
const {
data: { serverConfig },
} = useQuery({
query: serverConfigQuery,
});
const revalidate = useMutateQueryResource(); const revalidate = useMutateQueryResource();
const emailRef = useRef<HTMLInputElement>(null); const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null); const passwordRef = useRef<HTMLInputElement>(null);

View File

@@ -0,0 +1,21 @@
import { useQueryImmutable } from '@affine/core/hooks/use-query';
import {
adminServerConfigQuery,
getCurrentUserFeaturesQuery,
} from '@affine/graphql';
export const useServerConfig = () => {
const { data } = useQueryImmutable({
query: adminServerConfigQuery,
});
return data.serverConfig;
};
export const useCurrentUser = () => {
const { data } = useQueryImmutable({
query: getCurrentUserFeaturesQuery,
});
return data.currentUser;
};

View File

@@ -6,7 +6,7 @@ import {
} from '@affine/admin/components/ui/card'; } from '@affine/admin/components/ui/card';
import { ScrollArea } from '@affine/admin/components/ui/scroll-area'; import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import { Separator } from '@affine/admin/components/ui/separator'; import { Separator } from '@affine/admin/components/ui/separator';
import { useQuery } from '@affine/core/hooks/use-query'; import { useQueryImmutable } from '@affine/core/hooks/use-query';
import { getServerServiceConfigsQuery } from '@affine/graphql'; import { getServerServiceConfigsQuery } from '@affine/graphql';
import { Layout } from '../layout'; import { Layout } from '../layout';
@@ -171,7 +171,7 @@ const MailerCard = ({ mailerConfig }: { mailerConfig?: MailerConfig }) => {
}; };
export function ServerServiceConfig() { export function ServerServiceConfig() {
const { data } = useQuery({ const { data } = useQueryImmutable({
query: getServerServiceConfigsQuery, query: getServerServiceConfigsQuery,
}); });
const server = data.serverServiceConfigs.find( const server = data.serverServiceConfigs.find(

View File

@@ -7,11 +7,7 @@ import { Separator } from '@affine/admin/components/ui/separator';
import { TooltipProvider } from '@affine/admin/components/ui/tooltip'; import { TooltipProvider } from '@affine/admin/components/ui/tooltip';
import { cn } from '@affine/admin/utils'; import { cn } from '@affine/admin/utils';
import { useQuery } from '@affine/core/hooks/use-query'; import { useQuery } from '@affine/core/hooks/use-query';
import { import { FeatureType, getCurrentUserFeaturesQuery } from '@affine/graphql';
FeatureType,
getCurrentUserFeaturesQuery,
serverConfigQuery,
} from '@affine/graphql';
import { AlignJustifyIcon } from 'lucide-react'; import { AlignJustifyIcon } from 'lucide-react';
import type { ReactNode, RefObject } from 'react'; import type { ReactNode, RefObject } from 'react';
import { import {
@@ -36,6 +32,7 @@ import {
SheetTrigger, SheetTrigger,
} from '../components/ui/sheet'; } from '../components/ui/sheet';
import { Logo } from './accounts/components/logo'; import { Logo } from './accounts/components/logo';
import { useServerConfig } from './common';
import { NavContext } from './nav/context'; import { NavContext } from './nav/context';
import { Nav } from './nav/nav'; import { Nav } from './nav/nav';
@@ -85,6 +82,13 @@ export function useMediaQuery(query: string) {
} }
export function Layout({ content }: LayoutProps) { export function Layout({ content }: LayoutProps) {
const serverConfig = useServerConfig();
const {
data: { currentUser },
} = useQuery({
query: getCurrentUserFeaturesQuery,
});
const [rightPanelContent, setRightPanelContent] = useState<ReactNode>(null); const [rightPanelContent, setRightPanelContent] = useState<ReactNode>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const rightPanelRef = useRef<ImperativePanelHandle>(null); const rightPanelRef = useRef<ImperativePanelHandle>(null);
@@ -122,16 +126,6 @@ export function Layout({ content }: LayoutProps) {
[closePanel, openPanel] [closePanel, openPanel]
); );
const {
data: { serverConfig },
} = useQuery({
query: serverConfigQuery,
});
const {
data: { currentUser },
} = useQuery({
query: getCurrentUserFeaturesQuery,
});
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {

View File

@@ -12,29 +12,17 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@affine/admin/components/ui/dropdown-menu'; } from '@affine/admin/components/ui/dropdown-menu';
import { useQuery } from '@affine/core/hooks/use-query'; import { FeatureType } from '@affine/graphql';
import {
FeatureType,
getCurrentUserFeaturesQuery,
serverConfigQuery,
} from '@affine/graphql';
import { CircleUser, MoreVertical } from 'lucide-react'; import { CircleUser, MoreVertical } from 'lucide-react';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
export function UserDropdown() { import { useCurrentUser, useServerConfig } from '../common';
const {
data: { currentUser },
} = useQuery({
query: getCurrentUserFeaturesQuery,
});
const { export function UserDropdown() {
data: { serverConfig }, const currentUser = useCurrentUser();
} = useQuery({ const serverConfig = useServerConfig();
query: serverConfigQuery,
});
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -7,12 +7,12 @@ import {
} from '@affine/admin/components/ui/carousel'; } from '@affine/admin/components/ui/carousel';
import { validateEmailAndPassword } from '@affine/admin/utils'; import { validateEmailAndPassword } from '@affine/admin/utils';
import { useMutateQueryResource } from '@affine/core/hooks/use-mutation'; import { useMutateQueryResource } from '@affine/core/hooks/use-mutation';
import { useQuery } from '@affine/core/hooks/use-query';
import { serverConfigQuery } from '@affine/graphql'; import { serverConfigQuery } from '@affine/graphql';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useServerConfig } from '../common';
import { CreateAdmin } from './create-admin'; import { CreateAdmin } from './create-admin';
export enum CarouselSteps { export enum CarouselSteps {
@@ -72,10 +72,8 @@ export const Form = () => {
const [invalidEmail, setInvalidEmail] = useState(false); const [invalidEmail, setInvalidEmail] = useState(false);
const [invalidPassword, setInvalidPassword] = useState(false); const [invalidPassword, setInvalidPassword] = useState(false);
const { data } = useQuery({ const serverConfig = useServerConfig();
query: serverConfigQuery, const passwordLimits = serverConfig.credentialsRequirement.password;
});
const passwordLimits = data.serverConfig.credentialsRequirement.password;
const isCreateAdminStep = current - 1 === CarouselSteps.CreateAdmin; const isCreateAdminStep = current - 1 === CarouselSteps.CreateAdmin;
@@ -95,7 +93,7 @@ export const Form = () => {
api.on('select', () => { api.on('select', () => {
setCurrent(api.selectedScrollSnap() + 1); setCurrent(api.selectedScrollSnap() + 1);
}); });
}, [api, data.serverConfig.initialized, navigate]); }, [api, serverConfig.initialized, navigate]);
const createAdmin = useCallback(async () => { const createAdmin = useCallback(async () => {
try { try {
@@ -170,14 +168,14 @@ export const Form = () => {
const onPrevious = useCallback(() => { const onPrevious = useCallback(() => {
if (current === count) { if (current === count) {
if (data.serverConfig.initialized === true) { if (serverConfig.initialized === true) {
return navigate('/admin', { replace: true }); return navigate('/admin', { replace: true });
} }
toast.error('Goto Admin Panel failed, please try again.'); toast.error('Goto Admin Panel failed, please try again.');
return; return;
} }
api?.scrollPrev(); api?.scrollPrev();
}, [api, count, current, data.serverConfig.initialized, navigate]); }, [api, count, current, serverConfig.initialized, navigate]);
return ( return (
<div className="flex flex-col justify-between h-full w-full lg:pl-36 max-lg:items-center "> <div className="flex flex-col justify-between h-full w-full lg:pl-36 max-lg:items-center ">

View File

@@ -35,7 +35,7 @@ export function useMutation<Mutation extends GraphQLQuery, K extends Key = Key>(
config?: Omit< config?: Omit<
SWRMutationConfiguration< SWRMutationConfiguration<
QueryResponse<Mutation>, QueryResponse<Mutation>,
GraphQLError | GraphQLError[], GraphQLError,
K, K,
QueryVariables<Mutation> QueryVariables<Mutation>
>, >,
@@ -43,7 +43,7 @@ export function useMutation<Mutation extends GraphQLQuery, K extends Key = Key>(
> >
): SWRMutationResponse< ): SWRMutationResponse<
QueryResponse<Mutation>, QueryResponse<Mutation>,
GraphQLError | GraphQLError[], GraphQLError,
K, K,
QueryVariables<Mutation> QueryVariables<Mutation>
>; >;

View File

@@ -32,16 +32,12 @@ import useSWRInfinite from 'swr/infinite';
type useQueryFn = <Query extends GraphQLQuery>( type useQueryFn = <Query extends GraphQLQuery>(
options?: QueryOptions<Query>, options?: QueryOptions<Query>,
config?: Omit< config?: Omit<
SWRConfiguration< SWRConfiguration<QueryResponse<Query>, GraphQLError, typeof fetcher<Query>>,
QueryResponse<Query>,
GraphQLError | GraphQLError[],
typeof fetcher<Query>
>,
'fetcher' 'fetcher'
> >
) => SWRResponse< ) => SWRResponse<
QueryResponse<Query>, QueryResponse<Query>,
GraphQLError | GraphQLError[], GraphQLError,
{ {
suspense: true; suspense: true;
} }

View File

@@ -1,3 +0,0 @@
mutation addToAdmin($email: String!) {
addAdminister(email: $email)
}

View File

@@ -0,0 +1,17 @@
#import './fragments/password-limits.gql'
#import './fragments/credentials-requirement.gql'
query adminServerConfig {
serverConfig {
version
baseUrl
name
features
type
initialized
credentialsRequirement {
...CredentialsRequirement
}
availableUserFeatures
}
}

View File

@@ -1,3 +0,0 @@
mutation addToEarlyAccess($email: String!, $type: EarlyAccessType!) {
addToEarlyAccess(email: $email, type: $type)
}

View File

@@ -1,16 +0,0 @@
query earlyAccessUsers {
earlyAccessUsers {
id
name
email
avatarUrl
emailVerified
subscription {
plan
recurring
status
start
end
}
}
}

View File

@@ -1,3 +0,0 @@
mutation removeEarlyAccess($email: String!, $type: EarlyAccessType!) {
removeEarlyAccess(email: $email, type: $type)
}

View File

@@ -18,15 +18,27 @@ fragment CredentialsRequirement on CredentialsRequirementType {
...PasswordLimits ...PasswordLimits
} }
}` }`
export const addToAdminMutation = { export const adminServerConfigQuery = {
id: 'addToAdminMutation' as const, id: 'adminServerConfigQuery' as const,
operationName: 'addToAdmin', operationName: 'adminServerConfig',
definitionName: 'addAdminister', definitionName: 'serverConfig',
containsFile: false, containsFile: false,
query: ` query: `
mutation addToAdmin($email: String!) { query adminServerConfig {
addAdminister(email: $email) serverConfig {
}`, version
baseUrl
name
features
type
initialized
credentialsRequirement {
...CredentialsRequirement
}
availableUserFeatures
}
}${passwordLimitsFragment}
${credentialsRequirementFragment}`,
}; };
export const deleteBlobMutation = { export const deleteBlobMutation = {
@@ -254,52 +266,6 @@ mutation deleteWorkspace($id: String!) {
}`, }`,
}; };
export const addToEarlyAccessMutation = {
id: 'addToEarlyAccessMutation' as const,
operationName: 'addToEarlyAccess',
definitionName: 'addToEarlyAccess',
containsFile: false,
query: `
mutation addToEarlyAccess($email: String!, $type: EarlyAccessType!) {
addToEarlyAccess(email: $email, type: $type)
}`,
};
export const earlyAccessUsersQuery = {
id: 'earlyAccessUsersQuery' as const,
operationName: 'earlyAccessUsers',
definitionName: 'earlyAccessUsers',
containsFile: false,
query: `
query earlyAccessUsers {
earlyAccessUsers {
id
name
email
avatarUrl
emailVerified
subscription {
plan
recurring
status
start
end
}
}
}`,
};
export const removeEarlyAccessMutation = {
id: 'removeEarlyAccessMutation' as const,
operationName: 'removeEarlyAccess',
definitionName: 'removeEarlyAccess',
containsFile: false,
query: `
mutation removeEarlyAccess($email: String!, $type: EarlyAccessType!) {
removeEarlyAccess(email: $email, type: $type)
}`,
};
export const forkCopilotSessionMutation = { export const forkCopilotSessionMutation = {
id: 'forkCopilotSessionMutation' as const, id: 'forkCopilotSessionMutation' as const,
operationName: 'forkCopilotSession', operationName: 'forkCopilotSession',
@@ -803,15 +769,6 @@ query listUsers($filter: ListUserInput!) {
hasPassword hasPassword
emailVerified emailVerified
avatarUrl avatarUrl
quota {
humanReadable {
blobLimit
historyPeriod
memberLimit
name
storageQuota
}
}
} }
}`, }`,
}; };
@@ -889,17 +846,6 @@ mutation recoverDoc($workspaceId: String!, $docId: String!, $timestamp: DateTime
}`, }`,
}; };
export const removeAdminMutation = {
id: 'removeAdminMutation' as const,
operationName: 'removeAdmin',
definitionName: 'removeAdminister',
containsFile: false,
query: `
mutation removeAdmin($email: String!) {
removeAdminister(email: $email)
}`,
};
export const removeAvatarMutation = { export const removeAvatarMutation = {
id: 'removeAvatarMutation' as const, id: 'removeAvatarMutation' as const,
operationName: 'removeAvatar', operationName: 'removeAvatar',
@@ -1024,7 +970,6 @@ query serverConfig {
name name
features features
type type
initialized
credentialsRequirement { credentialsRequirement {
...CredentialsRequirement ...CredentialsRequirement
} }
@@ -1069,6 +1014,17 @@ query subscription {
}`, }`,
}; };
export const updateAccountFeaturesMutation = {
id: 'updateAccountFeaturesMutation' as const,
operationName: 'updateAccountFeatures',
definitionName: 'updateUserFeatures',
containsFile: false,
query: `
mutation updateAccountFeatures($userId: String!, $features: [FeatureType!]!) {
updateUserFeatures(id: $userId, features: $features)
}`,
};
export const updateAccountMutation = { export const updateAccountMutation = {
id: 'updateAccountMutation' as const, id: 'updateAccountMutation' as const,
operationName: 'updateAccount', operationName: 'updateAccount',

View File

@@ -7,14 +7,5 @@ query listUsers($filter: ListUserInput!) {
hasPassword hasPassword
emailVerified emailVerified
avatarUrl avatarUrl
quota {
humanReadable {
blobLimit
historyPeriod
memberLimit
name
storageQuota
}
}
} }
} }

View File

@@ -1,3 +0,0 @@
mutation removeAdmin($email: String!) {
removeAdminister(email: $email)
}

View File

@@ -8,7 +8,6 @@ query serverConfig {
name name
features features
type type
initialized
credentialsRequirement { credentialsRequirement {
...CredentialsRequirement ...CredentialsRequirement
} }

View File

@@ -0,0 +1,3 @@
mutation updateAccountFeatures($userId: String!, $features: [FeatureType!]!) {
updateUserFeatures(id: $userId, features: $features)
}

View File

@@ -200,7 +200,6 @@ export interface CreateCopilotPromptInput {
export interface CreateUserInput { export interface CreateUserInput {
email: Scalars['String']['input']; email: Scalars['String']['input'];
name: InputMaybe<Scalars['String']['input']>; name: InputMaybe<Scalars['String']['input']>;
password: InputMaybe<Scalars['String']['input']>;
} }
export interface CredentialsRequirementType { export interface CredentialsRequirementType {
@@ -245,11 +244,6 @@ export interface DocNotFoundDataType {
workspaceId: Scalars['String']['output']; workspaceId: Scalars['String']['output'];
} }
export enum EarlyAccessType {
AI = 'AI',
App = 'App',
}
export type ErrorDataUnion = export type ErrorDataUnion =
| BlobNotFoundDataType | BlobNotFoundDataType
| CopilotMessageNotFoundDataType | CopilotMessageNotFoundDataType
@@ -280,6 +274,8 @@ export enum ErrorNames {
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED', AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
BLOB_NOT_FOUND = 'BLOB_NOT_FOUND', BLOB_NOT_FOUND = 'BLOB_NOT_FOUND',
BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED', BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED',
CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT',
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
CANT_CHANGE_WORKSPACE_OWNER = 'CANT_CHANGE_WORKSPACE_OWNER', CANT_CHANGE_WORKSPACE_OWNER = 'CANT_CHANGE_WORKSPACE_OWNER',
CANT_UPDATE_LIFETIME_SUBSCRIPTION = 'CANT_UPDATE_LIFETIME_SUBSCRIPTION', CANT_UPDATE_LIFETIME_SUBSCRIPTION = 'CANT_UPDATE_LIFETIME_SUBSCRIPTION',
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN', COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
@@ -455,7 +451,7 @@ export interface ListUserInput {
export interface ManageUserInput { export interface ManageUserInput {
/** User email */ /** User email */
email: Scalars['String']['input']; email: InputMaybe<Scalars['String']['input']>;
/** User name */ /** User name */
name: InputMaybe<Scalars['String']['input']>; name: InputMaybe<Scalars['String']['input']>;
} }
@@ -468,8 +464,6 @@ export interface MissingOauthQueryParameterDataType {
export interface Mutation { export interface Mutation {
__typename?: 'Mutation'; __typename?: 'Mutation';
acceptInviteById: Scalars['Boolean']['output']; acceptInviteById: Scalars['Boolean']['output'];
addAdminister: Scalars['Boolean']['output'];
addToEarlyAccess: Scalars['Int']['output'];
addWorkspaceFeature: Scalars['Int']['output']; addWorkspaceFeature: Scalars['Int']['output'];
cancelSubscription: UserSubscription; cancelSubscription: UserSubscription;
changeEmail: UserType; changeEmail: UserType;
@@ -503,10 +497,8 @@ export interface Mutation {
leaveWorkspace: Scalars['Boolean']['output']; leaveWorkspace: Scalars['Boolean']['output'];
publishPage: WorkspacePage; publishPage: WorkspacePage;
recoverDoc: Scalars['DateTime']['output']; recoverDoc: Scalars['DateTime']['output'];
removeAdminister: Scalars['Boolean']['output'];
/** Remove user avatar */ /** Remove user avatar */
removeAvatar: RemoveAvatar; removeAvatar: RemoveAvatar;
removeEarlyAccess: Scalars['Int']['output'];
removeWorkspaceFeature: Scalars['Int']['output']; removeWorkspaceFeature: Scalars['Int']['output'];
resumeSubscription: UserSubscription; resumeSubscription: UserSubscription;
revoke: Scalars['Boolean']['output']; revoke: Scalars['Boolean']['output'];
@@ -532,6 +524,8 @@ export interface Mutation {
updateSubscriptionRecurring: UserSubscription; updateSubscriptionRecurring: UserSubscription;
/** Update a user */ /** Update a user */
updateUser: UserType; updateUser: UserType;
/** update user enabled feature */
updateUserFeatures: Array<FeatureType>;
/** Update workspace */ /** Update workspace */
updateWorkspace: WorkspaceType; updateWorkspace: WorkspaceType;
/** Upload user avatar */ /** Upload user avatar */
@@ -545,15 +539,6 @@ export interface MutationAcceptInviteByIdArgs {
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
} }
export interface MutationAddAdministerArgs {
email: Scalars['String']['input'];
}
export interface MutationAddToEarlyAccessArgs {
email: Scalars['String']['input'];
type: EarlyAccessType;
}
export interface MutationAddWorkspaceFeatureArgs { export interface MutationAddWorkspaceFeatureArgs {
feature: FeatureType; feature: FeatureType;
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
@@ -649,15 +634,6 @@ export interface MutationRecoverDocArgs {
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
} }
export interface MutationRemoveAdministerArgs {
email: Scalars['String']['input'];
}
export interface MutationRemoveEarlyAccessArgs {
email: Scalars['String']['input'];
type: EarlyAccessType;
}
export interface MutationRemoveWorkspaceFeatureArgs { export interface MutationRemoveWorkspaceFeatureArgs {
feature: FeatureType; feature: FeatureType;
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
@@ -753,6 +729,11 @@ export interface MutationUpdateUserArgs {
input: ManageUserInput; input: ManageUserInput;
} }
export interface MutationUpdateUserFeaturesArgs {
features: Array<FeatureType>;
id: Scalars['String']['input'];
}
export interface MutationUpdateWorkspaceArgs { export interface MutationUpdateWorkspaceArgs {
input: UpdateWorkspaceInput; input: UpdateWorkspaceInput;
} }
@@ -804,7 +785,6 @@ export interface Query {
collectAllBlobSizes: WorkspaceBlobSizes; collectAllBlobSizes: WorkspaceBlobSizes;
/** Get current user */ /** Get current user */
currentUser: Maybe<UserType>; currentUser: Maybe<UserType>;
earlyAccessUsers: Array<UserType>;
error: ErrorDataUnion; error: ErrorDataUnion;
/** send workspace invitation */ /** send workspace invitation */
getInviteInfo: InvitationType; getInviteInfo: InvitationType;
@@ -932,6 +912,8 @@ export interface SameSubscriptionRecurringDataType {
export interface ServerConfigType { export interface ServerConfigType {
__typename?: 'ServerConfigType'; __typename?: 'ServerConfigType';
/** Features for user that can be configured */
availableUserFeatures: Array<FeatureType>;
/** server base url */ /** server base url */
baseUrl: Scalars['String']['output']; baseUrl: Scalars['String']['output'];
/** credentials requirement */ /** credentials requirement */
@@ -1254,13 +1236,28 @@ export interface TokenType {
token: Scalars['String']['output']; token: Scalars['String']['output'];
} }
export type AddToAdminMutationVariables = Exact<{ export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>;
email: Scalars['String']['input'];
}>;
export type AddToAdminMutation = { export type AdminServerConfigQuery = {
__typename?: 'Mutation'; __typename?: 'Query';
addAdminister: boolean; serverConfig: {
__typename?: 'ServerConfigType';
version: string;
baseUrl: string;
name: string;
features: Array<ServerFeature>;
type: ServerDeploymentType;
initialized: boolean;
availableUserFeatures: Array<FeatureType>;
credentialsRequirement: {
__typename?: 'CredentialsRequirementType';
password: {
__typename?: 'PasswordLimitsType';
minLength: number;
maxLength: number;
};
};
};
}; };
export type DeleteBlobMutationVariables = Exact<{ export type DeleteBlobMutationVariables = Exact<{
@@ -1440,48 +1437,6 @@ export type DeleteWorkspaceMutation = {
deleteWorkspace: boolean; deleteWorkspace: boolean;
}; };
export type AddToEarlyAccessMutationVariables = Exact<{
email: Scalars['String']['input'];
type: EarlyAccessType;
}>;
export type AddToEarlyAccessMutation = {
__typename?: 'Mutation';
addToEarlyAccess: number;
};
export type EarlyAccessUsersQueryVariables = Exact<{ [key: string]: never }>;
export type EarlyAccessUsersQuery = {
__typename?: 'Query';
earlyAccessUsers: Array<{
__typename?: 'UserType';
id: string;
name: string;
email: string;
avatarUrl: string | null;
emailVerified: boolean;
subscription: {
__typename?: 'UserSubscription';
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
status: SubscriptionStatus;
start: string;
end: string | null;
} | null;
}>;
};
export type RemoveEarlyAccessMutationVariables = Exact<{
email: Scalars['String']['input'];
type: EarlyAccessType;
}>;
export type RemoveEarlyAccessMutation = {
__typename?: 'Mutation';
removeEarlyAccess: number;
};
export type ForkCopilotSessionMutationVariables = Exact<{ export type ForkCopilotSessionMutationVariables = Exact<{
options: ForkChatSessionInput; options: ForkChatSessionInput;
}>; }>;
@@ -1956,17 +1911,6 @@ export type ListUsersQuery = {
hasPassword: boolean | null; hasPassword: boolean | null;
emailVerified: boolean; emailVerified: boolean;
avatarUrl: string | null; avatarUrl: string | null;
quota: {
__typename?: 'UserQuota';
humanReadable: {
__typename?: 'UserQuotaHumanReadable';
blobLimit: string;
historyPeriod: string;
memberLimit: string;
name: string;
storageQuota: string;
};
} | null;
}>; }>;
}; };
@@ -2038,15 +1982,6 @@ export type RecoverDocMutation = {
recoverDoc: string; recoverDoc: string;
}; };
export type RemoveAdminMutationVariables = Exact<{
email: Scalars['String']['input'];
}>;
export type RemoveAdminMutation = {
__typename?: 'Mutation';
removeAdminister: boolean;
};
export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>; export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>;
export type RemoveAvatarMutation = { export type RemoveAvatarMutation = {
@@ -2154,7 +2089,6 @@ export type ServerConfigQuery = {
name: string; name: string;
features: Array<ServerFeature>; features: Array<ServerFeature>;
type: ServerDeploymentType; type: ServerDeploymentType;
initialized: boolean;
credentialsRequirement: { credentialsRequirement: {
__typename?: 'CredentialsRequirementType'; __typename?: 'CredentialsRequirementType';
password: { password: {
@@ -2197,6 +2131,16 @@ export type SubscriptionQuery = {
} | null; } | null;
}; };
export type UpdateAccountFeaturesMutationVariables = Exact<{
userId: Scalars['String']['input'];
features: Array<FeatureType> | FeatureType;
}>;
export type UpdateAccountFeaturesMutation = {
__typename?: 'Mutation';
updateUserFeatures: Array<FeatureType>;
};
export type UpdateAccountMutationVariables = Exact<{ export type UpdateAccountMutationVariables = Exact<{
id: Scalars['String']['input']; id: Scalars['String']['input'];
input: ManageUserInput; input: ManageUserInput;
@@ -2422,6 +2366,11 @@ export type WorkspaceQuotaQuery = {
}; };
export type Queries = export type Queries =
| {
name: 'adminServerConfigQuery';
variables: AdminServerConfigQueryVariables;
response: AdminServerConfigQuery;
}
| { | {
name: 'listBlobsQuery'; name: 'listBlobsQuery';
variables: ListBlobsQueryVariables; variables: ListBlobsQueryVariables;
@@ -2432,11 +2381,6 @@ export type Queries =
variables: CopilotQuotaQueryVariables; variables: CopilotQuotaQueryVariables;
response: CopilotQuotaQuery; response: CopilotQuotaQuery;
} }
| {
name: 'earlyAccessUsersQuery';
variables: EarlyAccessUsersQueryVariables;
response: EarlyAccessUsersQuery;
}
| { | {
name: 'getCopilotHistoriesQuery'; name: 'getCopilotHistoriesQuery';
variables: GetCopilotHistoriesQueryVariables; variables: GetCopilotHistoriesQueryVariables;
@@ -2614,11 +2558,6 @@ export type Queries =
}; };
export type Mutations = export type Mutations =
| {
name: 'addToAdminMutation';
variables: AddToAdminMutationVariables;
response: AddToAdminMutation;
}
| { | {
name: 'deleteBlobMutation'; name: 'deleteBlobMutation';
variables: DeleteBlobMutationVariables; variables: DeleteBlobMutationVariables;
@@ -2699,16 +2638,6 @@ export type Mutations =
variables: DeleteWorkspaceMutationVariables; variables: DeleteWorkspaceMutationVariables;
response: DeleteWorkspaceMutation; response: DeleteWorkspaceMutation;
} }
| {
name: 'addToEarlyAccessMutation';
variables: AddToEarlyAccessMutationVariables;
response: AddToEarlyAccessMutation;
}
| {
name: 'removeEarlyAccessMutation';
variables: RemoveEarlyAccessMutationVariables;
response: RemoveEarlyAccessMutation;
}
| { | {
name: 'forkCopilotSessionMutation'; name: 'forkCopilotSessionMutation';
variables: ForkCopilotSessionMutationVariables; variables: ForkCopilotSessionMutationVariables;
@@ -2729,11 +2658,6 @@ export type Mutations =
variables: RecoverDocMutationVariables; variables: RecoverDocMutationVariables;
response: RecoverDocMutation; response: RecoverDocMutation;
} }
| {
name: 'removeAdminMutation';
variables: RemoveAdminMutationVariables;
response: RemoveAdminMutation;
}
| { | {
name: 'removeAvatarMutation'; name: 'removeAvatarMutation';
variables: RemoveAvatarMutationVariables; variables: RemoveAvatarMutationVariables;
@@ -2784,6 +2708,11 @@ export type Mutations =
variables: SetWorkspacePublicByIdMutationVariables; variables: SetWorkspacePublicByIdMutationVariables;
response: SetWorkspacePublicByIdMutation; response: SetWorkspacePublicByIdMutation;
} }
| {
name: 'updateAccountFeaturesMutation';
variables: UpdateAccountFeaturesMutationVariables;
response: UpdateAccountFeaturesMutation;
}
| { | {
name: 'updateAccountMutation'; name: 'updateAccountMutation';
variables: UpdateAccountMutationVariables; variables: UpdateAccountMutationVariables;