From 2bf8027dffa7c2e82df0a11fa823770ceea35730 Mon Sep 17 00:00:00 2001 From: SkyHuang <906268297@qq.com> Date: Fri, 28 Jun 2024 16:05:16 +0800 Subject: [PATCH] feat: billing (#672) * feat: space supports displaying the plan level * chore: update icons and table component * feat: add the PAYMENT_REQUIRED http code * feat: admin user & setting config * feat: usage limit * feat: add paste checker for usage * chore: db migration * feat: user limit for license * feat: admin settings * refactor: use generics as the type for the custom ssrApi * fix: type error * fix: setting for disallow signup * refactor: obtain the settings from the database instead of from cls --- apps/nestjs-backend/src/app.module.ts | 2 + .../nestjs-backend/src/configs/base.config.ts | 1 + apps/nestjs-backend/src/custom.exception.ts | 2 + .../src/event-emitter/events/event.enum.ts | 3 + .../src/event-emitter/events/index.ts | 1 + .../events/space/collaborator.event.ts | 13 +++ .../event-emitter/events/user/user.event.ts | 7 ++ .../auth/strategies/access-token.strategy.ts | 3 + .../auth/strategies/github.strategy.ts | 5 +- .../auth/strategies/google.strategy.ts | 5 +- .../auth/strategies/local.strategy.ts | 3 + .../auth/strategies/session.strategy.ts | 3 + .../nestjs-backend/src/features/auth/utils.ts | 4 +- .../collaborator/collaborator.service.ts | 21 +++- .../src/features/next/next.controller.ts | 1 + .../features/selection/selection.service.ts | 7 +- .../src/features/setting/admin.guard.ts | 27 +++++ .../features/setting/setting.controller.ts | 27 +++++ .../src/features/setting/setting.module.ts | 11 +++ .../src/features/setting/setting.service.ts | 30 ++++++ .../src/features/space/space.service.ts | 12 +++ .../src/features/user/user.service.ts | 27 ++++- .../src/backend/api/rest/table.ssr.ts | 12 +++ .../src/features/app/blocks/admin/index.ts | 1 + .../app/blocks/admin/setting/SettingPage.tsx | 78 +++++++++++++++ .../admin/setting/components/CopyInstance.tsx | 19 ++++ .../blocks/admin/setting/components/index.ts | 1 + .../app/blocks/admin/setting/index.ts | 2 + .../blocks/base/base-side-bar/BaseSideBar.tsx | 98 ++++++++++++++----- .../app/blocks/share/view/ShareViewPage.tsx | 1 + .../features/app/blocks/space/SpaceCard.tsx | 11 ++- .../app/blocks/space/SpaceInnerPage.tsx | 20 +++- .../features/app/blocks/space/SpacePage.tsx | 44 ++++++--- .../blocks/space/space-side-bar/SpaceList.tsx | 22 +++-- .../space/space-side-bar/SpaceSideBar.tsx | 17 +++- .../table/table-header/Collaborators.tsx | 2 +- .../features/app/components/billing/Level.tsx | 14 +++ .../components/billing/LevelWithUpgrade.tsx | 60 ++++++++++++ .../components/billing/UsageLimitModal.tsx | 66 +++++++++++++ .../space/Collaborator.tsx | 23 +++++ .../space/Collaborators.tsx | 10 +- .../app/components/sidebar/SidebarContent.tsx | 66 +++++++++---- .../features/app/components/user/UserNav.tsx | 10 +- .../app/hooks/useBillingLevelConfig.ts | 35 +++++++ .../src/features/app/hooks/useIsCloud.ts | 7 ++ .../src/features/app/hooks/useSetting.ts | 18 ++++ .../src/features/app/layouts/AdminLayout.tsx | 64 ++++++++++++ .../src/features/app/layouts/BaseLayout.tsx | 2 + .../src/features/app/layouts/SpaceLayout.tsx | 2 +- .../src/features/auth/pages/LoginPage.tsx | 34 ++++--- apps/nextjs-app/src/lib/server-env.ts | 1 + apps/nextjs-app/src/lib/withAuthSSR.ts | 15 ++- apps/nextjs-app/src/pages/admin/setting.tsx | 39 ++++++++ apps/nextjs-app/src/pages/auth/login.tsx | 10 +- .../common-i18n/src/locales/en/common.json | 35 ++++++- .../common-i18n/src/locales/zh/common.json | 35 ++++++- packages/core/src/errors/http/constant.ts | 2 + .../src/errors/http/http-response.types.ts | 4 + packages/core/src/utils/id-generator.ts | 6 ++ .../20240625032002_add_admin/migration.sql | 3 + .../migration.sql | 11 +++ .../prisma/postgres/schema.prisma | 10 ++ .../20240625031955_add_admin/migration.sql | 3 + .../migration.sql | 11 +++ .../prisma/sqlite/schema.prisma | 10 ++ .../db-main-prisma/prisma/template.prisma | 10 ++ packages/icons/src/components/Admin.tsx | 18 ++++ packages/icons/src/components/License.tsx | 22 +++++ packages/icons/src/index.ts | 2 + packages/openapi/src/auth/user-me.ts | 1 + packages/openapi/src/billing/index.ts | 1 + .../get-subscription-summary-list.ts | 30 ++++++ .../subscription/get-subscription-summary.ts | 55 +++++++++++ .../openapi/src/billing/subscription/index.ts | 2 + packages/openapi/src/index.ts | 3 + packages/openapi/src/setting/get.ts | 36 +++++++ packages/openapi/src/setting/index.ts | 2 + packages/openapi/src/setting/update.ts | 38 +++++++ .../openapi/src/usage/get-instance-usage.ts | 29 ++++++ packages/openapi/src/usage/get-space-usage.ts | 86 ++++++++++++++++ packages/openapi/src/usage/index.ts | 2 + .../sdk/src/components/billing/store/index.ts | 1 + .../billing/store/usage-limit-modal.ts | 43 ++++++++ .../FieldCreateOrSelectModal.tsx | 1 + packages/sdk/src/context/app/queryClient.tsx | 12 +++ packages/ui-lib/src/shadcn/ui/table.tsx | 4 +- 86 files changed, 1429 insertions(+), 118 deletions(-) create mode 100644 apps/nestjs-backend/src/event-emitter/events/space/collaborator.event.ts create mode 100644 apps/nestjs-backend/src/event-emitter/events/user/user.event.ts create mode 100644 apps/nestjs-backend/src/features/setting/admin.guard.ts create mode 100644 apps/nestjs-backend/src/features/setting/setting.controller.ts create mode 100644 apps/nestjs-backend/src/features/setting/setting.module.ts create mode 100644 apps/nestjs-backend/src/features/setting/setting.service.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/index.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/setting/components/CopyInstance.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/setting/components/index.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/admin/setting/index.ts create mode 100644 apps/nextjs-app/src/features/app/components/billing/Level.tsx create mode 100644 apps/nextjs-app/src/features/app/components/billing/LevelWithUpgrade.tsx create mode 100644 apps/nextjs-app/src/features/app/components/billing/UsageLimitModal.tsx create mode 100644 apps/nextjs-app/src/features/app/components/collaborator-manage/space/Collaborator.tsx create mode 100644 apps/nextjs-app/src/features/app/hooks/useBillingLevelConfig.ts create mode 100644 apps/nextjs-app/src/features/app/hooks/useIsCloud.ts create mode 100644 apps/nextjs-app/src/features/app/hooks/useSetting.ts create mode 100644 apps/nextjs-app/src/features/app/layouts/AdminLayout.tsx create mode 100644 apps/nextjs-app/src/pages/admin/setting.tsx create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20240625032002_add_admin/migration.sql create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20240626072754_add_setting_table/migration.sql create mode 100644 packages/db-main-prisma/prisma/sqlite/migrations/20240625031955_add_admin/migration.sql create mode 100644 packages/db-main-prisma/prisma/sqlite/migrations/20240626072703_add_setting_table/migration.sql create mode 100644 packages/icons/src/components/Admin.tsx create mode 100644 packages/icons/src/components/License.tsx create mode 100644 packages/openapi/src/billing/index.ts create mode 100644 packages/openapi/src/billing/subscription/get-subscription-summary-list.ts create mode 100644 packages/openapi/src/billing/subscription/get-subscription-summary.ts create mode 100644 packages/openapi/src/billing/subscription/index.ts create mode 100644 packages/openapi/src/setting/get.ts create mode 100644 packages/openapi/src/setting/index.ts create mode 100644 packages/openapi/src/setting/update.ts create mode 100644 packages/openapi/src/usage/get-instance-usage.ts create mode 100644 packages/openapi/src/usage/get-space-usage.ts create mode 100644 packages/openapi/src/usage/index.ts create mode 100644 packages/sdk/src/components/billing/store/index.ts create mode 100644 packages/sdk/src/components/billing/store/usage-limit-modal.ts diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index f6dbea50d..1b64be334 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -16,6 +16,7 @@ import { NextModule } from './features/next/next.module'; import { NotificationModule } from './features/notification/notification.module'; import { PinModule } from './features/pin/pin.module'; import { SelectionModule } from './features/selection/selection.module'; +import { SettingModule } from './features/setting/setting.module'; import { ShareModule } from './features/share/share.module'; import { SpaceModule } from './features/space/space.module'; import { UserModule } from './features/user/user.module'; @@ -47,6 +48,7 @@ export const appModules = { ImportOpenApiModule, ExportOpenApiModule, PinModule, + SettingModule, ], providers: [InitBootstrapProvider], }; diff --git a/apps/nestjs-backend/src/configs/base.config.ts b/apps/nestjs-backend/src/configs/base.config.ts index 3c9887895..e0e742d90 100644 --- a/apps/nestjs-backend/src/configs/base.config.ts +++ b/apps/nestjs-backend/src/configs/base.config.ts @@ -4,6 +4,7 @@ import type { ConfigType } from '@nestjs/config'; import { registerAs } from '@nestjs/config'; export const baseConfig = registerAs('base', () => ({ + isCloud: process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() === 'CLOUD', brandName: process.env.BRAND_NAME!, publicOrigin: process.env.PUBLIC_ORIGIN!, storagePrefix: process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN!, diff --git a/apps/nestjs-backend/src/custom.exception.ts b/apps/nestjs-backend/src/custom.exception.ts index 42d704ef6..698a556c9 100644 --- a/apps/nestjs-backend/src/custom.exception.ts +++ b/apps/nestjs-backend/src/custom.exception.ts @@ -16,6 +16,8 @@ export const getDefaultCodeByStatus = (status: HttpStatus) => { return HttpErrorCode.VALIDATION_ERROR; case HttpStatus.UNAUTHORIZED: return HttpErrorCode.UNAUTHORIZED; + case HttpStatus.PAYMENT_REQUIRED: + return HttpErrorCode.PAYMENT_REQUIRED; case HttpStatus.FORBIDDEN: return HttpErrorCode.RESTRICTED_RESOURCE; case HttpStatus.NOT_FOUND: diff --git a/apps/nestjs-backend/src/event-emitter/events/event.enum.ts b/apps/nestjs-backend/src/event-emitter/events/event.enum.ts index 227a0f341..c8e1d80b5 100644 --- a/apps/nestjs-backend/src/event-emitter/events/event.enum.ts +++ b/apps/nestjs-backend/src/event-emitter/events/event.enum.ts @@ -40,4 +40,7 @@ export enum Events { // USER_PASSWORD_RESET = 'user.password.reset', USER_PASSWORD_CHANGE = 'user.password.change', // USER_PASSWORD_FORGOT = 'user.password.forgot' + + COLLABORATOR_CREATE = 'collaborator.create', + COLLABORATOR_DELETE = 'collaborator.delete', } diff --git a/apps/nestjs-backend/src/event-emitter/events/index.ts b/apps/nestjs-backend/src/event-emitter/events/index.ts index 374b83d34..a5b75d18c 100644 --- a/apps/nestjs-backend/src/event-emitter/events/index.ts +++ b/apps/nestjs-backend/src/event-emitter/events/index.ts @@ -3,4 +3,5 @@ export * from './core-event'; export * from './op-event'; export * from './base/base.event'; export * from './space/space.event'; +export * from './space/collaborator.event'; export * from './table'; diff --git a/apps/nestjs-backend/src/event-emitter/events/space/collaborator.event.ts b/apps/nestjs-backend/src/event-emitter/events/space/collaborator.event.ts new file mode 100644 index 000000000..23937ba13 --- /dev/null +++ b/apps/nestjs-backend/src/event-emitter/events/space/collaborator.event.ts @@ -0,0 +1,13 @@ +import { Events } from '../event.enum'; + +export class CollaboratorCreateEvent { + public readonly name = Events.COLLABORATOR_CREATE; + + constructor(public readonly spaceId: string) {} +} + +export class CollaboratorDeleteEvent { + public readonly name = Events.COLLABORATOR_DELETE; + + constructor(public readonly spaceId: string) {} +} diff --git a/apps/nestjs-backend/src/event-emitter/events/user/user.event.ts b/apps/nestjs-backend/src/event-emitter/events/user/user.event.ts new file mode 100644 index 000000000..41ab8990f --- /dev/null +++ b/apps/nestjs-backend/src/event-emitter/events/user/user.event.ts @@ -0,0 +1,7 @@ +import { Events } from '../event.enum'; + +export class UserSignUpEvent { + public readonly name = Events.USER_SIGNUP; + + constructor(public readonly userId: string) {} +} diff --git a/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts b/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts index 76863c5e0..c3c5ef0d7 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts @@ -32,6 +32,9 @@ export class AccessTokenStrategy extends PassportStrategy(PassportAccessTokenStr if (!user) { throw new UnauthorizedException(); } + if (user.deactivatedTime) { + throw new UnauthorizedException('Your account has been deactivated by the administrator'); + } this.cls.set('user.id', user.id); this.cls.set('user.name', user.name); diff --git a/apps/nestjs-backend/src/features/auth/strategies/github.strategy.ts b/apps/nestjs-backend/src/features/auth/strategies/github.strategy.ts index 1d1cb2a17..489841bb6 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/github.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/github.strategy.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigType } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import type { Profile } from 'passport-github2'; @@ -42,6 +42,9 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') { if (!user) { throw new UnauthorizedException('Failed to create user from GitHub profile'); } + if (user.deactivatedTime) { + throw new BadRequestException('Your account has been deactivated by the administrator'); + } await this.userService.refreshLastSignTime(user.id); return pickUserMe(user); } diff --git a/apps/nestjs-backend/src/features/auth/strategies/google.strategy.ts b/apps/nestjs-backend/src/features/auth/strategies/google.strategy.ts index b1c445693..aa389e398 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/google.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/google.strategy.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigType } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import type { Profile } from 'passport-google-oauth20'; @@ -44,6 +44,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { if (!user) { throw new UnauthorizedException('Failed to create user from Google profile'); } + if (user.deactivatedTime) { + throw new BadRequestException('Your account has been deactivated by the administrator'); + } await this.userService.refreshLastSignTime(user.id); return pickUserMe(user); } diff --git a/apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts b/apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts index 07b9fa935..7d77eb366 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts @@ -22,6 +22,9 @@ export class LocalStrategy extends PassportStrategy(Strategy) { if (!user) { throw new BadRequestException('Incorrect password.'); } + if (user.deactivatedTime) { + throw new BadRequestException('Your account has been deactivated by the administrator'); + } await this.userService.refreshLastSignTime(user.id); return pickUserMe(user); } diff --git a/apps/nestjs-backend/src/features/auth/strategies/session.strategy.ts b/apps/nestjs-backend/src/features/auth/strategies/session.strategy.ts index 17ed71b9a..87a22fdfa 100644 --- a/apps/nestjs-backend/src/features/auth/strategies/session.strategy.ts +++ b/apps/nestjs-backend/src/features/auth/strategies/session.strategy.ts @@ -25,6 +25,9 @@ export class SessionStrategy extends PassportStrategy(PassportSessionStrategy) { if (!user) { throw new UnauthorizedException(); } + if (user.deactivatedTime) { + throw new UnauthorizedException('Your account has been deactivated by the administrator'); + } this.cls.set('user.id', user.id); this.cls.set('user.name', user.name); this.cls.set('user.email', user.email); diff --git a/apps/nestjs-backend/src/features/auth/utils.ts b/apps/nestjs-backend/src/features/auth/utils.ts index be2399b82..22b863d81 100644 --- a/apps/nestjs-backend/src/features/auth/utils.ts +++ b/apps/nestjs-backend/src/features/auth/utils.ts @@ -6,11 +6,11 @@ import { getFullStorageUrl } from '../../utils/full-storage-url'; export const pickUserMe = ( user: Pick< Prisma.UserGetPayload, - 'id' | 'name' | 'avatar' | 'phone' | 'email' | 'password' | 'notifyMeta' + 'id' | 'name' | 'avatar' | 'phone' | 'email' | 'password' | 'notifyMeta' | 'isAdmin' > ): IUserMeVo => { return { - ...pick(user, 'id', 'name', 'phone', 'email'), + ...pick(user, 'id', 'name', 'phone', 'email', 'isAdmin'), notifyMeta: typeof user.notifyMeta === 'object' ? user.notifyMeta : JSON.parse(user.notifyMeta), avatar: user.avatar && !user.avatar?.startsWith('http') diff --git a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts index 912b2e873..1ce42be67 100644 --- a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts +++ b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts @@ -10,6 +10,12 @@ import { Knex } from 'knex'; import { isDate } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; +import { EventEmitterService } from '../../event-emitter/event-emitter.service'; +import { + CollaboratorCreateEvent, + CollaboratorDeleteEvent, + Events, +} from '../../event-emitter/events'; import type { IClsStore } from '../../types/cls'; import { getFullStorageUrl } from '../../utils/full-storage-url'; @@ -18,6 +24,7 @@ export class CollaboratorService { constructor( private readonly prismaService: PrismaService, private readonly cls: ClsService, + private readonly eventEmitterService: EventEmitterService, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) {} @@ -29,7 +36,7 @@ export class CollaboratorService { if (exist) { throw new BadRequestException('has already existed in space'); } - return await this.prismaService.txClient().collaborator.create({ + const collaborator = await this.prismaService.txClient().collaborator.create({ data: { spaceId, roleName: role, @@ -37,6 +44,11 @@ export class CollaboratorService { createdBy: currentUserId, }, }); + this.eventEmitterService.emitAsync( + Events.COLLABORATOR_CREATE, + new CollaboratorCreateEvent(spaceId) + ); + return collaborator; } async deleteBySpaceId(spaceId: string) { @@ -121,7 +133,7 @@ export class CollaboratorService { } async deleteCollaborator(spaceId: string, userId: string) { - return await this.prismaService.txClient().collaborator.updateMany({ + const result = await this.prismaService.txClient().collaborator.updateMany({ where: { spaceId, userId, @@ -130,6 +142,11 @@ export class CollaboratorService { deletedTime: new Date().toISOString(), }, }); + this.eventEmitterService.emitAsync( + Events.COLLABORATOR_DELETE, + new CollaboratorDeleteEvent(spaceId) + ); + return result; } async updateCollaborator(spaceId: string, updateCollaborator: UpdateSpaceCollaborateRo) { diff --git a/apps/nestjs-backend/src/features/next/next.controller.ts b/apps/nestjs-backend/src/features/next/next.controller.ts index c9a7543c7..4317ea732 100644 --- a/apps/nestjs-backend/src/features/next/next.controller.ts +++ b/apps/nestjs-backend/src/features/next/next.controller.ts @@ -26,6 +26,7 @@ export class NextController { 'invite/?*', 'share/?*', 'setting/?*', + 'admin/?*', ]) public async home(@Req() req: express.Request, @Res() res: express.Response) { await this.nextService.server.getRequestHandler()(req, res); diff --git a/apps/nestjs-backend/src/features/selection/selection.service.ts b/apps/nestjs-backend/src/features/selection/selection.service.ts index 548144abe..b12acfe91 100644 --- a/apps/nestjs-backend/src/features/selection/selection.service.ts +++ b/apps/nestjs-backend/src/features/selection/selection.service.ts @@ -649,7 +649,11 @@ export class SelectionService { return [range[0], range[1]]; } - async paste(tableId: string, pasteRo: IPasteRo) { + async paste( + tableId: string, + pasteRo: IPasteRo, + expansionChecker?: (col: number, row: number) => Promise + ) { const { content, header = [], ...rangesRo } = pasteRo; const { ranges, type, ...queryRo } = rangesRo; const { viewId } = queryRo; @@ -702,6 +706,7 @@ export class SelectionService { tableColCount, tableRowCount, ]); + await expansionChecker?.(numColsToExpand, numRowsToExpand); const updateRange: IPasteVo['ranges'] = [cell, cell]; diff --git a/apps/nestjs-backend/src/features/setting/admin.guard.ts b/apps/nestjs-backend/src/features/setting/admin.guard.ts new file mode 100644 index 000000000..f5e2ff9fd --- /dev/null +++ b/apps/nestjs-backend/src/features/setting/admin.guard.ts @@ -0,0 +1,27 @@ +import type { CanActivate } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { PrismaService } from '@teable/db-main-prisma'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../types/cls'; + +@Injectable() +export class AdminGuard implements CanActivate { + constructor( + private readonly cls: ClsService, + private readonly prismaService: PrismaService + ) {} + + async canActivate() { + const userId = this.cls.get('user.id'); + + const user = await this.prismaService.user.findUnique({ + where: { id: userId, deletedTime: null, deactivatedTime: null }, + }); + + if (!user || !user.isAdmin) { + throw new ForbiddenException('User is not an admin'); + } + + return true; + } +} diff --git a/apps/nestjs-backend/src/features/setting/setting.controller.ts b/apps/nestjs-backend/src/features/setting/setting.controller.ts new file mode 100644 index 000000000..3bd07daf8 --- /dev/null +++ b/apps/nestjs-backend/src/features/setting/setting.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; +import { IUpdateSettingRo, updateSettingRoSchema } from '@teable/openapi'; +import type { ISettingVo } from '@teable/openapi'; +import { ZodValidationPipe } from '../../zod.validation.pipe'; +import { Public } from '../auth/decorators/public.decorator'; +import { AdminGuard } from './admin.guard'; +import { SettingService } from './setting.service'; + +@Controller('api/admin/setting') +export class SettingController { + constructor(private readonly settingService: SettingService) {} + + @Public() + @Get() + async getSetting(): Promise { + return await this.settingService.getSetting(); + } + + @UseGuards(AdminGuard) + @Patch() + async updateSetting( + @Body(new ZodValidationPipe(updateSettingRoSchema)) + updateSettingRo: IUpdateSettingRo + ): Promise { + return await this.settingService.updateSetting(updateSettingRo); + } +} diff --git a/apps/nestjs-backend/src/features/setting/setting.module.ts b/apps/nestjs-backend/src/features/setting/setting.module.ts new file mode 100644 index 000000000..0df993dca --- /dev/null +++ b/apps/nestjs-backend/src/features/setting/setting.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminGuard } from './admin.guard'; +import { SettingController } from './setting.controller'; +import { SettingService } from './setting.service'; + +@Module({ + controllers: [SettingController], + exports: [SettingService], + providers: [SettingService, AdminGuard], +}) +export class SettingModule {} diff --git a/apps/nestjs-backend/src/features/setting/setting.service.ts b/apps/nestjs-backend/src/features/setting/setting.service.ts new file mode 100644 index 000000000..68ecc9346 --- /dev/null +++ b/apps/nestjs-backend/src/features/setting/setting.service.ts @@ -0,0 +1,30 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '@teable/db-main-prisma'; +import type { ISettingVo, IUpdateSettingRo } from '@teable/openapi'; + +@Injectable() +export class SettingService { + constructor(private readonly prismaService: PrismaService) {} + + async getSetting(): Promise { + return await this.prismaService.setting + .findFirstOrThrow({ + select: { + instanceId: true, + disallowSignUp: true, + disallowSpaceCreation: true, + }, + }) + .catch(() => { + throw new NotFoundException('Setting not found'); + }); + } + + async updateSetting(updateSettingRo: IUpdateSettingRo) { + const setting = await this.getSetting(); + return await this.prismaService.setting.update({ + where: { instanceId: setting.instanceId }, + data: updateSettingRo, + }); + } +} diff --git a/apps/nestjs-backend/src/features/space/space.service.ts b/apps/nestjs-backend/src/features/space/space.service.ts index 61e3cf54c..31cbec147 100644 --- a/apps/nestjs-backend/src/features/space/space.service.ts +++ b/apps/nestjs-backend/src/features/space/space.service.ts @@ -98,6 +98,18 @@ export class SpaceService { async createSpace(createSpaceRo: ICreateSpaceRo) { const userId = this.cls.get('user.id'); + const setting = await this.prismaService.setting.findFirst({ + select: { + disallowSignUp: true, + disallowSpaceCreation: true, + }, + }); + + if (setting?.disallowSpaceCreation) { + throw new ForbiddenException( + 'The current instance disallow space creation by the administrator' + ); + } const spaceList = await this.prismaService.space.findMany({ where: { deletedTime: null, createdBy: userId }, diff --git a/apps/nestjs-backend/src/features/user/user.service.ts b/apps/nestjs-backend/src/features/user/user.service.ts index cd7d3d9da..75a4f7e69 100644 --- a/apps/nestjs-backend/src/features/user/user.service.ts +++ b/apps/nestjs-backend/src/features/user/user.service.ts @@ -1,6 +1,6 @@ import https from 'https'; import { join } from 'path'; -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { generateAccountId, generateSpaceId, @@ -13,6 +13,10 @@ import { PrismaService } from '@teable/db-main-prisma'; import { type ICreateSpaceRo, type IUserNotifyMeta, UploadType } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; import sharp from 'sharp'; +import { BaseConfig, IBaseConfig } from '../../configs/base.config'; +import { EventEmitterService } from '../../event-emitter/event-emitter.service'; +import { Events } from '../../event-emitter/events'; +import { UserSignUpEvent } from '../../event-emitter/events/user/user.event'; import type { IClsStore } from '../../types/cls'; import { getFullStorageUrl } from '../../utils/full-storage-url'; import StorageAdapter from '../attachments/plugins/adapter'; @@ -23,7 +27,9 @@ export class UserService { constructor( private readonly prismaService: PrismaService, private readonly cls: ClsService, - @InjectStorageAdapter() readonly storageAdapter: StorageAdapter + private readonly eventEmitterService: EventEmitterService, + @InjectStorageAdapter() readonly storageAdapter: StorageAdapter, + @BaseConfig() private readonly baseConfig: IBaseConfig ) {} async getUserById(id: string) { @@ -76,6 +82,17 @@ export class UserService { user: Omit & { name?: string }, account?: Omit ) { + const setting = await this.prismaService.setting.findFirst({ + select: { + disallowSignUp: true, + disallowSpaceCreation: true, + }, + }); + + if (setting?.disallowSignUp) { + throw new BadRequestException('The current instance disallow sign up by the administrator'); + } + // defaults const defaultNotifyMeta: IUserNotifyMeta = { email: true, @@ -87,6 +104,10 @@ export class UserService { notifyMeta: JSON.stringify(defaultNotifyMeta), }; + const userTotalCount = await this.prismaService.txClient().user.count(); + + const isAdmin = !this.baseConfig.isCloud && userTotalCount === 0; + if (!user?.avatar) { const avatar = await this.generateDefaultAvatar(user.id!); user = { @@ -99,6 +120,7 @@ export class UserService { data: { ...user, name: user.name ?? user.email.split('@')[0], + isAdmin: isAdmin ? true : null, }, }); const { id, name } = newUser; @@ -111,6 +133,7 @@ export class UserService { this.cls.set('user.id', id); await this.createSpaceBySignup({ name: `${name}'s space` }); }); + this.eventEmitterService.emitAsync(Events.USER_SIGNUP, new UserSignUpEvent(id)); return newUser; } diff --git a/apps/nextjs-app/src/backend/api/rest/table.ssr.ts b/apps/nextjs-app/src/backend/api/rest/table.ssr.ts index 251a23d8c..312a70317 100644 --- a/apps/nextjs-app/src/backend/api/rest/table.ssr.ts +++ b/apps/nextjs-app/src/backend/api/rest/table.ssr.ts @@ -11,6 +11,8 @@ import type { ShareViewGetVo, ITableFullVo, ITableListVo, + ISettingVo, + IUserMeVo, IRecordsVo, ITableVo, } from '@teable/openapi'; @@ -22,6 +24,7 @@ import { GET_FIELD_LIST, GET_RECORDS_URL, GET_RECORD_URL, + GET_SETTING, GET_SPACE, GET_TABLE, GET_TABLE_LIST, @@ -29,6 +32,7 @@ import { SHARE_VIEW_GET, SPACE_COLLABORATE_LIST, UPDATE_NOTIFICATION_STATUS, + USER_ME, urlBuilder, } from '@teable/openapi'; import type { AxiosInstance } from 'axios'; @@ -138,4 +142,12 @@ export class SsrApi { .patch(urlBuilder(UPDATE_NOTIFICATION_STATUS, { notificationId }), data) .then(({ data }) => data); } + + async getSetting() { + return this.axios.get(GET_SETTING).then(({ data }) => data); + } + + async getUserMe() { + return this.axios.get(USER_ME).then(({ data }) => data); + } } diff --git a/apps/nextjs-app/src/features/app/blocks/admin/index.ts b/apps/nextjs-app/src/features/app/blocks/admin/index.ts new file mode 100644 index 000000000..7b7a1e9ea --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/index.ts @@ -0,0 +1 @@ +export * from './setting'; diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx new file mode 100644 index 000000000..b7ca44ae3 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/SettingPage.tsx @@ -0,0 +1,78 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { IUpdateSettingRo, ISettingVo } from '@teable/openapi'; +import { getSetting, updateSetting } from '@teable/openapi'; +import { Label, Switch } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { CopyInstance } from './components'; + +export interface ISettingPageProps { + settingServerData?: ISettingVo; +} + +export const SettingPage = (props: ISettingPageProps) => { + const { settingServerData } = props; + const queryClient = useQueryClient(); + const { t } = useTranslation('common'); + + const { data: setting = settingServerData } = useQuery({ + queryKey: ['setting'], + queryFn: () => getSetting().then(({ data }) => data), + }); + + const { mutateAsync: mutateUpdateSetting } = useMutation({ + mutationFn: (props: IUpdateSettingRo) => updateSetting(props), + onSuccess: () => { + queryClient.invalidateQueries(['setting']); + }, + }); + + const onCheckedChange = (key: string, value: boolean) => { + mutateUpdateSetting({ [key]: value }); + }; + + if (!setting) return null; + + const { instanceId, disallowSignUp, disallowSpaceCreation } = setting; + + return ( +
+
+

{t('settings.title')}

+
{t('admin.setting.description')}
+
+ +
+
+
+ +
+ {t('admin.setting.allowSignUpDescription')} +
+
+ onCheckedChange('disallowSignUp', !checked)} + /> +
+
+
+ +
+ {t('admin.setting.allowSpaceCreationDescription')} +
+
+ onCheckedChange('disallowSpaceCreation', !checked)} + /> +
+
+ +
+ + +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/CopyInstance.tsx b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/CopyInstance.tsx new file mode 100644 index 000000000..f1d59bc25 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/CopyInstance.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from 'next-i18next'; +import { CopyButton } from '@/features/app/components/CopyButton'; + +interface ICopyInstanceProps { + instanceId: string; +} + +export const CopyInstance = (props: ICopyInstanceProps) => { + const { instanceId } = props; + const { t } = useTranslation('common'); + + return ( +
+ {t('noun.instanceId')} + {instanceId} + +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/components/index.ts b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/index.ts new file mode 100644 index 000000000..12d2a9282 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/components/index.ts @@ -0,0 +1 @@ +export * from './CopyInstance'; diff --git a/apps/nextjs-app/src/features/app/blocks/admin/setting/index.ts b/apps/nextjs-app/src/features/app/blocks/admin/setting/index.ts new file mode 100644 index 000000000..3180aba12 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/admin/setting/index.ts @@ -0,0 +1,2 @@ +export * from './SettingPage'; +export * from './components'; diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSideBar.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSideBar.tsx index 9838df873..28216cb5b 100644 --- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSideBar.tsx +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSideBar.tsx @@ -1,10 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; import { Gauge, Lock, PackageCheck } from '@teable/icons'; -import { useBasePermission } from '@teable/sdk/hooks'; -import { cn } from '@teable/ui-lib/shadcn'; +import { getSpaceUsage } from '@teable/openapi'; +import { useBase, useBasePermission } from '@teable/sdk/hooks'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + cn, +} from '@teable/ui-lib/shadcn'; import { Button } from '@teable/ui-lib/shadcn/ui/button'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; +import { useIsCloud } from '@/features/app/hooks/useIsCloud'; import { tableConfig } from '@/features/i18n/table.config'; import { TableList } from '../../table-list/TableList'; import { QuickAction } from './QuickAction'; @@ -13,34 +22,51 @@ export const BaseSideBar = () => { const router = useRouter(); const { baseId } = router.query; const { t } = useTranslation(tableConfig.i18nNamespaces); + + const isCloud = useIsCloud(); + + const base = useBase(); const basePermission = useBasePermission(); + + const spaceId = base.spaceId; + + const { data: usage } = useQuery({ + queryKey: ['space-usage', spaceId], + queryFn: ({ queryKey }) => getSpaceUsage(queryKey[1]).then(({ data }) => data), + enabled: isCloud, + }); + + const { automationEnable = true, advancedPermissionsEnable = true } = usage?.limit ?? {}; + const pageRoutes: { href: string; - text: string; + label: string; Icon: React.FC<{ className?: string }>; disabled?: boolean; }[] = [ { href: `/base/${baseId}/dashboard`, - text: t('common:noun.dashboard'), + label: t('common:noun.dashboard'), Icon: Gauge, }, { href: `/base/${baseId}/automation`, - text: t('common:noun.automation'), + label: t('common:noun.automation'), Icon: PackageCheck, - disabled: true, + disabled: !automationEnable, }, ...(basePermission?.['base|authority_matrix_config'] ? [ { href: `/base/${baseId}/authority-matrix`, - text: t('common:noun.authorityMatrix'), + label: t('common:noun.authorityMatrix'), Icon: Lock, + disabled: !advancedPermissionsEnable, }, ] : []), ]; + return ( <>
@@ -48,25 +74,49 @@ export const BaseSideBar = () => { {t('common:quickAction.title')}
    - {pageRoutes.map(({ href, text, Icon, disabled }) => { + {pageRoutes.map(({ href, label, Icon, disabled }) => { return (
  • - + {disabled ? ( + + + + + + +

    {t('billing.unavailableInPlanTips')}

    +
    +
    +
    + ) : ( + + )}
  • ); })} diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/ShareViewPage.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/ShareViewPage.tsx index 5f56719a4..70f7bfd36 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/ShareViewPage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/ShareViewPage.tsx @@ -50,6 +50,7 @@ export const ShareViewPage = (props: IShareViewPageProps) => { email: '', notifyMeta: {}, hasPassword: false, + isAdmin: false, }} > = (props) => { - const { space, bases } = props; + const { space, bases, subscription } = props; const router = useRouter(); + const isCloud = useIsCloud(); const queryClient = useQueryClient(); const [renaming, setRenaming] = useState(false); const [spaceName, setSpaceName] = useState(space.name); @@ -78,6 +82,9 @@ export const SpaceCard: FC = (props) => { + {isCloud && ( + + )}
{ const router = useRouter(); const queryClient = useQueryClient(); + const isCloud = useIsCloud(); const ref = useRef(null); const spaceId = router.query.spaceId as string; const { t } = useTranslation(spaceConfig.i18nNamespaces); @@ -33,6 +42,12 @@ export const SpaceInnerPage: React.FC = () => { return bases?.filter((base) => base.spaceId === spaceId); }, [bases, spaceId]); + const { data: subscriptionSummary } = useQuery({ + queryKey: ['subscription-summary', spaceId], + queryFn: () => getSubscriptionSummary(spaceId).then(({ data }) => data), + enabled: isCloud, + }); + const { mutate: deleteSpaceMutator } = useMutation({ mutationFn: deleteSpace, onSuccess: async () => { @@ -89,6 +104,9 @@ export const SpaceInnerPage: React.FC = () => {

{space.name}

+ {isCloud && ( + + )} {basesInSpace?.length ? ( diff --git a/apps/nextjs-app/src/features/app/blocks/space/SpacePage.tsx b/apps/nextjs-app/src/features/app/blocks/space/SpacePage.tsx index bed035a75..e5ae74ffc 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/SpacePage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/SpacePage.tsx @@ -1,13 +1,16 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createSpace } from '@teable/openapi'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createSpace, getSubscriptionSummaryList } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import { Spin } from '@teable/ui-lib/base'; import { Button } from '@teable/ui-lib/shadcn'; +import { keyBy } from 'lodash'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import { useRef, type FC } from 'react'; +import { useRef, type FC, useMemo } from 'react'; import { GUIDE_CREATE_SPACE } from '@/components/Guide'; import { spaceConfig } from '@/features/i18n/space.config'; +import { useIsCloud } from '../../hooks/useIsCloud'; +import { useSetting } from '../../hooks/useSetting'; import { useTemplateMonitor } from '../base/duplicate/useTemplateMonitor'; import { SpaceCard } from './SpaceCard'; import { useBaseList } from './useBaseList'; @@ -16,6 +19,7 @@ import { useSpaceListOrdered } from './useSpaceListOrdered'; export const SpacePage: FC = () => { const queryClient = useQueryClient(); const router = useRouter(); + const isCloud = useIsCloud(); const ref = useRef(null); const { t } = useTranslation(spaceConfig.i18nNamespaces); useTemplateMonitor(); @@ -24,6 +28,14 @@ export const SpacePage: FC = () => { const baseList = useBaseList(); + const { data: subscriptionList } = useQuery({ + queryKey: ['subscription-summary-list'], + queryFn: () => getSubscriptionSummaryList().then(({ data }) => data), + enabled: isCloud, + }); + + const { disallowSpaceCreation } = useSetting(); + const { mutate: createSpaceMutator, isLoading } = useMutation({ mutationFn: createSpace, onSuccess: async (data) => { @@ -37,19 +49,26 @@ export const SpacePage: FC = () => { }, }); + const subscriptionMap = useMemo(() => { + if (subscriptionList == null) return {}; + return keyBy(subscriptionList, 'spaceId'); + }, [subscriptionList]); + return (

{t('space:allSpaces')}

- + {!disallowSpaceCreation && ( + + )}
{orderedSpaceList.map((space) => ( @@ -57,6 +76,7 @@ export const SpacePage: FC = () => { key={space.id} space={space} bases={baseList?.filter(({ spaceId }) => spaceId === space.id)} + subscription={subscriptionMap[space.id]} /> ))}
diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx index d6103d047..0c57f8e78 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx @@ -6,10 +6,12 @@ import { Spin } from '@teable/ui-lib/base'; import { Button } from '@teable/ui-lib/shadcn'; import { useRouter } from 'next/router'; import { type FC } from 'react'; +import { useSetting } from '@/features/app/hooks/useSetting'; import { SpaceItem } from './SpaceItem'; export const SpaceList: FC = () => { const router = useRouter(); + const { disallowSpaceCreation } = useSetting(); const queryClient = useQueryClient(); const { data: spaceList } = useQuery({ @@ -33,15 +35,17 @@ export const SpaceList: FC = () => { return (
- + {!disallowSpaceCreation && ( + + )}
    diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSideBar.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSideBar.tsx index bc22f9224..a72da9407 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSideBar.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSideBar.tsx @@ -1,33 +1,44 @@ -import { Home } from '@teable/icons'; +import { Admin, Home } from '@teable/icons'; import { cn } from '@teable/ui-lib/shadcn'; import { Button } from '@teable/ui-lib/shadcn/ui/button'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; +import { useIsCloud } from '@/features/app/hooks/useIsCloud'; import { spaceConfig } from '@/features/i18n/space.config'; import { PinList } from './PinList'; import { SpaceList } from './SpaceList'; -export const SpaceSideBar = () => { +export const SpaceSideBar = (props: { isAdmin?: boolean | null }) => { + const { isAdmin } = props; const router = useRouter(); + const isCloud = useIsCloud(); const { t } = useTranslation(spaceConfig.i18nNamespaces); const pageRoutes: { href: string; text: string; Icon: React.FC<{ className?: string }>; + hidden?: boolean; }[] = [ { href: '/space', text: t('space:allSpaces'), Icon: Home, }, + { + href: '/admin/setting', + text: t('noun.adminPanel'), + Icon: Admin, + hidden: isCloud || !isAdmin, + }, ]; return ( <>
      - {pageRoutes.map(({ href, text, Icon }) => { + {pageRoutes.map(({ href, text, Icon, hidden }) => { + if (hidden) return null; return (
    • + )} +
    + ); +}; diff --git a/apps/nextjs-app/src/features/app/components/billing/UsageLimitModal.tsx b/apps/nextjs-app/src/features/app/components/billing/UsageLimitModal.tsx new file mode 100644 index 000000000..d7db65b83 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/billing/UsageLimitModal.tsx @@ -0,0 +1,66 @@ +import { UsageLimitModalType, useUsageLimitModalStore } from '@teable/sdk/components/billing/store'; +import { useBase } from '@teable/sdk/hooks'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + Button, +} from '@teable/ui-lib/shadcn'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; + +export const UsageLimitModal = () => { + const base = useBase(); + const router = useRouter(); + const { t } = useTranslation('common'); + const { modalType, modalOpen, toggleModal } = useUsageLimitModalStore(); + const isUpgrade = modalType === UsageLimitModalType.Upgrade; + + const description = useMemo(() => { + if (!isUpgrade) { + return t('billing.userLimitExceededDescription'); + } + return t('billing.overLimitsDescription'); + }, [isUpgrade, t]); + + if (base == null) return null; + + const { spaceId } = base; + + const onClick = () => { + if (isUpgrade) { + router.push({ + pathname: '/space/[spaceId]/setting/plan', + query: { spaceId }, + }); + } else { + router.push('/admin/user'); + } + toggleModal(false); + }; + + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + {t('billing.overLimits')} + {description} + + + + + + + ); +}; diff --git a/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Collaborator.tsx b/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Collaborator.tsx new file mode 100644 index 000000000..067cda6a0 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Collaborator.tsx @@ -0,0 +1,23 @@ +import { cn } from '@teable/ui-lib/shadcn'; +import { UserAvatar } from '../../user/UserAvatar'; + +interface ICollaboratorProps { + name: string; + email: string; + avatar?: string | null; + className?: string; +} + +export const Collaborator = (props: ICollaboratorProps) => { + const { name, email, avatar, className } = props; + + return ( +
    + +
    +

    {name}

    +

    {email}

    +
    +
    + ); +}; diff --git a/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Collaborators.tsx b/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Collaborators.tsx index 75ecd1030..38fee0172 100644 --- a/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Collaborators.tsx +++ b/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Collaborators.tsx @@ -23,7 +23,7 @@ import { debounce } from 'lodash'; import { useTranslation } from 'next-i18next'; import type { FC, PropsWithChildren } from 'react'; import React, { useMemo, useState } from 'react'; -import { UserAvatar } from '@/features/app/components/user/UserAvatar'; +import { Collaborator } from './Collaborator'; import { RoleSelect } from './RoleSelect'; extend(relativeTime); @@ -99,13 +99,7 @@ export const Collaborators: FC> = (props) => {
    {collaboratorsFiltered?.map(({ userId, userName, email, role, avatar, createdTime }) => (
    -
    - -
    -

    {userName}

    -

    {email}

    -
    -
    +
    {t('invite.dialog.collaboratorJoin', { joinTime: dayjs(createdTime).fromNow(), diff --git a/apps/nextjs-app/src/features/app/components/sidebar/SidebarContent.tsx b/apps/nextjs-app/src/features/app/components/sidebar/SidebarContent.tsx index d55a79d77..decf49b41 100644 --- a/apps/nextjs-app/src/features/app/components/sidebar/SidebarContent.tsx +++ b/apps/nextjs-app/src/features/app/components/sidebar/SidebarContent.tsx @@ -1,4 +1,10 @@ -import { cn } from '@teable/ui-lib/shadcn'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + cn, +} from '@teable/ui-lib/shadcn'; import { Button } from '@teable/ui-lib/shadcn/ui/button'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -8,6 +14,7 @@ export interface ISidebarContentRoute { label: string; route: string; pathTo: string; + disabledTip?: string; } interface ISidebarContentProps { @@ -23,24 +30,49 @@ export const SidebarContent = (props: ISidebarContentProps) => {
    {title && {title}}
      - {routes.map(({ Icon, label, route, pathTo }) => { + {routes.map(({ Icon, label, route, pathTo, disabledTip }) => { return (
    • - + {disabledTip ? ( + + + + + + +

      {disabledTip}

      +
      +
      +
      + ) : ( + + )}
    • ); })} diff --git a/apps/nextjs-app/src/features/app/components/user/UserNav.tsx b/apps/nextjs-app/src/features/app/components/user/UserNav.tsx index 3b9ce32b3..fa7241061 100644 --- a/apps/nextjs-app/src/features/app/components/user/UserNav.tsx +++ b/apps/nextjs-app/src/features/app/components/user/UserNav.tsx @@ -1,6 +1,6 @@ import { ExitIcon } from '@radix-ui/react-icons'; import { useMutation } from '@tanstack/react-query'; -import { Code, HelpCircle, Settings } from '@teable/icons'; +import { Code, HelpCircle, License, Settings } from '@teable/icons'; import { signout } from '@teable/openapi'; import { useSession } from '@teable/sdk/hooks'; import { @@ -14,6 +14,7 @@ import { import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import React from 'react'; +import { useIsCloud } from '../../hooks/useIsCloud'; import { useSettingStore } from '../setting/useSettingStore'; export const UserNav: React.FC = (props) => { @@ -25,6 +26,7 @@ export const UserNav: React.FC = (props) => { const { mutateAsync: loginOut, isLoading } = useMutation({ mutationFn: signout, }); + const isCloud = useIsCloud(); const loginOutClick = async () => { await loginOut(); @@ -52,6 +54,12 @@ export const UserNav: React.FC = (props) => { {t('help.title')} + {isCloud && ( + router.push('/setting/license')}> + + {t('noun.license')} + + )} router.push('/setting/personal-access-token')} diff --git a/apps/nextjs-app/src/features/app/hooks/useBillingLevelConfig.ts b/apps/nextjs-app/src/features/app/hooks/useBillingLevelConfig.ts new file mode 100644 index 000000000..4b2d95171 --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/useBillingLevelConfig.ts @@ -0,0 +1,35 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { BillingProductLevel } from '@teable/openapi'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; + +export const useBillingLevelConfig = (productLevel?: BillingProductLevel) => { + const { t } = useTranslation('common'); + + const config = useMemo(() => { + return { + [BillingProductLevel.Free]: { + name: t('level.free'), + description: t('billing.levelTips', { level: t('level.free') }), + tagCls: 'bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-white', + }, + [BillingProductLevel.Plus]: { + name: t('level.plus'), + description: t('billing.levelTips', { level: t('level.plus') }), + tagCls: 'bg-violet-200 dark:bg-violet-700 text-violet-600 dark:text-white', + }, + [BillingProductLevel.Pro]: { + name: t('level.pro'), + description: t('billing.levelTips', { level: t('level.pro') }), + tagCls: 'bg-amber-200 dark:bg-amber-700 text-amber-600 dark:text-white', + }, + [BillingProductLevel.Enterprise]: { + name: t('level.enterprise'), + description: t('billing.levelTips', { level: t('level.enterprise') }), + tagCls: 'bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-white', + }, + }; + }, [t]); + + return config[productLevel as BillingProductLevel] ?? config[BillingProductLevel.Free]; +}; diff --git a/apps/nextjs-app/src/features/app/hooks/useIsCloud.ts b/apps/nextjs-app/src/features/app/hooks/useIsCloud.ts new file mode 100644 index 000000000..974a00f6f --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/useIsCloud.ts @@ -0,0 +1,7 @@ +import { useEnv } from './useEnv'; + +export const useIsCloud = () => { + const { edition } = useEnv(); + + return edition?.toUpperCase() === 'CLOUD'; +}; diff --git a/apps/nextjs-app/src/features/app/hooks/useSetting.ts b/apps/nextjs-app/src/features/app/hooks/useSetting.ts new file mode 100644 index 000000000..f894226a8 --- /dev/null +++ b/apps/nextjs-app/src/features/app/hooks/useSetting.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { getSetting } from '@teable/openapi'; +import { useSession } from '@teable/sdk/hooks'; + +export const useSetting = () => { + const { user } = useSession(); + const { data: setting, isLoading } = useQuery({ + queryKey: ['setting'], + queryFn: () => getSetting().then(({ data }) => data), + }); + + const { disallowSignUp = false, disallowSpaceCreation = false } = setting ?? {}; + + return { + disallowSignUp, + disallowSpaceCreation: !user.isAdmin && (isLoading || disallowSpaceCreation), + }; +}; diff --git a/apps/nextjs-app/src/features/app/layouts/AdminLayout.tsx b/apps/nextjs-app/src/features/app/layouts/AdminLayout.tsx new file mode 100644 index 000000000..e5b9c92d4 --- /dev/null +++ b/apps/nextjs-app/src/features/app/layouts/AdminLayout.tsx @@ -0,0 +1,64 @@ +import type { DehydratedState } from '@tanstack/react-query'; +import { Admin, Settings } from '@teable/icons'; +import type { IUser } from '@teable/sdk'; +import { SessionProvider } from '@teable/sdk'; +import { AppProvider } from '@teable/sdk/context'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import React from 'react'; +import { Sidebar } from '@/features/app/components/sidebar/Sidebar'; +import { SidebarHeaderLeft } from '@/features/app/components/sidebar/SidebarHeaderLeft'; +import { useSdkLocale } from '@/features/app/hooks/useSdkLocale'; +import { AppLayout } from '@/features/app/layouts'; +import { SidebarContent } from '../components/sidebar/SidebarContent'; + +export const AdminLayout: React.FC<{ + children: React.ReactNode; + user?: IUser; + dehydratedState?: DehydratedState; +}> = ({ children, user, dehydratedState }) => { + const sdkLocale = useSdkLocale(); + const { i18n } = useTranslation(); + const { t } = useTranslation('common'); + const router = useRouter(); + + const onBack = () => { + router.push({ pathname: '/space' }); + }; + + const routes = [ + { + Icon: Settings, + label: t('settings.title'), + route: '/admin/setting', + pathTo: '/admin/setting', + }, + ]; + + return ( + + + {t('noun.adminPanel')} + + + +
      + } + onBack={onBack} + /> + } + > + + + {children} +
      +
      +
      +
      + ); +}; diff --git a/apps/nextjs-app/src/features/app/layouts/BaseLayout.tsx b/apps/nextjs-app/src/features/app/layouts/BaseLayout.tsx index c7529b534..940e51df5 100644 --- a/apps/nextjs-app/src/features/app/layouts/BaseLayout.tsx +++ b/apps/nextjs-app/src/features/app/layouts/BaseLayout.tsx @@ -9,6 +9,7 @@ import { AppLayout } from '@/features/app/layouts'; import { BaseSideBar } from '../blocks/base/base-side-bar/BaseSideBar'; import { BaseSidebarHeaderLeft } from '../blocks/base/base-side-bar/BaseSidebarHeaderLeft'; import { BasePermissionListener } from '../blocks/base/BasePermissionListener'; +import { UsageLimitModal } from '../components/billing/UsageLimitModal'; import { Sidebar } from '../components/sidebar/Sidebar'; import { SideBarFooter } from '../components/SideBarFooter'; import { useSdkLocale } from '../hooks/useSdkLocale'; @@ -54,6 +55,7 @@ export const BaseLayout: React.FC<{ {isHydrated &&
      {children}
      }
    + diff --git a/apps/nextjs-app/src/features/app/layouts/SpaceLayout.tsx b/apps/nextjs-app/src/features/app/layouts/SpaceLayout.tsx index cb5b93684..d9c751cd4 100644 --- a/apps/nextjs-app/src/features/app/layouts/SpaceLayout.tsx +++ b/apps/nextjs-app/src/features/app/layouts/SpaceLayout.tsx @@ -30,7 +30,7 @@ export const SpaceLayout: React.FC<{ }>
    - +
    diff --git a/apps/nextjs-app/src/features/auth/pages/LoginPage.tsx b/apps/nextjs-app/src/features/auth/pages/LoginPage.tsx index 98a3362e0..36019f0ff 100644 --- a/apps/nextjs-app/src/features/auth/pages/LoginPage.tsx +++ b/apps/nextjs-app/src/features/auth/pages/LoginPage.tsx @@ -1,6 +1,6 @@ -import { QueryClientProvider } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { TeableNew } from '@teable/icons'; -import { createQueryClient } from '@teable/sdk/context/app/queryClient'; +import { getSetting } from '@teable/openapi'; import { Tabs, TabsList, TabsTrigger } from '@teable/ui-lib/shadcn'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; @@ -11,8 +11,6 @@ import type { ISignForm } from '../components/SignForm'; import { SignForm } from '../components/SignForm'; import { SocialAuth } from '../components/SocialAuth'; -const queryClient = createQueryClient(); - export const LoginPage: FC = () => { const { t } = useTranslation(authConfig.i18nNamespaces); const router = useRouter(); @@ -21,8 +19,16 @@ export const LoginPage: FC = () => { const onSuccess = useCallback(() => { window.location.href = redirect ? decodeURIComponent(redirect) : '/space'; }, [redirect]); + + const { data: setting } = useQuery({ + queryKey: ['setting'], + queryFn: () => getSetting().then(({ data }) => data), + }); + + const { disallowSignUp = false } = setting ?? {}; + return ( - + <>
    @@ -30,18 +36,22 @@ export const LoginPage: FC = () => { {t('common:brand')}
    - setSignType(val as ISignForm['type'])}> - - {t('auth:button.signin')} - {t('auth:button.signup')} - - + {disallowSignUp ? ( + t('auth:button.signin') + ) : ( + setSignType(val as ISignForm['type'])}> + + {t('auth:button.signin')} + {t('auth:button.signup')} + + + )}
    - + ); }; diff --git a/apps/nextjs-app/src/lib/server-env.ts b/apps/nextjs-app/src/lib/server-env.ts index cce0543ef..f37b03482 100644 --- a/apps/nextjs-app/src/lib/server-env.ts +++ b/apps/nextjs-app/src/lib/server-env.ts @@ -7,6 +7,7 @@ export interface IServerEnv { sentryDsn?: string; socialAuthProviders?: string[]; storagePrefix?: string; + edition?: string; } export const EnvContext = React.createContext({}); diff --git a/apps/nextjs-app/src/lib/withAuthSSR.ts b/apps/nextjs-app/src/lib/withAuthSSR.ts index 17c602cb7..3be687887 100644 --- a/apps/nextjs-app/src/lib/withAuthSSR.ts +++ b/apps/nextjs-app/src/lib/withAuthSSR.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type { ParsedUrlQuery } from 'querystring'; import type { IHttpError } from '@teable/core'; import type { @@ -13,19 +14,17 @@ export type GetServerSideProps< P extends { [key: string]: any } = { [key: string]: any }, Q extends ParsedUrlQuery = ParsedUrlQuery, D extends PreviewData = PreviewData, -> = ( - context: GetServerSidePropsContext, - ssrApi: SsrApi -) => Promise>; + T extends SsrApi = SsrApi, +> = (context: GetServerSidePropsContext, ssrApi: T) => Promise>; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export default function withAuthSSR

    ( - handler: GetServerSideProps

    +export default function withAuthSSR

    ( + handler: GetServerSideProps, + ssrClass: new () => T = SsrApi as new () => T ): NextGetServerSideProps { return async (context: GetServerSidePropsContext) => { const req = context.req; try { - const ssrApi = new SsrApi(); + const ssrApi = new ssrClass(); ssrApi.axios.defaults.headers['cookie'] = req.headers.cookie || ''; return await handler(context, ssrApi); } catch (e) { diff --git a/apps/nextjs-app/src/pages/admin/setting.tsx b/apps/nextjs-app/src/pages/admin/setting.tsx new file mode 100644 index 000000000..fd4defe93 --- /dev/null +++ b/apps/nextjs-app/src/pages/admin/setting.tsx @@ -0,0 +1,39 @@ +import type { GetServerSideProps } from 'next'; +import type { ReactElement } from 'react'; +import type { ISettingPageProps } from '@/features/app/blocks/admin'; +import { SettingPage } from '@/features/app/blocks/admin'; +import { AdminLayout } from '@/features/app/layouts/AdminLayout'; +import { getTranslationsProps } from '@/lib/i18n'; +import type { NextPageWithLayout } from '@/lib/type'; +import withAuthSSR from '@/lib/withAuthSSR'; + +const Setting: NextPageWithLayout = ({ settingServerData }) => ( + +); + +export const getServerSideProps: GetServerSideProps = withAuthSSR(async (context, ssrApi) => { + const userMe = await ssrApi.getUserMe(); + + if (!userMe?.isAdmin) { + return { + redirect: { + destination: '/403', + permanent: false, + }, + }; + } + + const setting = await ssrApi.getSetting(); + return { + props: { + settingServerData: setting, + ...(await getTranslationsProps(context, 'common')), + }, + }; +}); + +Setting.getLayout = function getLayout(page: ReactElement, pageProps) { + return {page}; +}; + +export default Setting; diff --git a/apps/nextjs-app/src/pages/auth/login.tsx b/apps/nextjs-app/src/pages/auth/login.tsx index 3d1417180..c85ee4cac 100644 --- a/apps/nextjs-app/src/pages/auth/login.tsx +++ b/apps/nextjs-app/src/pages/auth/login.tsx @@ -1,3 +1,5 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { createQueryClient } from '@teable/sdk/context'; import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'; import { LoginPage } from '@/features/auth/pages/LoginPage'; import { authConfig } from '@/features/i18n/auth.config'; @@ -7,8 +9,14 @@ type Props = { /** Add props here */ }; +const queryClient = createQueryClient(); + export default function LoginRoute(_props: InferGetServerSidePropsType) { - return ; + return ( + + + + ); } export const getServerSideProps: GetServerSideProps = async (context) => { diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index b96c6acfb..e4b2975f9 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -23,7 +23,10 @@ "loading": "Loading...", "refreshPage": "Refresh Page", "yesDelete": "Yes, delete", - "rename": "Rename" + "rename": "Rename", + "change": "Change", + "upgrade": "Upgrade", + "search": "Search" }, "quickAction": { "title": "Quick Actions...", @@ -100,7 +103,16 @@ "record": "Record", "dashboard": "Dashboard", "automation": "Automation", - "authorityMatrix": "Authority Matrix" + "authorityMatrix": "Authority Matrix", + "adminPanel": "Admin Panel", + "license": "License", + "instanceId": "Instance ID" + }, + "level": { + "free": "Free", + "plus": "Plus", + "pro": "Pro", + "enterprise": "Enterprise" }, "noResult": "No Result.", "untitled": "Untitled", @@ -158,5 +170,22 @@ "mainLink": "https://help.teable.io", "apiLink": "https://help.teable.io/developer/api" }, - "pagePermissionChangeTip": "Page permissions have been updated. Please refresh the page to see the latest content." + "pagePermissionChangeTip": "Page permissions have been updated. Please refresh the page to see the latest content.", + "listEmptyTips": "The list is empty", + "billing": { + "overLimits": "Over limits", + "overLimitsDescription": "Your current subscription has exceeded its usage limit. Please upgrade your plan to continue using this feature without interruption.", + "userLimitExceededDescription": "The current instance have reached the maximum number of users allowed by your license. Please deactivate some users or upgrade the license.", + "unavailableInPlanTips": "The current subscription plan does not support this feature", + "levelTips": "This space is currently on the {{level}} plan" + }, + "admin": { + "setting": { + "description": "Change the settings for your current instance", + "allowSignUp": "Allow creating new accounts", + "allowSignUpDescription": "Disabling this option will prohibit new user registrations, and the register button will no longer appear on the login page.", + "allowSpaceCreation": "Allow everyone to create new workspaces", + "allowSpaceCreationDescription": "Disabling this option will prevent users other than administrators from creating new spaces." + } + } } diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json index e12a8dfa9..3d99e8223 100644 --- a/packages/common-i18n/src/locales/zh/common.json +++ b/packages/common-i18n/src/locales/zh/common.json @@ -23,7 +23,10 @@ "loading": "加载中...", "refreshPage": "刷新页面", "yesDelete": "是,删除", - "rename": "重命名" + "rename": "重命名", + "change": "变更", + "upgrade": "升级", + "search": "搜索" }, "quickAction": { "title": "快捷搜索...", @@ -100,7 +103,16 @@ "record": "记录", "dashboard": "仪表盘", "automation": "自动化", - "authorityMatrix": "权限矩阵" + "authorityMatrix": "权限矩阵", + "adminPanel": "管理面板", + "license": "许可证", + "instanceId": "实例 ID" + }, + "level": { + "free": "免费版", + "plus": "高级版", + "pro": "专业版", + "enterprise": "企业版" }, "noResult": "无结果。", "untitled": "未命名", @@ -157,5 +169,22 @@ "mainLink": "https://help.teable.cn", "apiLink": "https://help.teable.cn/gao-dai-ma-kai-fa/api" }, - "pagePermissionChangeTip": "页面权限已更新,请刷新页面以查看最新内容。" + "pagePermissionChangeTip": "页面权限已更新,请刷新页面以查看最新内容。", + "listEmptyTips": "列表为空", + "billing": { + "overLimits": "超出限制", + "overLimitsDescription": "用量已超出了当前订阅版本的限制,您可以升级到更高级的版本以解锁更多功能用量。", + "userLimitExceededDescription": "当前实例已达到许可证允许的最大用户数限制,请停用超限的用户或升级许可证以继续使用。", + "unavailableInPlanTips": "当前订阅计划不支持此功能", + "levelTips": "当前空间使用的是{{level}}计划" + }, + "admin": { + "setting": { + "description": "更改当前实例的设置", + "allowSignUp": "允许新用户注册", + "allowSignUpDescription": "关闭此选项将禁止新用户注册,登录页面将不再显示注册按钮。", + "allowSpaceCreation": "允许所有人创建新的空间", + "allowSpaceCreationDescription": "关闭此选项将禁止除管理员以外的用户创建新的空间。" + } + } } diff --git a/packages/core/src/errors/http/constant.ts b/packages/core/src/errors/http/constant.ts index 679e70ee9..daa537e7a 100644 --- a/packages/core/src/errors/http/constant.ts +++ b/packages/core/src/errors/http/constant.ts @@ -5,8 +5,10 @@ export const ErrorCodeToStatusMap: Record = { [HttpErrorCode.VALIDATION_ERROR]: 400, [HttpErrorCode.UNAUTHORIZED]: 401, [HttpErrorCode.UNAUTHORIZED_SHARE]: 401, + [HttpErrorCode.PAYMENT_REQUIRED]: 402, [HttpErrorCode.RESTRICTED_RESOURCE]: 403, [HttpErrorCode.NOT_FOUND]: 404, + [HttpErrorCode.USER_LIMIT_EXCEEDED]: 460, [HttpErrorCode.INTERNAL_SERVER_ERROR]: 500, [HttpErrorCode.DATABASE_CONNECTION_UNAVAILABLE]: 503, [HttpErrorCode.GATEWAY_TIMEOUT]: 504, diff --git a/packages/core/src/errors/http/http-response.types.ts b/packages/core/src/errors/http/http-response.types.ts index ad330f0ae..ad95a1971 100644 --- a/packages/core/src/errors/http/http-response.types.ts +++ b/packages/core/src/errors/http/http-response.types.ts @@ -16,10 +16,14 @@ export enum HttpErrorCode { UNAUTHORIZED = 'unauthorized', // 401 - Given the bearer token used, the client doesn't have permission to perform this operation. UNAUTHORIZED_SHARE = 'unauthorized_share', + // 402 - Payment Required + PAYMENT_REQUIRED = 'payment_required', // 403 - Given the bearer token used, the client doesn't have permission to perform this operation. RESTRICTED_RESOURCE = 'restricted_resource', // 404 - Given the bearer token used, the resource does not exist. This error can also indicate that the resource has not been shared with owner of the bearer token. NOT_FOUND = 'not_found', + // 460 - The user has reached the limit of the number of users that can be created in the current instance. + USER_LIMIT_EXCEEDED = 'user_limit_exceeded', // 500 - An unexpected error occurred. INTERNAL_SERVER_ERROR = 'internal_server_error', // 503 - database is unavailable or is not in a state that can be queried. Please try again later. diff --git a/packages/core/src/utils/id-generator.ts b/packages/core/src/utils/id-generator.ts index 3cc586857..abb74716b 100644 --- a/packages/core/src/utils/id-generator.ts +++ b/packages/core/src/utils/id-generator.ts @@ -29,6 +29,8 @@ export enum IdPrefix { AuthorityMatrix = 'aut', AuthorityMatrixRole = 'aur', + + License = 'lic', } const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -128,3 +130,7 @@ export function generateAuthorityMatrixId() { export function generateAuthorityMatrixRoleId() { return IdPrefix.AuthorityMatrixRole + getRandomString(16); } + +export function generateLicenseId() { + return IdPrefix.License + getRandomString(16); +} diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20240625032002_add_admin/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20240625032002_add_admin/migration.sql new file mode 100644 index 000000000..41cf55563 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20240625032002_add_admin/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "deactivated_time" TIMESTAMP(3), +ADD COLUMN "is_admin" BOOLEAN; diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20240626072754_add_setting_table/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20240626072754_add_setting_table/migration.sql new file mode 100644 index 000000000..2a74341b9 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20240626072754_add_setting_table/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "setting" ( + "instance_id" TEXT NOT NULL, + "disallow_sign_up" BOOLEAN, + "disallow_space_creation" BOOLEAN, + + CONSTRAINT "setting_pkey" PRIMARY KEY ("instance_id") +); + +-- Insert initial record +INSERT INTO "setting" ("instance_id", "disallow_sign_up", "disallow_space_creation") VALUES (gen_random_uuid(), NULL, NULL); diff --git a/packages/db-main-prisma/prisma/postgres/schema.prisma b/packages/db-main-prisma/prisma/postgres/schema.prisma index b3f290541..2506b6df1 100644 --- a/packages/db-main-prisma/prisma/postgres/schema.prisma +++ b/packages/db-main-prisma/prisma/postgres/schema.prisma @@ -184,8 +184,10 @@ model User { phone String? @unique email String @unique avatar String? + isAdmin Boolean? @map("is_admin") notifyMeta String? @map("notify_meta") lastSignTime DateTime? @map("last_sign_time") + deactivatedTime DateTime? @map("deactivated_time") createdTime DateTime @default(now()) @map("created_time") deletedTime DateTime? @map("deleted_time") lastModifiedTime DateTime? @updatedAt @map("last_modified_time") @@ -319,4 +321,12 @@ model AccessToken { lastModifiedTime DateTime? @updatedAt @map("last_modified_time") @@map("access_token") +} + +model Setting { + instanceId String @id @default(cuid()) @map("instance_id") + disallowSignUp Boolean? @map("disallow_sign_up") + disallowSpaceCreation Boolean? @map("disallow_space_creation") + + @@map("setting") } \ No newline at end of file diff --git a/packages/db-main-prisma/prisma/sqlite/migrations/20240625031955_add_admin/migration.sql b/packages/db-main-prisma/prisma/sqlite/migrations/20240625031955_add_admin/migration.sql new file mode 100644 index 000000000..bc93e2328 --- /dev/null +++ b/packages/db-main-prisma/prisma/sqlite/migrations/20240625031955_add_admin/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "deactivated_time" DATETIME; +ALTER TABLE "users" ADD COLUMN "is_admin" BOOLEAN; diff --git a/packages/db-main-prisma/prisma/sqlite/migrations/20240626072703_add_setting_table/migration.sql b/packages/db-main-prisma/prisma/sqlite/migrations/20240626072703_add_setting_table/migration.sql new file mode 100644 index 000000000..2a74341b9 --- /dev/null +++ b/packages/db-main-prisma/prisma/sqlite/migrations/20240626072703_add_setting_table/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "setting" ( + "instance_id" TEXT NOT NULL, + "disallow_sign_up" BOOLEAN, + "disallow_space_creation" BOOLEAN, + + CONSTRAINT "setting_pkey" PRIMARY KEY ("instance_id") +); + +-- Insert initial record +INSERT INTO "setting" ("instance_id", "disallow_sign_up", "disallow_space_creation") VALUES (gen_random_uuid(), NULL, NULL); diff --git a/packages/db-main-prisma/prisma/sqlite/schema.prisma b/packages/db-main-prisma/prisma/sqlite/schema.prisma index 33e14429f..43f7c60a9 100644 --- a/packages/db-main-prisma/prisma/sqlite/schema.prisma +++ b/packages/db-main-prisma/prisma/sqlite/schema.prisma @@ -184,8 +184,10 @@ model User { phone String? @unique email String @unique avatar String? + isAdmin Boolean? @map("is_admin") notifyMeta String? @map("notify_meta") lastSignTime DateTime? @map("last_sign_time") + deactivatedTime DateTime? @map("deactivated_time") createdTime DateTime @default(now()) @map("created_time") deletedTime DateTime? @map("deleted_time") lastModifiedTime DateTime? @updatedAt @map("last_modified_time") @@ -319,4 +321,12 @@ model AccessToken { lastModifiedTime DateTime? @updatedAt @map("last_modified_time") @@map("access_token") +} + +model Setting { + instanceId String @id @default(cuid()) @map("instance_id") + disallowSignUp Boolean? @map("disallow_sign_up") + disallowSpaceCreation Boolean? @map("disallow_space_creation") + + @@map("setting") } \ No newline at end of file diff --git a/packages/db-main-prisma/prisma/template.prisma b/packages/db-main-prisma/prisma/template.prisma index dddaf540b..0d21c5326 100644 --- a/packages/db-main-prisma/prisma/template.prisma +++ b/packages/db-main-prisma/prisma/template.prisma @@ -184,8 +184,10 @@ model User { phone String? @unique email String @unique avatar String? + isAdmin Boolean? @map("is_admin") notifyMeta String? @map("notify_meta") lastSignTime DateTime? @map("last_sign_time") + deactivatedTime DateTime? @map("deactivated_time") createdTime DateTime @default(now()) @map("created_time") deletedTime DateTime? @map("deleted_time") lastModifiedTime DateTime? @updatedAt @map("last_modified_time") @@ -319,4 +321,12 @@ model AccessToken { lastModifiedTime DateTime? @updatedAt @map("last_modified_time") @@map("access_token") +} + +model Setting { + instanceId String @id @default(cuid()) @map("instance_id") + disallowSignUp Boolean? @map("disallow_sign_up") + disallowSpaceCreation Boolean? @map("disallow_space_creation") + + @@map("setting") } \ No newline at end of file diff --git a/packages/icons/src/components/Admin.tsx b/packages/icons/src/components/Admin.tsx new file mode 100644 index 000000000..0272e512e --- /dev/null +++ b/packages/icons/src/components/Admin.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const Admin = (props: SVGProps) => ( + + + +); +export default Admin; diff --git a/packages/icons/src/components/License.tsx b/packages/icons/src/components/License.tsx new file mode 100644 index 000000000..759892f2d --- /dev/null +++ b/packages/icons/src/components/License.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const License = (props: SVGProps) => ( + + + + +); +export default License; diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index b4a0f1542..bff5900b5 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -1,4 +1,5 @@ export { default as A } from './components/A'; +export { default as Admin } from './components/Admin'; export { default as AlertCircle } from './components/AlertCircle'; export { default as AlertTriangle } from './components/AlertTriangle'; export { default as Apple } from './components/Apple'; @@ -77,6 +78,7 @@ export { default as Key } from './components/Key'; export { default as Layers } from './components/Layers'; export { default as LayoutList } from './components/LayoutList'; export { default as LayoutTemplate } from './components/LayoutTemplate'; +export { default as License } from './components/License'; export { default as Link } from './components/Link'; export { default as ListChecks } from './components/ListChecks'; export { default as ListOrdered } from './components/ListOrdered'; diff --git a/packages/openapi/src/auth/user-me.ts b/packages/openapi/src/auth/user-me.ts index a081be60a..cfd17e7f5 100644 --- a/packages/openapi/src/auth/user-me.ts +++ b/packages/openapi/src/auth/user-me.ts @@ -14,6 +14,7 @@ export const userMeVoSchema = z.object({ phone: z.string().nullable().optional(), notifyMeta: userNotifyMetaSchema, hasPassword: z.boolean(), + isAdmin: z.boolean().nullable(), }); export type IUserMeVo = z.infer; diff --git a/packages/openapi/src/billing/index.ts b/packages/openapi/src/billing/index.ts new file mode 100644 index 000000000..afb80ae9e --- /dev/null +++ b/packages/openapi/src/billing/index.ts @@ -0,0 +1 @@ +export * from './subscription'; diff --git a/packages/openapi/src/billing/subscription/get-subscription-summary-list.ts b/packages/openapi/src/billing/subscription/get-subscription-summary-list.ts new file mode 100644 index 000000000..8c64f3c9d --- /dev/null +++ b/packages/openapi/src/billing/subscription/get-subscription-summary-list.ts @@ -0,0 +1,30 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute } from '../../utils'; +import { z } from '../../zod'; +import type { ISubscriptionSummaryVo } from './get-subscription-summary'; +import { subscriptionSummaryVoSchema } from './get-subscription-summary'; + +export const GET_SUBSCRIPTION_SUMMARY_LIST = '/billing/subscription/summary'; + +export const GetSubscriptionSummaryListRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_SUBSCRIPTION_SUMMARY_LIST, + description: 'Retrieves a summary of subscription information across all spaces', + request: {}, + responses: { + 200: { + description: 'Returns a summary of subscription information for each space.', + content: { + 'application/json': { + schema: z.array(subscriptionSummaryVoSchema), + }, + }, + }, + }, + tags: ['billing'], +}); + +export const getSubscriptionSummaryList = async () => { + return axios.get(GET_SUBSCRIPTION_SUMMARY_LIST); +}; diff --git a/packages/openapi/src/billing/subscription/get-subscription-summary.ts b/packages/openapi/src/billing/subscription/get-subscription-summary.ts new file mode 100644 index 000000000..b053cf85c --- /dev/null +++ b/packages/openapi/src/billing/subscription/get-subscription-summary.ts @@ -0,0 +1,55 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; + +export enum RecurringIntervalType { + Month = 'month', + Year = 'year', +} + +export enum BillingProductLevel { + Free = 'free', + Plus = 'plus', + Pro = 'pro', + Enterprise = 'enterprise', +} + +export const subscriptionSummaryVoSchema = z.object({ + spaceId: z.string(), + level: z.nativeEnum(BillingProductLevel), +}); + +export type ISubscriptionSummaryVo = z.infer; + +export const GET_SUBSCRIPTION_SUMMARY = '/space/{spaceId}/billing/subscription/summary'; + +export const GetSubscriptionSummaryRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_SUBSCRIPTION_SUMMARY, + description: 'Retrieves a summary of subscription information for a space', + request: { + params: z.object({ + spaceId: z.string(), + }), + }, + responses: { + 200: { + description: 'Returns a summary of subscription information about a space.', + content: { + 'application/json': { + schema: subscriptionSummaryVoSchema, + }, + }, + }, + }, + tags: ['billing'], +}); + +export const getSubscriptionSummary = async (spaceId: string) => { + return axios.get( + urlBuilder(GET_SUBSCRIPTION_SUMMARY, { + spaceId, + }) + ); +}; diff --git a/packages/openapi/src/billing/subscription/index.ts b/packages/openapi/src/billing/subscription/index.ts new file mode 100644 index 000000000..7160e929f --- /dev/null +++ b/packages/openapi/src/billing/subscription/index.ts @@ -0,0 +1,2 @@ +export * from './get-subscription-summary'; +export * from './get-subscription-summary-list'; diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index a21d60ffd..d594f07b2 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -22,3 +22,6 @@ export * from './export'; export * from './utils'; export * from './zod'; export * from './pin'; +export * from './billing'; +export * from './setting'; +export * from './usage'; diff --git a/packages/openapi/src/setting/get.ts b/packages/openapi/src/setting/get.ts new file mode 100644 index 000000000..3428718f1 --- /dev/null +++ b/packages/openapi/src/setting/get.ts @@ -0,0 +1,36 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; +import { axios } from '../axios'; +import { registerRoute } from '../utils'; + +export const settingVoSchema = z.object({ + instanceId: z.string(), + disallowSignUp: z.boolean().nullable(), + disallowSpaceCreation: z.boolean().nullable(), +}); + +export type ISettingVo = z.infer; + +export const GET_SETTING = '/admin/setting'; + +export const GetSettingRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_SETTING, + description: 'Get the instance settings', + request: {}, + responses: { + 200: { + description: 'Returns the instance settings.', + content: { + 'application/json': { + schema: settingVoSchema, + }, + }, + }, + }, + tags: ['admin'], +}); + +export const getSetting = async () => { + return axios.get(GET_SETTING); +}; diff --git a/packages/openapi/src/setting/index.ts b/packages/openapi/src/setting/index.ts new file mode 100644 index 000000000..d1ae10047 --- /dev/null +++ b/packages/openapi/src/setting/index.ts @@ -0,0 +1,2 @@ +export * from './get'; +export * from './update'; diff --git a/packages/openapi/src/setting/update.ts b/packages/openapi/src/setting/update.ts new file mode 100644 index 000000000..24b3dd42a --- /dev/null +++ b/packages/openapi/src/setting/update.ts @@ -0,0 +1,38 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; +import { axios } from '../axios'; +import { registerRoute } from '../utils'; + +export const updateSettingRoSchema = z.object({ + disallowSignUp: z.boolean().optional(), + disallowSpaceCreation: z.boolean().optional(), +}); + +export type IUpdateSettingRo = z.infer; + +export const UPDATE_SETTING = '/admin/setting'; + +export const UpdateSettingRoute: RouteConfig = registerRoute({ + method: 'patch', + path: UPDATE_SETTING, + description: 'Get the instance settings', + request: { + body: { + content: { + 'application/json': { + schema: updateSettingRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Update settings successfully.', + }, + }, + tags: ['admin'], +}); + +export const updateSetting = async (updateSettingRo: IUpdateSettingRo) => { + return axios.patch(UPDATE_SETTING, updateSettingRo); +}; diff --git a/packages/openapi/src/usage/get-instance-usage.ts b/packages/openapi/src/usage/get-instance-usage.ts new file mode 100644 index 000000000..47e820565 --- /dev/null +++ b/packages/openapi/src/usage/get-instance-usage.ts @@ -0,0 +1,29 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute } from '../utils'; +import type { IUsageVo } from './get-space-usage'; +import { usageVoSchema } from './get-space-usage'; + +export const GET_INSTANCE_USAGE = '/instance/usage'; + +export const GetInstanceUsageRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_INSTANCE_USAGE, + description: 'Get usage information for the instance', + request: {}, + responses: { + 200: { + description: 'Returns usage information for the instance.', + content: { + 'application/json': { + schema: usageVoSchema, + }, + }, + }, + }, + tags: ['usage'], +}); + +export const getInstanceUsage = async () => { + return axios.get(GET_INSTANCE_USAGE); +}; diff --git a/packages/openapi/src/usage/get-space-usage.ts b/packages/openapi/src/usage/get-space-usage.ts new file mode 100644 index 000000000..854df7ce4 --- /dev/null +++ b/packages/openapi/src/usage/get-space-usage.ts @@ -0,0 +1,86 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; +import { axios } from '../axios'; +import { BillingProductLevel } from '../billing'; +import { registerRoute, urlBuilder } from '../utils'; + +export enum UsageFeature { + NumRows = 'numRows', + AttachmentSize = 'attachmentSize', + NumDatabaseConnections = 'numDatabaseConnections', + NumApiCalls = 'numApiCalls', +} + +export const usageFeatureSchema = z.object({ + [UsageFeature.NumRows]: z.number(), + [UsageFeature.AttachmentSize]: z.number(), + [UsageFeature.NumDatabaseConnections]: z.number(), + [UsageFeature.NumApiCalls]: z.number(), +}); + +export enum UsageFeatureLimit { + MaxRows = 'maxRows', + MaxSizeAttachments = 'maxSizeAttachments', + MaxNumDatabaseConnections = 'maxNumDatabaseConnections', + MaxNumApiCallsPerMonth = 'maxNumApiCallsPerMonth', + MaxRevisionHistoryDays = 'maxRevisionHistoryDays', + MaxAutomationHistoryDays = 'maxAutomationHistoryDays', + AutomationEnable = 'automationEnable', + AuditLogEnable = 'auditLogEnable', + AdminPanelEnable = 'adminPanelEnable', + ExtensionsEnable = 'extensionsEnable', + RowColoringEnable = 'rowColoringEnable', + AdvancedPermissionsEnable = 'advancedPermissionsEnable', + PasswordRestrictedSharesEnable = 'passwordRestrictedSharesEnable', +} + +export const usageFeatureLimitSchema = z.object({ + [UsageFeatureLimit.MaxRows]: z.number(), + [UsageFeatureLimit.MaxSizeAttachments]: z.number(), + [UsageFeatureLimit.MaxNumDatabaseConnections]: z.number(), + [UsageFeatureLimit.MaxNumApiCallsPerMonth]: z.number(), + [UsageFeatureLimit.MaxRevisionHistoryDays]: z.number(), + [UsageFeatureLimit.MaxAutomationHistoryDays]: z.number(), + [UsageFeatureLimit.AutomationEnable]: z.boolean(), + [UsageFeatureLimit.AuditLogEnable]: z.boolean(), + [UsageFeatureLimit.AdminPanelEnable]: z.boolean(), + [UsageFeatureLimit.ExtensionsEnable]: z.boolean(), + [UsageFeatureLimit.RowColoringEnable]: z.boolean(), + [UsageFeatureLimit.AdvancedPermissionsEnable]: z.boolean(), + [UsageFeatureLimit.PasswordRestrictedSharesEnable]: z.boolean(), +}); + +export const usageVoSchema = z.object({ + level: z.nativeEnum(BillingProductLevel), + limit: usageFeatureLimitSchema, +}); + +export type IUsageVo = z.infer; + +export const GET_SPACE_USAGE = '/space/{spaceId}/usage'; + +export const GetSpaceUsageRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_SPACE_USAGE, + description: 'Get usage information for the space', + request: { + params: z.object({ + spaceId: z.string(), + }), + }, + responses: { + 200: { + description: 'Returns usage information for the space.', + content: { + 'application/json': { + schema: usageVoSchema, + }, + }, + }, + }, + tags: ['usage'], +}); + +export const getSpaceUsage = async (spaceId: string) => { + return axios.get(urlBuilder(GET_SPACE_USAGE, { spaceId })); +}; diff --git a/packages/openapi/src/usage/index.ts b/packages/openapi/src/usage/index.ts new file mode 100644 index 000000000..574741578 --- /dev/null +++ b/packages/openapi/src/usage/index.ts @@ -0,0 +1,2 @@ +export * from './get-space-usage'; +export * from './get-instance-usage'; diff --git a/packages/sdk/src/components/billing/store/index.ts b/packages/sdk/src/components/billing/store/index.ts new file mode 100644 index 000000000..3dd6921ed --- /dev/null +++ b/packages/sdk/src/components/billing/store/index.ts @@ -0,0 +1 @@ +export * from './usage-limit-modal'; diff --git a/packages/sdk/src/components/billing/store/usage-limit-modal.ts b/packages/sdk/src/components/billing/store/usage-limit-modal.ts new file mode 100644 index 000000000..83a838af8 --- /dev/null +++ b/packages/sdk/src/components/billing/store/usage-limit-modal.ts @@ -0,0 +1,43 @@ +import { create } from 'zustand'; + +export enum UsageLimitModalType { + Upgrade = 'upgrade', + User = 'user', +} + +interface IUsageLimitModalState { + modalType: UsageLimitModalType; + modalOpen: boolean; + openModal: (modalType: UsageLimitModalType) => void; + closeModal: () => void; + toggleModal: (open: boolean) => void; +} + +export const useUsageLimitModalStore = create((set) => ({ + modalType: UsageLimitModalType.Upgrade, + modalOpen: false, + openModal: () => { + set((state) => { + return { + ...state, + modalOpen: true, + }; + }); + }, + closeModal: () => { + set((state) => { + return { + ...state, + modalOpen: false, + }; + }); + }, + toggleModal: (open: boolean) => { + set((state) => { + return { + ...state, + modalOpen: open, + }; + }); + }, +})); diff --git a/packages/sdk/src/components/select-field-dialog/FieldCreateOrSelectModal.tsx b/packages/sdk/src/components/select-field-dialog/FieldCreateOrSelectModal.tsx index ddf625e1b..482134af9 100644 --- a/packages/sdk/src/components/select-field-dialog/FieldCreateOrSelectModal.tsx +++ b/packages/sdk/src/components/select-field-dialog/FieldCreateOrSelectModal.tsx @@ -122,6 +122,7 @@ export const FieldCreateOrSelectModal = forwardRef< className="p-5" closeable={false} onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} > {title} diff --git a/packages/sdk/src/context/app/queryClient.tsx b/packages/sdk/src/context/app/queryClient.tsx index 97ae4b47f..c726dc0e9 100644 --- a/packages/sdk/src/context/app/queryClient.tsx +++ b/packages/sdk/src/context/app/queryClient.tsx @@ -1,6 +1,10 @@ import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; import type { IHttpError } from '@teable/core'; import { toast } from '@teable/ui-lib'; +import { + UsageLimitModalType, + useUsageLimitModalStore, +} from '../../components/billing/store/usage-limit-modal'; export const errorRequestHandler = (error: unknown) => { const { code, message, status } = error as IHttpError; @@ -9,6 +13,14 @@ export const errorRequestHandler = (error: unknown) => { window.location.href = `/auth/login?redirect=${encodeURIComponent(window.location.href)}`; return; } + if (status === 402) { + useUsageLimitModalStore.setState({ modalType: UsageLimitModalType.Upgrade, modalOpen: true }); + return; + } + if (status === 460) { + useUsageLimitModalStore.setState({ modalType: UsageLimitModalType.User, modalOpen: true }); + return; + } toast({ variant: 'destructive', title: code || 'Unknown Error', diff --git a/packages/ui-lib/src/shadcn/ui/table.tsx b/packages/ui-lib/src/shadcn/ui/table.tsx index 3f683777c..8b8d49e1b 100644 --- a/packages/ui-lib/src/shadcn/ui/table.tsx +++ b/packages/ui-lib/src/shadcn/ui/table.tsx @@ -4,9 +4,7 @@ import { cn } from '../utils'; const Table = React.forwardRef>( ({ className, ...props }, ref) => ( -

    - - +
    ) ); Table.displayName = 'Table';