mirror of
https://github.com/teableio/teable.git
synced 2026-03-23 00:04:56 +08:00
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
This commit is contained in:
parent
13b4463428
commit
2bf8027dff
@ -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],
|
||||
};
|
||||
|
||||
@ -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!,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import { Events } from '../event.enum';
|
||||
|
||||
export class UserSignUpEvent {
|
||||
public readonly name = Events.USER_SIGNUP;
|
||||
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -6,11 +6,11 @@ import { getFullStorageUrl } from '../../utils/full-storage-url';
|
||||
export const pickUserMe = (
|
||||
user: Pick<
|
||||
Prisma.UserGetPayload<null>,
|
||||
'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')
|
||||
|
||||
@ -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<IClsStore>,
|
||||
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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<void>
|
||||
) {
|
||||
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];
|
||||
|
||||
|
||||
27
apps/nestjs-backend/src/features/setting/admin.guard.ts
Normal file
27
apps/nestjs-backend/src/features/setting/admin.guard.ts
Normal file
@ -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<IClsStore>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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<ISettingVo> {
|
||||
return await this.settingService.getSetting();
|
||||
}
|
||||
|
||||
@UseGuards(AdminGuard)
|
||||
@Patch()
|
||||
async updateSetting(
|
||||
@Body(new ZodValidationPipe(updateSettingRoSchema))
|
||||
updateSettingRo: IUpdateSettingRo
|
||||
): Promise<ISettingVo> {
|
||||
return await this.settingService.updateSetting(updateSettingRo);
|
||||
}
|
||||
}
|
||||
11
apps/nestjs-backend/src/features/setting/setting.module.ts
Normal file
11
apps/nestjs-backend/src/features/setting/setting.module.ts
Normal file
@ -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 {}
|
||||
30
apps/nestjs-backend/src/features/setting/setting.service.ts
Normal file
30
apps/nestjs-backend/src/features/setting/setting.service.ts
Normal file
@ -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<ISettingVo> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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<IClsStore>,
|
||||
@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<Prisma.UserCreateInput, 'name'> & { name?: string },
|
||||
account?: Omit<Prisma.AccountUncheckedCreateInput, 'userId'>
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<void>(urlBuilder(UPDATE_NOTIFICATION_STATUS, { notificationId }), data)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async getSetting() {
|
||||
return this.axios.get<ISettingVo>(GET_SETTING).then(({ data }) => data);
|
||||
}
|
||||
|
||||
async getUserMe() {
|
||||
return this.axios.get<IUserMeVo>(USER_ME).then(({ data }) => data);
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/nextjs-app/src/features/app/blocks/admin/index.ts
Normal file
1
apps/nextjs-app/src/features/app/blocks/admin/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './setting';
|
||||
@ -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 (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto overflow-x-hidden px-8 py-6">
|
||||
<div className="border-b pb-4">
|
||||
<h1 className="text-2xl font-semibold">{t('settings.title')}</h1>
|
||||
<div className="mt-3 text-sm text-slate-500">{t('admin.setting.description')}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col space-y-4 py-4">
|
||||
<div className="flex flex-row items-center justify-between rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="allow-sign-up">{t('admin.setting.allowSignUp')}</Label>
|
||||
<div className="text-[13px] text-gray-500">
|
||||
{t('admin.setting.allowSignUpDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="allow-sign-up"
|
||||
checked={!disallowSignUp}
|
||||
onCheckedChange={(checked) => onCheckedChange('disallowSignUp', !checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="allow-space-creation">{t('admin.setting.allowSpaceCreation')}</Label>
|
||||
<div className="text-[13px] text-gray-500">
|
||||
{t('admin.setting.allowSpaceCreationDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="allow-space-creation"
|
||||
checked={!disallowSpaceCreation}
|
||||
onCheckedChange={(checked) => onCheckedChange('disallowSpaceCreation', !checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grow" />
|
||||
|
||||
<CopyInstance instanceId={instanceId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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 (
|
||||
<div className="flex w-full shrink-0 items-center justify-between gap-x-2 overflow-hidden rounded-md bg-slate-100 p-4 dark:bg-slate-700">
|
||||
<span className="text-sm font-semibold">{t('noun.instanceId')}</span>
|
||||
<span className="flex-1 truncate text-sm text-gray-600">{instanceId}</span>
|
||||
<CopyButton size="xs" text={instanceId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from './CopyInstance';
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './SettingPage';
|
||||
export * from './components';
|
||||
@ -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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
@ -48,25 +74,49 @@ export const BaseSideBar = () => {
|
||||
<QuickAction>{t('common:quickAction.title')}</QuickAction>
|
||||
</div>
|
||||
<ul>
|
||||
{pageRoutes.map(({ href, text, Icon, disabled }) => {
|
||||
{pageRoutes.map(({ href, label, Icon, disabled }) => {
|
||||
return (
|
||||
<li key={href}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={'xs'}
|
||||
asChild
|
||||
className={cn(
|
||||
'w-full justify-start text-sm px-2 my-[2px]',
|
||||
href === router.asPath && 'bg-secondary'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Link href={href} className="font-normal">
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<p className="truncate">{text}</p>
|
||||
<div className="grow basis-0"></div>
|
||||
</Link>
|
||||
</Button>
|
||||
{disabled ? (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="my-[2px] w-full cursor-not-allowed justify-start text-sm font-normal text-gray-500 hover:bg-background hover:text-gray-500"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
asChild
|
||||
disabled
|
||||
>
|
||||
<div className="flex">
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<p className="truncate">{label}</p>
|
||||
<div className="grow basis-0"></div>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('billing.unavailableInPlanTips')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
asChild
|
||||
className={cn(
|
||||
'w-full justify-start text-sm my-[2px]',
|
||||
href === router.asPath && 'bg-secondary'
|
||||
)}
|
||||
>
|
||||
<Link href={href} className="font-normal">
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<p className="truncate">{label}</p>
|
||||
<div className="grow basis-0"></div>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -50,6 +50,7 @@ export const ShareViewPage = (props: IShareViewPageProps) => {
|
||||
email: '',
|
||||
notifyMeta: {},
|
||||
hasPassword: false,
|
||||
isAdmin: false,
|
||||
}}
|
||||
>
|
||||
<AnchorContext.Provider
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { IGetBaseVo, IGetSpaceVo } from '@teable/openapi';
|
||||
import type { IGetBaseVo, IGetSpaceVo, ISubscriptionSummaryVo } from '@teable/openapi';
|
||||
import { PinType, deleteSpace, updateSpace } from '@teable/openapi';
|
||||
import { ReactQueryKeys } from '@teable/sdk/config';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@teable/ui-lib/shadcn';
|
||||
@ -7,18 +7,22 @@ import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { type FC, useEffect, useState } from 'react';
|
||||
import { spaceConfig } from '@/features/i18n/space.config';
|
||||
import { LevelWithUpgrade } from '../../components/billing/LevelWithUpgrade';
|
||||
import { SpaceActionBar } from '../../components/space/SpaceActionBar';
|
||||
import { SpaceRenaming } from '../../components/space/SpaceRenaming';
|
||||
import { useIsCloud } from '../../hooks/useIsCloud';
|
||||
import { DraggableBaseGrid } from './DraggableBaseGrid';
|
||||
import { StarButton } from './space-side-bar/StarButton';
|
||||
|
||||
interface ISpaceCard {
|
||||
space: IGetSpaceVo;
|
||||
bases?: IGetBaseVo[];
|
||||
subscription?: ISubscriptionSummaryVo;
|
||||
}
|
||||
export const SpaceCard: FC<ISpaceCard> = (props) => {
|
||||
const { space, bases } = props;
|
||||
const { space, bases, subscription } = props;
|
||||
const router = useRouter();
|
||||
const isCloud = useIsCloud();
|
||||
const queryClient = useQueryClient();
|
||||
const [renaming, setRenaming] = useState<boolean>(false);
|
||||
const [spaceName, setSpaceName] = useState<string>(space.name);
|
||||
@ -78,6 +82,9 @@ export const SpaceCard: FC<ISpaceCard> = (props) => {
|
||||
</CardTitle>
|
||||
</SpaceRenaming>
|
||||
<StarButton className="opacity-100" id={space.id} type={PinType.Space} />
|
||||
{isCloud && (
|
||||
<LevelWithUpgrade level={subscription?.level} spaceId={space.id} withUpgrade />
|
||||
)}
|
||||
</div>
|
||||
<SpaceActionBar
|
||||
className="flex shrink-0 items-center gap-3"
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { PinType, deleteSpace, getSpaceById, updateSpace } from '@teable/openapi';
|
||||
import {
|
||||
PinType,
|
||||
deleteSpace,
|
||||
getSpaceById,
|
||||
getSubscriptionSummary,
|
||||
updateSpace,
|
||||
} from '@teable/openapi';
|
||||
import { ReactQueryKeys } from '@teable/sdk/config';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { spaceConfig } from '@/features/i18n/space.config';
|
||||
import { LevelWithUpgrade } from '../../components/billing/LevelWithUpgrade';
|
||||
import { Collaborators } from '../../components/collaborator-manage/space-inner/Collaborators';
|
||||
import { SpaceActionBar } from '../../components/space/SpaceActionBar';
|
||||
import { SpaceRenaming } from '../../components/space/SpaceRenaming';
|
||||
import { useIsCloud } from '../../hooks/useIsCloud';
|
||||
import { DraggableBaseGrid } from './DraggableBaseGrid';
|
||||
import { StarButton } from './space-side-bar/StarButton';
|
||||
import { useBaseList } from './useBaseList';
|
||||
@ -15,6 +23,7 @@ import { useBaseList } from './useBaseList';
|
||||
export const SpaceInnerPage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const isCloud = useIsCloud();
|
||||
const ref = useRef<HTMLDivElement>(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 = () => {
|
||||
<h1 className="text-2xl font-semibold">{space.name}</h1>
|
||||
</SpaceRenaming>
|
||||
<StarButton className="opacity-100" id={space.id} type={PinType.Space} />
|
||||
{isCloud && (
|
||||
<LevelWithUpgrade level={subscriptionSummary?.level} spaceId={space.id} withUpgrade />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{basesInSpace?.length ? (
|
||||
|
||||
@ -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<HTMLDivElement>(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 (
|
||||
<div ref={ref} className="flex h-screen flex-1 flex-col overflow-hidden py-8">
|
||||
<div className="flex items-center justify-between px-12">
|
||||
<h1 className="text-2xl font-semibold">{t('space:allSpaces')}</h1>
|
||||
<Button
|
||||
className={GUIDE_CREATE_SPACE}
|
||||
size={'sm'}
|
||||
disabled={isLoading}
|
||||
onClick={() => createSpaceMutator({})}
|
||||
>
|
||||
{isLoading && <Spin className="size-3" />}
|
||||
{t('space:action.createSpace')}
|
||||
</Button>
|
||||
{!disallowSpaceCreation && (
|
||||
<Button
|
||||
className={GUIDE_CREATE_SPACE}
|
||||
size={'sm'}
|
||||
disabled={isLoading}
|
||||
onClick={() => createSpaceMutator({})}
|
||||
>
|
||||
{isLoading && <Spin className="size-3" />}
|
||||
{t('space:action.createSpace')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-8 overflow-y-auto px-8 pt-8 sm:px-12">
|
||||
{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]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -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 (
|
||||
<div className="flex flex-col gap-2 overflow-hidden">
|
||||
<div className="px-3">
|
||||
<Button
|
||||
variant={'outline'}
|
||||
size={'xs'}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
onClick={() => addSpace({})}
|
||||
>
|
||||
{isLoading ? <Spin className="size-3" /> : <Plus />}
|
||||
</Button>
|
||||
{!disallowSpaceCreation && (
|
||||
<Button
|
||||
variant={'outline'}
|
||||
size={'xs'}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
onClick={() => addSpace({})}
|
||||
>
|
||||
{isLoading ? <Spin className="size-3" /> : <Plus />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3">
|
||||
<ul>
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
<ul>
|
||||
{pageRoutes.map(({ href, text, Icon }) => {
|
||||
{pageRoutes.map(({ href, text, Icon, hidden }) => {
|
||||
if (hidden) return null;
|
||||
return (
|
||||
<li key={href}>
|
||||
<Button
|
||||
|
||||
@ -22,7 +22,7 @@ interface CollaboratorsProps {
|
||||
maxAvatarLen?: number;
|
||||
}
|
||||
|
||||
type ICollaboratorUser = Omit<IUser, 'phone' | 'notifyMeta' | 'hasPassword'>;
|
||||
type ICollaboratorUser = Omit<IUser, 'phone' | 'notifyMeta' | 'hasPassword' | 'isAdmin'>;
|
||||
|
||||
export const Collaborators: React.FC<CollaboratorsProps> = ({ className, maxAvatarLen = 3 }) => {
|
||||
const router = useRouter();
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import type { BillingProductLevel } from '@teable/openapi';
|
||||
import { cn } from '@teable/ui-lib/shadcn';
|
||||
import { useBillingLevelConfig } from '../../hooks/useBillingLevelConfig';
|
||||
|
||||
interface ILevelProps {
|
||||
level?: BillingProductLevel;
|
||||
}
|
||||
|
||||
export const Level = (props: ILevelProps) => {
|
||||
const { level } = props;
|
||||
const { name, tagCls } = useBillingLevelConfig(level);
|
||||
|
||||
return <div className={cn('shrink-0 rounded px-2 py-px text-[13px]', tagCls)}>{name}</div>;
|
||||
};
|
||||
@ -0,0 +1,60 @@
|
||||
import { BillingProductLevel } from '@teable/openapi';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@teable/ui-lib/shadcn';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useBillingLevelConfig } from '../../hooks/useBillingLevelConfig';
|
||||
import { Level } from './Level';
|
||||
|
||||
interface ILevelWithUpgradeProps {
|
||||
level?: BillingProductLevel;
|
||||
spaceId?: string;
|
||||
withUpgrade?: boolean;
|
||||
}
|
||||
|
||||
export const LevelWithUpgrade = (props: ILevelWithUpgradeProps) => {
|
||||
const { level, spaceId, withUpgrade } = props;
|
||||
const isEnterprise = level === BillingProductLevel.Enterprise;
|
||||
const { t } = useTranslation('common');
|
||||
const { description } = useBillingLevelConfig(level);
|
||||
const router = useRouter();
|
||||
|
||||
const onClick = () => {
|
||||
if (spaceId == null) return;
|
||||
|
||||
router.push({
|
||||
pathname: '/space/[spaceId]/setting/plan',
|
||||
query: { spaceId },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-x-1 text-sm">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Level level={level} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent hideWhenDetached={true} sideOffset={8}>
|
||||
<p>{description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{withUpgrade && !isEnterprise && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="text-violet-500 hover:text-violet-500"
|
||||
onClick={onClick}
|
||||
>
|
||||
{t('actions.upgrade')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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 (
|
||||
<Dialog open={modalOpen} onOpenChange={toggleModal}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[425px]"
|
||||
closeable={isUpgrade}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('billing.overLimits')}</DialogTitle>
|
||||
<DialogDescription className="pt-1">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button size="sm" onClick={onClick}>
|
||||
{isUpgrade ? t('actions.upgrade') : t('actions.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -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 (
|
||||
<div className={cn('flex flex-1', className)}>
|
||||
<UserAvatar user={{ name, avatar }} />
|
||||
<div className="ml-2 flex flex-1 flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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<PropsWithChildren<ICollaborators>> = (props) => {
|
||||
<div className="space-y-5">
|
||||
{collaboratorsFiltered?.map(({ userId, userName, email, role, avatar, createdTime }) => (
|
||||
<div key={userId} className="relative flex items-center gap-3 pr-6">
|
||||
<div className="flex flex-1">
|
||||
<UserAvatar user={{ name: userName, avatar }} />
|
||||
<div className="ml-2 flex flex-1 flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{userName}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Collaborator name={userName} email={email} avatar={avatar} />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('invite.dialog.collaboratorJoin', {
|
||||
joinTime: dayjs(createdTime).fromNow(),
|
||||
|
||||
@ -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) => {
|
||||
<div className="flex flex-col gap-2 border-t px-4 py-2">
|
||||
{title && <span className="text-sm text-slate-500">{title}</span>}
|
||||
<ul>
|
||||
{routes.map(({ Icon, label, route, pathTo }) => {
|
||||
{routes.map(({ Icon, label, route, pathTo, disabledTip }) => {
|
||||
return (
|
||||
<li key={route}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={'xs'}
|
||||
asChild
|
||||
className={cn(
|
||||
'w-full justify-start text-sm my-[2px]',
|
||||
route === router.pathname && 'bg-secondary'
|
||||
)}
|
||||
>
|
||||
<Link href={pathTo} className="font-normal">
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<p className="truncate">{label}</p>
|
||||
<div className="grow basis-0"></div>
|
||||
</Link>
|
||||
</Button>
|
||||
{disabledTip ? (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="my-[2px] w-full cursor-not-allowed justify-start text-sm font-normal text-gray-500 hover:bg-background hover:text-gray-500"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
asChild
|
||||
disabled
|
||||
>
|
||||
<div className="flex">
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<p className="truncate">{label}</p>
|
||||
<div className="grow basis-0"></div>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{disabledTip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
asChild
|
||||
className={cn(
|
||||
'w-full justify-start text-sm my-[2px]',
|
||||
route === router.pathname && 'bg-secondary'
|
||||
)}
|
||||
>
|
||||
<Link href={pathTo} className="font-normal">
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<p className="truncate">{label}</p>
|
||||
<div className="grow basis-0"></div>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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<React.PropsWithChildren> = (props) => {
|
||||
@ -25,6 +26,7 @@ export const UserNav: React.FC<React.PropsWithChildren> = (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<React.PropsWithChildren> = (props) => {
|
||||
{t('help.title')}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{isCloud && (
|
||||
<DropdownMenuItem className="flex gap-2" onClick={() => router.push('/setting/license')}>
|
||||
<License className="size-4 shrink-0" />
|
||||
{t('noun.license')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="flex gap-2"
|
||||
onClick={() => router.push('/setting/personal-access-token')}
|
||||
|
||||
@ -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];
|
||||
};
|
||||
7
apps/nextjs-app/src/features/app/hooks/useIsCloud.ts
Normal file
7
apps/nextjs-app/src/features/app/hooks/useIsCloud.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useEnv } from './useEnv';
|
||||
|
||||
export const useIsCloud = () => {
|
||||
const { edition } = useEnv();
|
||||
|
||||
return edition?.toUpperCase() === 'CLOUD';
|
||||
};
|
||||
18
apps/nextjs-app/src/features/app/hooks/useSetting.ts
Normal file
18
apps/nextjs-app/src/features/app/hooks/useSetting.ts
Normal file
@ -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),
|
||||
};
|
||||
};
|
||||
64
apps/nextjs-app/src/features/app/layouts/AdminLayout.tsx
Normal file
64
apps/nextjs-app/src/features/app/layouts/AdminLayout.tsx
Normal file
@ -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 (
|
||||
<AppLayout>
|
||||
<Head>
|
||||
<title>{t('noun.adminPanel')}</title>
|
||||
</Head>
|
||||
<AppProvider locale={sdkLocale} lang={i18n.language} dehydratedState={dehydratedState}>
|
||||
<SessionProvider user={user}>
|
||||
<div id="portal" className="relative flex h-screen w-full items-start">
|
||||
<Sidebar
|
||||
headerLeft={
|
||||
<SidebarHeaderLeft
|
||||
title={t('noun.adminPanel')}
|
||||
icon={<Admin className="size-5 shrink-0" />}
|
||||
onBack={onBack}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SidebarContent routes={routes} />
|
||||
</Sidebar>
|
||||
{children}
|
||||
</div>
|
||||
</SessionProvider>
|
||||
</AppProvider>
|
||||
</AppLayout>
|
||||
);
|
||||
};
|
||||
@ -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 && <div className="min-w-80 flex-1">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<UsageLimitModal />
|
||||
</TableProvider>
|
||||
</BaseProvider>
|
||||
</AnchorContext.Provider>
|
||||
|
||||
@ -30,7 +30,7 @@ export const SpaceLayout: React.FC<{
|
||||
<Sidebar headerLeft={<SidebarHeaderLeft />}>
|
||||
<Fragment>
|
||||
<div className="flex flex-1 flex-col gap-2 divide-y divide-solid overflow-hidden">
|
||||
<SpaceSideBar />
|
||||
<SpaceSideBar isAdmin={user?.isAdmin} />
|
||||
</div>
|
||||
<SideBarFooter />
|
||||
</Fragment>
|
||||
|
||||
@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<>
|
||||
<NextSeo title={t('auth:page.title')} />
|
||||
<div className="fixed h-screen w-full overflow-y-auto">
|
||||
<div className="absolute left-0 flex h-[4em] w-full items-center justify-between bg-background px-5 lg:h-20">
|
||||
@ -30,18 +36,22 @@ export const LoginPage: FC = () => {
|
||||
<TeableNew className="size-8 text-black" />
|
||||
{t('common:brand')}
|
||||
</div>
|
||||
<Tabs value={signType} onValueChange={(val) => setSignType(val as ISignForm['type'])}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="signin">{t('auth:button.signin')}</TabsTrigger>
|
||||
<TabsTrigger value="signup">{t('auth:button.signup')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{disallowSignUp ? (
|
||||
t('auth:button.signin')
|
||||
) : (
|
||||
<Tabs value={signType} onValueChange={(val) => setSignType(val as ISignForm['type'])}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="signin">{t('auth:button.signin')}</TabsTrigger>
|
||||
<TabsTrigger value="signup">{t('auth:button.signup')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative top-1/2 mx-auto w-80 -translate-y-1/2 py-[5em] lg:py-24">
|
||||
<SignForm type={signType} onSuccess={onSuccess} />
|
||||
<SocialAuth />
|
||||
</div>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,6 +7,7 @@ export interface IServerEnv {
|
||||
sentryDsn?: string;
|
||||
socialAuthProviders?: string[];
|
||||
storagePrefix?: string;
|
||||
edition?: string;
|
||||
}
|
||||
|
||||
export const EnvContext = React.createContext<IServerEnv>({});
|
||||
|
||||
@ -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<Q, D>,
|
||||
ssrApi: SsrApi
|
||||
) => Promise<GetServerSidePropsResult<P>>;
|
||||
T extends SsrApi = SsrApi,
|
||||
> = (context: GetServerSidePropsContext<Q, D>, ssrApi: T) => Promise<GetServerSidePropsResult<P>>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function withAuthSSR<P extends { [key: string]: any }>(
|
||||
handler: GetServerSideProps<P>
|
||||
export default function withAuthSSR<P extends { [key: string]: any }, T extends SsrApi = SsrApi>(
|
||||
handler: GetServerSideProps<P, ParsedUrlQuery, PreviewData, T>,
|
||||
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) {
|
||||
|
||||
39
apps/nextjs-app/src/pages/admin/setting.tsx
Normal file
39
apps/nextjs-app/src/pages/admin/setting.tsx
Normal file
@ -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<ISettingPageProps> = ({ settingServerData }) => (
|
||||
<SettingPage settingServerData={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 <AdminLayout {...pageProps}>{page}</AdminLayout>;
|
||||
};
|
||||
|
||||
export default Setting;
|
||||
@ -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<typeof getServerSideProps>) {
|
||||
return <LoginPage />;
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "关闭此选项将禁止除管理员以外的用户创建新的空间。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@ export const ErrorCodeToStatusMap: Record<HttpErrorCode, number> = {
|
||||
[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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "deactivated_time" TIMESTAMP(3),
|
||||
ADD COLUMN "is_admin" BOOLEAN;
|
||||
@ -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);
|
||||
@ -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")
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "deactivated_time" DATETIME;
|
||||
ALTER TABLE "users" ADD COLUMN "is_admin" BOOLEAN;
|
||||
@ -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);
|
||||
@ -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")
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
18
packages/icons/src/components/Admin.tsx
Normal file
18
packages/icons/src/components/Admin.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
const Admin = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.429 18.357c-.077.41-.342.676-.673.676H21.7a1.18 1.18 0 0 0-1.176 1.176c.009.154.044.306.103.448a.85.85 0 0 1-.29 1.026l-.032.022-1.364.751a.9.9 0 0 1-.374.086.91.91 0 0 1-.67-.287c-.176-.19-.642-.584-.97-.584-.32 0-.793.394-.959.574a.92.92 0 0 1-1.012.21l-1.35-.75c-.355-.247-.479-.69-.317-1.05.028-.066.103-.277.103-.444a1.18 1.18 0 0 0-1.178-1.176h-.046c-.34 0-.603-.267-.682-.678a7 7 0 0 1-.116-1.09c0-.46.104-1.031.116-1.092.08-.41.343-.678.674-.678h.055c.649 0 1.176-.525 1.177-1.173a1.3 1.3 0 0 0-.104-.45.85.85 0 0 1 .29-1.027l.033-.02 1.4-.769.026-.01a.93.93 0 0 1 1.003.21c.17.178.629.55.946.55.312 0 .768-.364.938-.542a.93.93 0 0 1 1.006-.2l1.374.76c.357.246.482.69.32 1.05a1.4 1.4 0 0 0-.102.446 1.177 1.177 0 0 0 1.176 1.174h.047c.338 0 .603.266.681.678.012.062.116.632.116 1.092 0 .483-.116 1.086-.114 1.09m-1.024-1.812a2.244 2.244 0 0 1-1.953-2.224q.007-.349.12-.68l-.972-.54q-.19.175-.401.325-.638.449-1.212.45-.582 0-1.224-.458a3.5 3.5 0 0 1-.402-.334l-1.017.56c.057.168.12.417.12.678a2.25 2.25 0 0 1-1.953 2.225 5 5 0 0 0-.067.719q.009.36.067.717a2.24 2.24 0 0 1 1.954 2.225c0 .26-.067.51-.122.68l.94.525q.192-.182.403-.34c.431-.314.85-.476 1.242-.476.396 0 .819.165 1.254.487q.214.16.405.346l.985-.542a2.2 2.2 0 0 1-.12-.679 2.24 2.24 0 0 1 1.953-2.225 5.5 5.5 0 0 0 .067-.72c0-.234-.04-.532-.067-.72m-4.47 2.788a2.08 2.08 0 0 1-2.078-2.076 2.078 2.078 0 0 1 4.154 0 2.08 2.08 0 0 1-2.077 2.076m0-3.041c-.54 0-.985.426-1.008.966a1.007 1.007 0 0 0 2.014 0 1.01 1.01 0 0 0-1.007-.966m-3.014-5.288-.339.263c-.688.612-2.002.817-2.87.884l-.112-.003q-.366.002-.731.035h-.01v.002c-3.929.37-7.014 3.663-7.014 7.662v1.176h8.61c.227.488.508.95.84 1.373H2.151a.69.69 0 0 1-.694-.686v-1.863c0-3.729 2.26-7.035 5.762-8.424l.397-.156-.337-.263a5.3 5.3 0 0 1-2.068-4.203c0-2.947 2.417-5.344 5.388-5.344s5.388 2.397 5.39 5.344a5.31 5.31 0 0 1-2.068 4.203m-3.322-8.173c-2.205 0-3.999 1.782-3.999 3.97 0 2.19 1.794 3.972 4 3.972s4-1.781 4-3.972-1.795-3.97-4-3.97"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default Admin;
|
||||
22
packages/icons/src/components/License.tsx
Normal file
22
packages/icons/src/components/License.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
const License = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M2 5v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3H5a3 3 0 0 0-3 3m17 15H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 16h6q1 0 1 1t-1 1H9q-1 0-1-1t1-1M13.94 14a.8.8 0 0 1-.4-.105l-1.5-.82a.06.06 0 0 0-.07 0l-1.5.82a.83.83 0 0 1-.91-.07.9.9 0 0 1-.345-.875l.285-1.74a.08.08 0 0 0-.025-.07L8.26 9.91A.92.92 0 0 1 8.045 9a.86.86 0 0 1 .695-.61l1.68-.255a.08.08 0 0 0 .055-.04l.75-1.595a.85.85 0 0 1 1.55 0l.75 1.585a.07.07 0 0 0 .055.04l1.68.255a.86.86 0 0 1 .695.62.92.92 0 0 1-.215.92l-1.215 1.23a.1.1 0 0 0-.025.07l.29 1.74a.9.9 0 0 1-.345.875.83.83 0 0 1-.505.165M12 11.41a.6.6 0 0 1 .25.065l.935.5a.06.06 0 0 0 .05 0 .06.06 0 0 0 0-.045l-.18-1.095a.57.57 0 0 1 .155-.5l.79-.76a.04.04 0 0 0 0-.05.04.04 0 0 0-.035-.035l-1.045-.16A.52.52 0 0 1 12.5 9l-.47-1s0-.025-.04-.025-.04 0-.04.025l-.45 1a.53.53 0 0 1-.405.31l-1.055.19a.04.04 0 0 0-.035.035.04.04 0 0 0 0 .045l.755.78a.57.57 0 0 1 .155.5l-.18 1.095a.05.05 0 0 0 0 .05.05.05 0 0 0 .05 0l.935-.5a.56.56 0 0 1 .28-.095"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default License;
|
||||
@ -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';
|
||||
|
||||
@ -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<typeof userMeVoSchema>;
|
||||
|
||||
1
packages/openapi/src/billing/index.ts
Normal file
1
packages/openapi/src/billing/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './subscription';
|
||||
@ -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<ISubscriptionSummaryVo[]>(GET_SUBSCRIPTION_SUMMARY_LIST);
|
||||
};
|
||||
@ -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<typeof subscriptionSummaryVoSchema>;
|
||||
|
||||
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<ISubscriptionSummaryVo>(
|
||||
urlBuilder(GET_SUBSCRIPTION_SUMMARY, {
|
||||
spaceId,
|
||||
})
|
||||
);
|
||||
};
|
||||
2
packages/openapi/src/billing/subscription/index.ts
Normal file
2
packages/openapi/src/billing/subscription/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './get-subscription-summary';
|
||||
export * from './get-subscription-summary-list';
|
||||
@ -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';
|
||||
|
||||
36
packages/openapi/src/setting/get.ts
Normal file
36
packages/openapi/src/setting/get.ts
Normal file
@ -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<typeof settingVoSchema>;
|
||||
|
||||
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<ISettingVo>(GET_SETTING);
|
||||
};
|
||||
2
packages/openapi/src/setting/index.ts
Normal file
2
packages/openapi/src/setting/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './get';
|
||||
export * from './update';
|
||||
38
packages/openapi/src/setting/update.ts
Normal file
38
packages/openapi/src/setting/update.ts
Normal file
@ -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<typeof updateSettingRoSchema>;
|
||||
|
||||
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);
|
||||
};
|
||||
29
packages/openapi/src/usage/get-instance-usage.ts
Normal file
29
packages/openapi/src/usage/get-instance-usage.ts
Normal file
@ -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<IUsageVo>(GET_INSTANCE_USAGE);
|
||||
};
|
||||
86
packages/openapi/src/usage/get-space-usage.ts
Normal file
86
packages/openapi/src/usage/get-space-usage.ts
Normal file
@ -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<typeof usageVoSchema>;
|
||||
|
||||
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<IUsageVo>(urlBuilder(GET_SPACE_USAGE, { spaceId }));
|
||||
};
|
||||
2
packages/openapi/src/usage/index.ts
Normal file
2
packages/openapi/src/usage/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './get-space-usage';
|
||||
export * from './get-instance-usage';
|
||||
1
packages/sdk/src/components/billing/store/index.ts
Normal file
1
packages/sdk/src/components/billing/store/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './usage-limit-modal';
|
||||
@ -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<IUsageLimitModalState>((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,
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
@ -122,6 +122,7 @@ export const FieldCreateOrSelectModal = forwardRef<
|
||||
className="p-5"
|
||||
closeable={false}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -4,9 +4,7 @@ import { cn } from '../utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
)
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user