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:
SkyHuang 2024-06-28 16:05:16 +08:00 committed by GitHub
parent 13b4463428
commit 2bf8027dff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 1429 additions and 118 deletions

View File

@ -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],
};

View File

@ -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!,

View File

@ -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:

View File

@ -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',
}

View File

@ -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';

View File

@ -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) {}
}

View File

@ -0,0 +1,7 @@
import { Events } from '../event.enum';
export class UserSignUpEvent {
public readonly name = Events.USER_SIGNUP;
constructor(public readonly userId: string) {}
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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')

View File

@ -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) {

View File

@ -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);

View File

@ -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];

View 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;
}
}

View File

@ -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);
}
}

View 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 {}

View 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,
});
}
}

View File

@ -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 },

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -0,0 +1 @@
export * from './setting';

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from './CopyInstance';

View File

@ -0,0 +1,2 @@
export * from './SettingPage';
export * from './components';

View File

@ -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>
);
})}

View File

@ -50,6 +50,7 @@ export const ShareViewPage = (props: IShareViewPageProps) => {
email: '',
notifyMeta: {},
hasPassword: false,
isAdmin: false,
}}
>
<AnchorContext.Provider

View File

@ -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"

View File

@ -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 ? (

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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();

View File

@ -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>;
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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(),

View File

@ -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>
);
})}

View File

@ -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')}

View File

@ -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];
};

View File

@ -0,0 +1,7 @@
import { useEnv } from './useEnv';
export const useIsCloud = () => {
const { edition } = useEnv();
return edition?.toUpperCase() === 'CLOUD';
};

View 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),
};
};

View 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>
);
};

View File

@ -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>

View File

@ -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>

View File

@ -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>
</>
);
};

View File

@ -7,6 +7,7 @@ export interface IServerEnv {
sentryDsn?: string;
socialAuthProviders?: string[];
storagePrefix?: string;
edition?: string;
}
export const EnvContext = React.createContext<IServerEnv>({});

View File

@ -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) {

View 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;

View File

@ -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) => {

View File

@ -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."
}
}
}

View File

@ -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": "关闭此选项将禁止除管理员以外的用户创建新的空间。"
}
}
}

View File

@ -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,

View File

@ -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.

View File

@ -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);
}

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "deactivated_time" TIMESTAMP(3),
ADD COLUMN "is_admin" BOOLEAN;

View File

@ -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);

View File

@ -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")
}

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "deactivated_time" DATETIME;
ALTER TABLE "users" ADD COLUMN "is_admin" BOOLEAN;

View File

@ -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);

View File

@ -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")
}

View File

@ -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")
}

View 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;

View 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;

View File

@ -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';

View File

@ -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>;

View File

@ -0,0 +1 @@
export * from './subscription';

View File

@ -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);
};

View File

@ -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,
})
);
};

View File

@ -0,0 +1,2 @@
export * from './get-subscription-summary';
export * from './get-subscription-summary-list';

View File

@ -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';

View 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);
};

View File

@ -0,0 +1,2 @@
export * from './get';
export * from './update';

View 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);
};

View 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);
};

View 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 }));
};

View File

@ -0,0 +1,2 @@
export * from './get-space-usage';
export * from './get-instance-usage';

View File

@ -0,0 +1 @@
export * from './usage-limit-modal';

View File

@ -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,
};
});
},
}));

View File

@ -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>

View File

@ -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',

View File

@ -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';