diff --git a/src/client/components/WorkspaceSwitcher.tsx b/src/client/components/WorkspaceSwitcher.tsx index 24a8207..009994d 100644 --- a/src/client/components/WorkspaceSwitcher.tsx +++ b/src/client/components/WorkspaceSwitcher.tsx @@ -1,6 +1,12 @@ import React, { useState } from 'react'; import { cn } from '@/utils/style'; -import { setUserInfo, useUserInfo } from '@/store/user'; +import { + changeUserCurrentWorkspace, + setUserInfo, + useCurrentWorkspace, + useCurrentWorkspaceSafe, + useUserInfo, +} from '@/store/user'; import { LuPlusCircle } from 'react-icons/lu'; import { useTranslation } from '@i18next-toolkit/react'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; @@ -30,6 +36,7 @@ import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; import { trpc } from '@/api/trpc'; import { showErrorToast } from '@/utils/error'; import { first, upperCase } from 'lodash-es'; +import { Empty } from 'antd'; interface WorkspaceSwitcherProps { isCollapsed: boolean; @@ -41,6 +48,7 @@ export const WorkspaceSwitcher: React.FC = React.memo( const [open, setOpen] = React.useState(false); const [showNewWorkspaceDialog, setShowNewWorkspaceDialog] = useState(false); const [newWorkspaceName, setNewWorkspaceName] = useState(''); + const currentWorkspace = useCurrentWorkspaceSafe(); const createWorkspaceMutation = trpc.workspace.create.useMutation({ onSuccess: (userInfo) => { setUserInfo(userInfo); @@ -56,7 +64,7 @@ export const WorkspaceSwitcher: React.FC = React.memo( async (workspace: { id: string; name: string }) => { setOpen(false); - if (userInfo?.currentWorkspace.id === workspace.id) { + if (userInfo?.currentWorkspaceId === workspace.id) { return; } @@ -64,6 +72,7 @@ export const WorkspaceSwitcher: React.FC = React.memo( await switchWorkspaceMutation.mutateAsync({ workspaceId: workspace.id, }); + changeUserCurrentWorkspace(workspace.id); } catch (err) { showErrorToast(err); } @@ -88,8 +97,6 @@ export const WorkspaceSwitcher: React.FC = React.memo( return null; } - const currentWorkspace = userInfo.currentWorkspace; - return ( = React.memo( props.isCollapsed && 'h-9 w-9 items-center justify-center p-0' )} > - - - - {upperCase(first(currentWorkspace.name))} - - + {currentWorkspace ? ( + <> + + + + {upperCase(first(currentWorkspace.name))} + + - - {currentWorkspace.name} - + + {currentWorkspace.name} + + + ) : ( + {t('Select Workspace')} + )} = React.memo( {t('No workspace found.')} + {userInfo.workspaces.length === 0 && ( + + )} + {userInfo.workspaces.map(({ workspace }) => ( { - const userInfo = useUserInfo(); + const currentWorkspace = useCurrentWorkspaceSafe(); let title = 'Tianji - Insight into everything'; - if (userInfo) { - title = userInfo.currentWorkspace.name + ' | ' + title; + if (currentWorkspace) { + title = currentWorkspace.name + ' | ' + title; } return ( diff --git a/src/client/components/layout/UserConfig.tsx b/src/client/components/layout/UserConfig.tsx index f371135..20ffe46 100644 --- a/src/client/components/layout/UserConfig.tsx +++ b/src/client/components/layout/UserConfig.tsx @@ -18,6 +18,7 @@ import { useEvent } from '@/hooks/useEvent'; import { useSettingsStore } from '@/store/settings'; import { setUserInfo, + useCurrentWorkspace, useCurrentWorkspaceId, useUserInfo, useUserStore, @@ -41,6 +42,7 @@ export const UserConfig: React.FC = React.memo((props) => { const navigate = useNavigate(); const colorScheme = useSettingsStore((state) => state.colorScheme); const workspaceId = useCurrentWorkspaceId(); + const currentWorkspace = useCurrentWorkspace(); const workspaces = useUserStore((state) => { const userInfo = state.info; if (userInfo) { @@ -48,7 +50,7 @@ export const UserConfig: React.FC = React.memo((props) => { id: w.workspace.id, name: w.workspace.name, role: w.role, - current: userInfo.currentWorkspace?.id === w.workspace.id, + current: currentWorkspace.id === w.workspace.id, })); } diff --git a/src/client/components/website/WebsiteConfig.tsx b/src/client/components/website/WebsiteConfig.tsx index 4a4a2f3..31821a2 100644 --- a/src/client/components/website/WebsiteConfig.tsx +++ b/src/client/components/website/WebsiteConfig.tsx @@ -1,4 +1,4 @@ -import { Button, Form, Input, message, Popconfirm } from 'antd'; +import { Form, Input, message } from 'antd'; import React from 'react'; import { deleteWorkspaceWebsite } from '../../api/model/website'; import { useRequest } from '../../hooks/useRequest'; @@ -18,6 +18,8 @@ import { useTranslation } from '@i18next-toolkit/react'; import { useNavigate } from '@tanstack/react-router'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { AlertConfirm } from '../AlertConfirm'; +import { Button } from '../ui/button'; +import { Card, CardContent, CardHeader } from '../ui/card'; export const WebsiteConfig: React.FC<{ websiteId: string }> = React.memo( (props) => { @@ -51,6 +53,14 @@ export const WebsiteConfig: React.FC<{ websiteId: string }> = React.memo( workspaceId, websiteId, }); + trpcUtils.website.all.refetch({ workspaceId }); + + navigate({ + to: '/website/$websiteId', + params: { + websiteId, + }, + }); } ); @@ -135,21 +145,28 @@ export const WebsiteConfig: React.FC<{ websiteId: string }> = React.memo( - + - handleDeleteWebsite()} - > - - + + + {t('Danger Zone')} + + +
+ handleDeleteWebsite()} + > + + +
+
+
diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index 6e42eb0..fb2470c 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as rootRoute } from './routes/__root' import { Route as WebsiteImport } from './routes/website' import { Route as TelemetryImport } from './routes/telemetry' +import { Route as SwitchWorkspaceImport } from './routes/switchWorkspace' import { Route as SurveyImport } from './routes/survey' import { Route as SettingsImport } from './routes/settings' import { Route as ServerImport } from './routes/server' @@ -58,6 +59,11 @@ const TelemetryRoute = TelemetryImport.update({ getParentRoute: () => rootRoute, } as any) +const SwitchWorkspaceRoute = SwitchWorkspaceImport.update({ + path: '/switchWorkspace', + getParentRoute: () => rootRoute, +} as any) + const SurveyRoute = SurveyImport.update({ path: '/survey', getParentRoute: () => rootRoute, @@ -258,6 +264,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SurveyImport parentRoute: typeof rootRoute } + '/switchWorkspace': { + preLoaderRoute: typeof SwitchWorkspaceImport + parentRoute: typeof rootRoute + } '/telemetry': { preLoaderRoute: typeof TelemetryImport parentRoute: typeof rootRoute @@ -391,6 +401,7 @@ export const routeTree = rootRoute.addChildren([ SurveySurveyIdEditRoute, SurveySurveyIdIndexRoute, ]), + SwitchWorkspaceRoute, TelemetryRoute.addChildren([TelemetryTelemetryIdRoute, TelemetryAddRoute]), WebsiteRoute.addChildren([ WebsiteAddRoute, diff --git a/src/client/routes/settings/workspace.tsx b/src/client/routes/settings/workspace.tsx index 91c49b0..d352055 100644 --- a/src/client/routes/settings/workspace.tsx +++ b/src/client/routes/settings/workspace.tsx @@ -37,6 +37,8 @@ import { Button } from '@/components/ui/button'; import { useEventWithLoading } from '@/hooks/useEvent'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; +import { AlertConfirm } from '@/components/AlertConfirm'; +import { ROLES } from '@tianji/shared'; export const Route = createFileRoute('/settings/workspace')({ beforeLoad: routeAuthBeforeLoad, @@ -54,7 +56,7 @@ const columnHelper = createColumnHelper(); function PageComponent() { const { t } = useTranslation(); - const { id: workspaceId, name } = useCurrentWorkspace(); + const { id: workspaceId, name, role } = useCurrentWorkspace(); const { data: members = [], refetch: refetchMembers } = trpc.workspace.members.useQuery({ workspaceId, @@ -69,6 +71,10 @@ function PageComponent() { onSuccess: defaultSuccessHandler, onError: defaultErrorHandler, }); + const deleteWorkspaceMutation = trpc.workspace.delete.useMutation({ + onSuccess: defaultSuccessHandler, + onError: defaultErrorHandler, + }); const [handleInvite, isLoading] = useEventWithLoading( async (values: InviteFormValues) => { @@ -173,6 +179,41 @@ function PageComponent() { + + {role === ROLES.owner && ( + + + {t('Danger Zone')} + + +
+ { + await deleteWorkspaceMutation.mutateAsync({ + workspaceId, + }); + + setTimeout(() => { + window.location.reload(); + }, 1000); + }} + > + + +
+
+
+ )} diff --git a/src/client/routes/switchWorkspace.tsx b/src/client/routes/switchWorkspace.tsx new file mode 100644 index 0000000..fab5d05 --- /dev/null +++ b/src/client/routes/switchWorkspace.tsx @@ -0,0 +1,79 @@ +import { + createFileRoute, + redirect, + useNavigate, + useSearch, +} from '@tanstack/react-router'; +import { useTranslation } from '@i18next-toolkit/react'; +import { z } from 'zod'; +import { useCurrentWorkspaceSafe, type UserLoginInfo } from '../store/user'; +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from '@/components/ui/card'; +import { WorkspaceSwitcher } from '@/components/WorkspaceSwitcher'; +import { Button } from '@/components/ui/button'; +import { useEvent } from '@/hooks/useEvent'; + +export const Route = createFileRoute('/switchWorkspace')({ + validateSearch: z.object({ + // redirect: z.string().catch('/'), + redirect: z.string().optional(), + }), + beforeLoad: ({ context }) => { + const userInfo: UserLoginInfo | undefined = (context as any).userInfo; + + if ( + userInfo && + userInfo.currentWorkspaceId && + userInfo.workspaces.some( + (w) => w.workspace.id === userInfo.currentWorkspaceId + ) + ) { + throw redirect({ + to: '/', + }); + } + }, + component: PageComponent, +}); + +function PageComponent() { + const { t } = useTranslation(); + const currentWorkspace = useCurrentWorkspaceSafe(); + const search = Route.useSearch(); + const navigate = useNavigate(); + + const handleEnter = useEvent(() => { + navigate({ + to: search.redirect ?? '/', + }); + }); + + return ( +
+ + +
+ +
+
+ +
{t('Select Workspace')}
+ + +
+ + {currentWorkspace && ( + + + + )} +
+
+ ); +} diff --git a/src/client/store/user.ts b/src/client/store/user.ts index e1bd725..eaf577e 100644 --- a/src/client/store/user.ts +++ b/src/client/store/user.ts @@ -1,32 +1,29 @@ -import { create } from 'zustand'; +import { shallow } from 'zustand/shallow'; +import { createWithEqualityFn } from 'zustand/traditional'; import { createSocketIOClient } from '../api/socketio'; import { AppRouterOutput } from '../api/trpc'; -type UserLoginInfo = NonNullable; +export type UserLoginInfo = NonNullable; interface UserState { info: UserLoginInfo | null; } -export const useUserStore = create(() => ({ - info: null, -})); +export const useUserStore = createWithEqualityFn( + () => ({ + info: null, + }), + shallow +); export function setUserInfo(info: UserLoginInfo) { - if (!info.currentWorkspace && info.workspaces[0]) { - // Make sure currentWorkspace existed - info.currentWorkspace = { - ...info.workspaces[0].workspace, - }; - } - useUserStore.setState({ info, }); // create socketio after login - if (info.currentWorkspace) { - createSocketIOClient(info.currentWorkspace.id); + if (info.currentWorkspaceId) { + createSocketIOClient(info.currentWorkspaceId); } } @@ -38,10 +35,56 @@ export function useIsLogined() { return !!useUserInfo(); } +export function changeUserCurrentWorkspace(currentWorkspaceId: string) { + const currentUserInfo = useUserStore.getState().info; + if (!currentUserInfo) { + return; + } + + useUserStore.setState({ + info: { + ...currentUserInfo, + currentWorkspaceId, + }, + }); + createSocketIOClient(currentWorkspaceId); +} + +export function useCurrentWorkspaceSafe() { + const currentWorkspace = useUserStore((state) => { + if (!state.info) { + return null; + } + + const currentWorkspaceId = state.info.currentWorkspaceId; + if (!currentWorkspaceId) { + return null; + } + + const currentWorkspace = state.info?.workspaces.find( + (w) => w.workspace.id === currentWorkspaceId + ); + + if (!currentWorkspace) { + return null; + } + + return { + id: currentWorkspace.workspace.id, + name: currentWorkspace.workspace.name, + role: currentWorkspace.role, + }; + }); + + return currentWorkspace; +} + +/** + * Direct return current workspace info + * NOTICE: its will throw error if no workspace id + */ export function useCurrentWorkspace() { - const currentWorkspace = useUserStore( - (state) => state.info?.currentWorkspace - ); + const currentWorkspace = useCurrentWorkspaceSafe(); if (!currentWorkspace) { throw new Error('No Workspace Id'); @@ -50,9 +93,13 @@ export function useCurrentWorkspace() { return currentWorkspace; } +/** + * Direct return current workspace id + * NOTICE: its will throw error if no workspace id + */ export function useCurrentWorkspaceId() { const currentWorkspaceId = useUserStore( - (state) => state.info?.currentWorkspace?.id + (state) => state.info?.currentWorkspaceId ); if (!currentWorkspaceId) { diff --git a/src/client/utils/route.ts b/src/client/utils/route.ts index 16631e6..663f857 100644 --- a/src/client/utils/route.ts +++ b/src/client/utils/route.ts @@ -1,10 +1,12 @@ +import { UserLoginInfo } from '@/store/user'; import { FileBaseRouteOptions, redirect } from '@tanstack/react-router'; export const routeAuthBeforeLoad: FileBaseRouteOptions['beforeLoad'] = ({ context, location, }) => { - if (!(context as any).userInfo) { + const userInfo: UserLoginInfo | undefined = (context as any).userInfo; + if (!userInfo) { throw redirect({ to: '/login', search: { @@ -12,4 +14,18 @@ export const routeAuthBeforeLoad: FileBaseRouteOptions['beforeLoad'] = ({ }, }); } + + if ( + !userInfo.currentWorkspaceId || + userInfo.workspaces.every( + (w) => w.workspace.id !== userInfo.currentWorkspaceId + ) + ) { + throw redirect({ + to: '/switchWorkspace', + search: { + redirect: location.href, + }, + }); + } }; diff --git a/src/server/model/_schema/index.ts b/src/server/model/_schema/index.ts index 11bd7c0..c170a06 100644 --- a/src/server/model/_schema/index.ts +++ b/src/server/model/_schema/index.ts @@ -31,7 +31,7 @@ export const userInfoSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), deletedAt: z.date().nullable(), - currentWorkspace: workspaceSchema, + currentWorkspaceId: z.string().nullable(), workspaces: z.array( z.object({ role: z.string(), diff --git a/src/server/model/user.ts b/src/server/model/user.ts index a2b0df8..b1eae8b 100644 --- a/src/server/model/user.ts +++ b/src/server/model/user.ts @@ -33,12 +33,7 @@ export const createUserSelect = { createdAt: true, updatedAt: true, deletedAt: true, - currentWorkspace: { - select: { - id: true, - name: true, - }, - }, + currentWorkspaceId: true, workspaces: { select: { role: true, diff --git a/src/server/prisma/migrations/20240914125927_remove_user_current_workspace_id_fk/migration.sql b/src/server/prisma/migrations/20240914125927_remove_user_current_workspace_id_fk/migration.sql new file mode 100644 index 0000000..e94693e --- /dev/null +++ b/src/server/prisma/migrations/20240914125927_remove_user_current_workspace_id_fk/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "User" DROP CONSTRAINT "User_currentWorkspaceId_fkey"; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "currentWorkspaceId" DROP NOT NULL; diff --git a/src/server/prisma/schema.prisma b/src/server/prisma/schema.prisma index 4fefa35..1616f04 100644 --- a/src/server/prisma/schema.prisma +++ b/src/server/prisma/schema.prisma @@ -29,9 +29,7 @@ model User { createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) deletedAt DateTime? @db.Timestamptz(6) - currentWorkspaceId String @db.VarChar(30) - - currentWorkspace Workspace @relation(fields: [currentWorkspaceId], references: [id]) + currentWorkspaceId String? @db.VarChar(30) accounts Account[] sessions Session[] @@ -97,7 +95,6 @@ model Workspace { telemetryList Telemetry[] // for user currentWorkspace - selectedUsers User[] // user list who select this workspace, not use in most of case workspaceDailyUsage WorkspaceDailyUsage[] workspaceAuditLog WorkspaceAuditLog[] surveys Survey[] diff --git a/src/server/prisma/zod/user.ts b/src/server/prisma/zod/user.ts index ba970df..179f5c5 100644 --- a/src/server/prisma/zod/user.ts +++ b/src/server/prisma/zod/user.ts @@ -1,6 +1,6 @@ import * as z from "zod" import * as imports from "./schemas/index.js" -import { CompleteWorkspace, RelatedWorkspaceModelSchema, CompleteAccount, RelatedAccountModelSchema, CompleteSession, RelatedSessionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema } from "./index.js" +import { CompleteAccount, RelatedAccountModelSchema, CompleteSession, RelatedSessionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema } from "./index.js" export const UserModelSchema = z.object({ id: z.string(), @@ -14,11 +14,10 @@ export const UserModelSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), deletedAt: z.date().nullish(), - currentWorkspaceId: z.string(), + currentWorkspaceId: z.string().nullish(), }) export interface CompleteUser extends z.infer { - currentWorkspace: CompleteWorkspace accounts: CompleteAccount[] sessions: CompleteSession[] workspaces: CompleteWorkspacesOnUsers[] @@ -30,7 +29,6 @@ export interface CompleteUser extends z.infer { * NOTE: Lazy required in case of potential circular dependencies within schema */ export const RelatedUserModelSchema: z.ZodSchema = z.lazy(() => UserModelSchema.extend({ - currentWorkspace: RelatedWorkspaceModelSchema, accounts: RelatedAccountModelSchema.array(), sessions: RelatedSessionModelSchema.array(), workspaces: RelatedWorkspacesOnUsersModelSchema.array(), diff --git a/src/server/prisma/zod/workspace.ts b/src/server/prisma/zod/workspace.ts index f5decf6..6b3ae0e 100644 --- a/src/server/prisma/zod/workspace.ts +++ b/src/server/prisma/zod/workspace.ts @@ -1,6 +1,6 @@ import * as z from "zod" import * as imports from "./schemas/index.js" -import { CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteUser, RelatedUserModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js" +import { CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js" // Helper schema for JSON fields type Literal = boolean | number | string @@ -31,7 +31,6 @@ export interface CompleteWorkspace extends z.infer monitors: CompleteMonitor[] monitorStatusPages: CompleteMonitorStatusPage[] telemetryList: CompleteTelemetry[] - selectedUsers: CompleteUser[] workspaceDailyUsage: CompleteWorkspaceDailyUsage[] workspaceAuditLog: CompleteWorkspaceAuditLog[] surveys: CompleteSurvey[] @@ -50,7 +49,6 @@ export const RelatedWorkspaceModelSchema: z.ZodSchema = z.laz monitors: RelatedMonitorModelSchema.array(), monitorStatusPages: RelatedMonitorStatusPageModelSchema.array(), telemetryList: RelatedTelemetryModelSchema.array(), - selectedUsers: RelatedUserModelSchema.array(), workspaceDailyUsage: RelatedWorkspaceDailyUsageModelSchema.array(), workspaceAuditLog: RelatedWorkspaceAuditLogModelSchema.array(), surveys: RelatedSurveyModelSchema.array(), diff --git a/src/server/trpc/routers/user.ts b/src/server/trpc/routers/user.ts index f46d206..4c7489c 100644 --- a/src/server/trpc/routers/user.ts +++ b/src/server/trpc/routers/user.ts @@ -138,7 +138,7 @@ export const userRouter = router({ info: protectProedure .input(z.void()) .output(userInfoSchema.nullable()) - .query(async ({ input, ctx }) => { + .query(async ({ ctx }) => { return getUserInfo(ctx.user.id); }), }); diff --git a/src/server/trpc/routers/workspace.ts b/src/server/trpc/routers/workspace.ts index a1587b8..a105121 100644 --- a/src/server/trpc/routers/workspace.ts +++ b/src/server/trpc/routers/workspace.ts @@ -23,6 +23,7 @@ import { leaveWorkspace, } from '../../model/user.js'; import { WorkspacesOnUsersModelSchema } from '../../prisma/zod/workspacesonusers.js'; +import { monitorManager } from '../../model/monitor/index.js'; export const workspaceRouter = router({ create: protectProedure @@ -104,7 +105,7 @@ export const workspaceRouter = router({ id: workspaceId, users: { some: { - userId, + userId, // make sure is member of this workspace }, }, }, @@ -143,6 +144,19 @@ export const workspaceRouter = router({ const { workspaceId } = input; const userId = ctx.user.id; + const monitors = await prisma.monitor.findMany({ + where: { + workspaceId, + }, + select: { + id: true, + }, + }); + + await Promise.all( + monitors.map((m) => monitorManager.delete(workspaceId, m.id)) + ); + await prisma.workspace.delete({ where: { id: workspaceId,