diff --git a/packages/global/support/user/account/cancellation/type.ts b/packages/global/support/user/account/cancellation/type.ts index 76543c45c34a..4a3b3027165a 100644 --- a/packages/global/support/user/account/cancellation/type.ts +++ b/packages/global/support/user/account/cancellation/type.ts @@ -14,6 +14,12 @@ export const TeamAccountCancellationStatusSchema = AccountCancellationStatusSche ]); export type TeamAccountCancellationStatus = z.infer; +export const AccountCancellationSummarySchema = z.object({ + status: TeamAccountCancellationStatusSchema, + scheduledCancelAt: z.union([z.date(), z.iso.datetime({ offset: true })]).optional() +}); +export type AccountCancellationSummary = z.infer; + export const AccountCancellationAllowedMethodSchema = z.enum(accountCancellationAllowedMethods); export type AccountCancellationAllowedMethod = z.infer< typeof AccountCancellationAllowedMethodSchema diff --git a/packages/global/support/user/type.ts b/packages/global/support/user/type.ts index cb42c9798f2a..4434f94f5b68 100644 --- a/packages/global/support/user/type.ts +++ b/packages/global/support/user/type.ts @@ -1,3 +1,4 @@ +import { AccountCancellationSummarySchema } from './account/cancellation/type'; import z from 'zod'; import { ObjectIdSchema } from '../../common/type/mongo'; import { LanguageSchema, type LangEnum } from '../../common/i18n/type'; @@ -38,6 +39,7 @@ export const UserSchema = z.object({ avatar: z.string().nullish(), timezone: z.string(), language: LanguageSchema.optional(), + accountCancellation: AccountCancellationSummarySchema.optional(), team: TeamTmbItemSchema, permission: z.instanceof(TeamPermission), contact: z.string().nullish(), diff --git a/packages/service/support/user/controller.ts b/packages/service/support/user/controller.ts index 59faaedcfedd..6ec33d72a97c 100644 --- a/packages/service/support/user/controller.ts +++ b/packages/service/support/user/controller.ts @@ -5,6 +5,8 @@ import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { TeamPermission } from '@fastgpt/global/support/permission/user/controller'; import type { ClientSession } from '../../common/mongo'; import { getUserFallbackTeam } from './team/fallback'; +import { getActiveAccountCancellationByUserId } from './account/cancellation/read'; +import { formatTeamAccountCancellationSummary } from './account/cancellation/formatter'; export async function authUserExist({ userId, username }: { userId?: string; username?: string }) { if (userId) { @@ -54,6 +56,7 @@ export async function getUserDetail({ return Promise.reject(ERROR_ENUM.unAuthorization); } + const accountCancellation = await getActiveAccountCancellationByUserId(String(user._id)); const permission = isRoot ? new TeamPermission({ isOwner: true }) : tmb.permission; const team = { ...tmb, @@ -69,6 +72,9 @@ export async function getUserDetail({ permission, contact: user.contact, language: user.language, - tags: user.tags + tags: user.tags, + ...(accountCancellation + ? { accountCancellation: formatTeamAccountCancellationSummary(accountCancellation) } + : {}) }; } diff --git a/pro b/pro index e9b02bf9be4a..1ed762aa9316 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit e9b02bf9be4a61306686608239d03184906f7346 +Subproject commit 1ed762aa93161d90e92448e5a73132626798fefe diff --git a/projects/app/src/components/Layout/index.tsx b/projects/app/src/components/Layout/index.tsx index 34c121d9deea..e2d04b1ff267 100644 --- a/projects/app/src/components/Layout/index.tsx +++ b/projects/app/src/components/Layout/index.tsx @@ -230,7 +230,7 @@ const Layout = ({ children }: { children: JSX.Element }) => { {!!userInfo && importantInforms.length > 0 && ( )} - + {router.pathname !== '/account/cancel' && } )} diff --git a/projects/app/src/components/support/activity/ActivityAdModal.tsx b/projects/app/src/components/support/activity/ActivityAdModal.tsx index a476081f8f26..64f18905b31f 100644 --- a/projects/app/src/components/support/activity/ActivityAdModal.tsx +++ b/projects/app/src/components/support/activity/ActivityAdModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { useDisclosure } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import { getActivityAd } from '@/web/common/system/api'; @@ -17,6 +17,7 @@ import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useLocalStorageState } from 'ahooks'; import { useRouter } from 'next/router'; import { useUserStore } from '@/web/support/user/useUserStore'; +import { accountCancellationActiveStatuses } from '@fastgpt/global/support/user/account/cancellation/constants'; const CLOSED_AD_KEY = 'logout-activity-ad'; const CLOSED_AD_DURATION = 24 * 60 * 60 * 1000; // 24 hours @@ -27,6 +28,15 @@ const ActivityAdModal = () => { const { feConfigs } = useSystemStore(); const router = useRouter(); const { userInfo } = useUserStore(); + const isCancellationRestricted = + !!userInfo && + (accountCancellationActiveStatuses.includes( + userInfo.accountCancellation?.status as (typeof accountCancellationActiveStatuses)[number] + ) || + accountCancellationActiveStatuses.includes( + userInfo.team?.accountCancellation + ?.status as (typeof accountCancellationActiveStatuses)[number] + )); // Check if ad was recently closed const [closedData, setClosedData] = useLocalStorageState(CLOSED_AD_KEY, { @@ -35,7 +45,7 @@ const ActivityAdModal = () => { const { data } = useRequest( async () => { - if (!feConfigs?.isPlus || !userInfo) return; + if (!feConfigs?.isPlus || !userInfo || isCancellationRestricted) return; return getActivityAd(); }, { @@ -64,7 +74,7 @@ const ActivityAdModal = () => { onOpen(); } }, - refreshDeps: [userInfo] + refreshDeps: [isCancellationRestricted, userInfo] } ); @@ -73,7 +83,7 @@ const ActivityAdModal = () => { setClosedData(JSON.stringify({ timestamp: Date.now(), adId: data.id })); } onClose(); - }, [data?.id, onClose, setClosedData]); + }, [data, onClose, setClosedData]); const handleJoin = useCallback(() => { if (data?.activityAdLink) { @@ -84,7 +94,7 @@ const ActivityAdModal = () => { window.open(data.activityAdLink, '_blank'); } } - }, [data?.activityAdLink, handleClose, router]); + }, [data, handleClose, router]); if (!data?.activityAdImage || !userInfo) { return null; diff --git a/projects/app/src/web/support/user/loginRedirect/index.ts b/projects/app/src/web/support/user/loginRedirect/index.ts index 6e94af2dd0ed..a57d18f91e99 100644 --- a/projects/app/src/web/support/user/loginRedirect/index.ts +++ b/projects/app/src/web/support/user/loginRedirect/index.ts @@ -52,7 +52,8 @@ export const resolveLoginRedirectAfterLogin = async ({ lastTmbId?: string; restoreWorkflowLocalDraft: RestoreWorkflowLocalDraft; }) => { - const cancellationStatus = user.team?.accountCancellation?.status; + const cancellationStatus = + user.accountCancellation?.status ?? user.team?.accountCancellation?.status; if ( accountCancellationActiveStatuses.includes( cancellationStatus as (typeof accountCancellationActiveStatuses)[number] diff --git a/projects/app/test/web/support/user/loginRedirect.test.ts b/projects/app/test/web/support/user/loginRedirect.test.ts index 867423c22ef8..202c2b81b53f 100644 --- a/projects/app/test/web/support/user/loginRedirect.test.ts +++ b/projects/app/test/web/support/user/loginRedirect.test.ts @@ -133,6 +133,54 @@ describe('login redirect helpers', () => { expect(restoreWorkflowLocalDraft).not.toHaveBeenCalled(); }); + it('redirects a user with personal cancellation to the account cancellation page', async () => { + const route = await resolveLoginRedirectAfterLogin({ + user: { + ...user, + accountCancellation: { + status: 'pending' + } + } as UserType, + fallbackRoute: '/dashboard/agent', + restoreWorkflowLocalDraft: vi.fn() + }); + + expect(route).toBe('/account/cancel'); + }); + + it('redirects a finalizing user with personal cancellation', async () => { + const route = await resolveLoginRedirectAfterLogin({ + user: { + ...user, + accountCancellation: { + status: 'finalizing' + } + } as UserType, + fallbackRoute: '/dashboard/agent', + restoreWorkflowLocalDraft: vi.fn() + }); + + expect(route).toBe('/account/cancel'); + }); + + it('redirects a cancelling member without a team cancellation summary', async () => { + const route = await resolveLoginRedirectAfterLogin({ + user: { + ...user, + accountCancellation: { + status: 'pending' + }, + team: { + ...user.team + } + } as UserType, + fallbackRoute: '/dashboard/agent', + restoreWorkflowLocalDraft: vi.fn() + }); + + expect(route).toBe('/account/cancel'); + }); + afterEach(() => { vi.useRealTimers(); });