diff --git a/packages/global/openapi/support/user/team/invitationLink/api.ts b/packages/global/openapi/support/user/team/invitationLink/api.ts index c339fc6572b3..e3fc8b71d37d 100644 --- a/packages/global/openapi/support/user/team/invitationLink/api.ts +++ b/packages/global/openapi/support/user/team/invitationLink/api.ts @@ -1,5 +1,6 @@ import z from 'zod'; import { ObjectIdSchema } from '../../../../../common/type/mongo'; +import { TeamMemberNameSchema } from '../../../../../support/user/team/memberName'; const InvitationLinkIdSchema = z.string().min(1).meta({ example: 'V1StGXR8_Z5jdHi6B-myT', @@ -44,23 +45,35 @@ const InvitationLinkBaseShape = { example: '2026-08-26T00:00:00.000Z', description: '邀请链接过期时间' }), - description: z.string().meta({ description: '邀请链接描述' }) + description: z.string().meta({ description: '邀请链接描述' }), + creatorUsername: z.string().optional().meta({ description: '邀请链接创建者用户名' }) }; /* ============================================================================ - * API: 接受团队邀请链接 - * Route: POST /api/proApi/support/user/team/invitationLink/accept + * API: 接受团队邀请链接并设置成员名 + * Route: POST /api/proApi/support/user/team/invitationLink/acceptWithMemberName * Method: POST - * Description: 当前用户接受邀请链接并加入对应团队。 + * Description: 当前用户设置目标团队成员名并接受邀请链接。 * Tags: ['邀请链接管理', '团队管理', 'Write'] * ============================================================================ */ -export const AcceptInvitationLinkBodySchema = z +export const AcceptInvitationWithMemberNameBodySchema = z .object({ - linkId: InvitationLinkIdSchema + linkId: InvitationLinkIdSchema, + memberName: TeamMemberNameSchema.meta({ description: '目标团队成员名', example: '张三' }) }) - .meta({ example: { linkId: 'V1StGXR8_Z5jdHi6B-myT' } }); -export type AcceptInvitationLinkBodyType = z.infer; + .meta({ example: { linkId: 'V1StGXR8_Z5jdHi6B-myT', memberName: '张三' } }); +export type AcceptInvitationWithMemberNameBodyType = z.infer< + typeof AcceptInvitationWithMemberNameBodySchema +>; + +export const AcceptInvitationWithMemberNameResponseSchema = z.object({ + teamId: ObjectIdSchema.meta({ description: '目标团队 ID' }), + tmbId: ObjectIdSchema.meta({ description: '目标团队成员 ID' }) +}); +export type AcceptInvitationWithMemberNameResponseType = z.infer< + typeof AcceptInvitationWithMemberNameResponseSchema +>; /* ============================================================================ * API: 创建团队邀请链接 @@ -115,7 +128,7 @@ export type ForbidInvitationLinkBodyType = z.infer; /* ============================================================================ diff --git a/packages/global/openapi/support/user/team/invitationLink/index.ts b/packages/global/openapi/support/user/team/invitationLink/index.ts index e362527b0b79..fbeee9f3663e 100644 --- a/packages/global/openapi/support/user/team/invitationLink/index.ts +++ b/packages/global/openapi/support/user/team/invitationLink/index.ts @@ -1,7 +1,8 @@ import type { OpenAPIPath } from '../../../../type'; import { DevApiTagsMap } from '../../../../tag'; import { - AcceptInvitationLinkBodySchema, + AcceptInvitationWithMemberNameBodySchema, + AcceptInvitationWithMemberNameResponseSchema, CreateInvitationLinkBodySchema, CreateInvitationLinkResponseSchema, ForbidInvitationLinkBodySchema, @@ -13,21 +14,26 @@ import { const TeamInvitationLinkTags = [DevApiTagsMap.teamInvitationLink]; export const TeamInvitationLinkPath: OpenAPIPath = { - '/proApi/support/user/team/invitationLink/accept': { + '/proApi/support/user/team/invitationLink/acceptWithMemberName': { post: { - summary: '接受团队邀请链接', - description: '当前用户接受邀请链接并加入对应团队', + summary: '接受团队邀请链接并设置成员名', + description: '当前用户设置目标团队成员名并接受邀请链接', tags: [...TeamInvitationLinkTags], requestBody: { content: { 'application/json': { - schema: AcceptInvitationLinkBodySchema + schema: AcceptInvitationWithMemberNameBodySchema } } }, responses: { 200: { - description: '接受邀请成功' + description: '接受邀请成功并返回目标团队成员信息', + content: { + 'application/json': { + schema: AcceptInvitationWithMemberNameResponseSchema + } + } } } } diff --git a/packages/global/openapi/support/user/team/member/api.ts b/packages/global/openapi/support/user/team/member/api.ts index 6f05aab27ddc..dbf357136fe9 100644 --- a/packages/global/openapi/support/user/team/member/api.ts +++ b/packages/global/openapi/support/user/team/member/api.ts @@ -4,6 +4,7 @@ import { ObjectIdSchema } from '../../../../../common/type/mongo'; import { GroupMemberRole } from '../../../../../support/permission/memberGroup/constant'; import { PermissionSchema } from '../../../../../support/permission/controller'; import { TeamMemberStatusEnum } from '../../../../../support/user/team/constant'; +import { TeamMemberNameSchema } from '../../../../../support/user/team/memberName'; import { PaginationResponseSchema, PaginationSchema } from '../../../../api'; const TeamMemberIdSchema = ObjectIdSchema.meta({ @@ -173,9 +174,9 @@ export type UpdateTeamMemberInviteBodyType = z.infer; @@ -190,9 +191,9 @@ export type UpdateTeamMemberNameBodyType = z.infer value !== UNSET_TEAM_MEMBER_NAME, '成员名非法!'); + +/** 将交互式成员名规范化并在非法输入时抛出参数错误。 */ +export const normalizeTeamMemberName = (memberName: unknown) => + TeamMemberNameSchema.parse(memberName); + +/** 仅返回有效成员名,供注册和同步等非交互式入口选择明确的缺省值。 */ +export const getValidTeamMemberName = (memberName: unknown) => { + const result = TeamMemberNameSchema.safeParse(memberName); + return result.success ? result.data : undefined; +}; diff --git a/packages/global/test/openapi/support/user/api.test.ts b/packages/global/test/openapi/support/user/api.test.ts index 0aafe4ac3467..aa1623f12d55 100644 --- a/packages/global/test/openapi/support/user/api.test.ts +++ b/packages/global/test/openapi/support/user/api.test.ts @@ -36,7 +36,10 @@ import { UpdateTeamCollaboratorBodySchema, UpdateTeamCollaboratorOneBodySchema } from '@fastgpt/global/openapi/support/user/team/collaborator/api'; -import { GetInvitationLinkInfoResponseSchema } from '@fastgpt/global/openapi/support/user/team/invitationLink/api'; +import { + AcceptInvitationWithMemberNameBodySchema, + GetInvitationLinkInfoResponseSchema +} from '@fastgpt/global/openapi/support/user/team/invitationLink/api'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; import { InformLevelEnum } from '@fastgpt/global/support/user/inform/constants'; import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant'; @@ -79,8 +82,12 @@ describe('support user OpenAPI contracts', () => { openAPIDocument.paths?.['/proApi/support/user/team/collaborator/updateOne']?.put?.tags ).toEqual([DevApiTagsMap.teamPermission]); expect( - openAPIDocument.paths?.['/proApi/support/user/team/invitationLink/accept']?.post?.tags + openAPIDocument.paths?.['/proApi/support/user/team/invitationLink/acceptWithMemberName']?.post + ?.tags ).toEqual([DevApiTagsMap.teamInvitationLink]); + expect( + openAPIDocument.paths?.['/proApi/support/user/team/invitationLink/accept'] + ).toBeUndefined(); expect( openAPIDocument.paths?.['/proApi/support/user/team/invitationLink/create']?.post?.tags ).toEqual([DevApiTagsMap.teamInvitationLink]); @@ -334,8 +341,34 @@ describe('support user OpenAPI contracts', () => { expect(UserSyncResponseSchema.parse(undefined)).toBeUndefined(); }); - it('allows empty invitation details for existing team members', () => { - expect(GetInvitationLinkInfoResponseSchema.parse(undefined)).toBeUndefined(); + it('parses invitation details and requires a valid member name when accepting', () => { + expect( + GetInvitationLinkInfoResponseSchema.parse({ + _id: objectId, + linkId: 'invite-link-id', + teamId: secondObjectId, + expires: '2026-08-26T00:00:00.000Z', + description: '邀请加入团队', + creatorUsername: 'root', + members: [], + teamAvatar: null, + teamName: 'FastGPT 团队', + alreadyJoined: true + }) + ).toMatchObject({ alreadyJoined: true, creatorUsername: 'root' }); + + expect( + AcceptInvitationWithMemberNameBodySchema.parse({ + linkId: 'invite-link-id', + memberName: ' 张三 ' + }) + ).toEqual({ linkId: 'invite-link-id', memberName: '张三' }); + expect( + AcceptInvitationWithMemberNameBodySchema.safeParse({ + linkId: 'invite-link-id', + memberName: 'a'.repeat(21) + }).success + ).toBe(false); }); it('parses team management list and write contracts', () => { diff --git a/packages/global/test/support/user/team/memberName.test.ts b/packages/global/test/support/user/team/memberName.test.ts new file mode 100644 index 000000000000..0d7049c57b52 --- /dev/null +++ b/packages/global/test/support/user/team/memberName.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { + getValidTeamMemberName, + normalizeTeamMemberName, + TeamMemberNameSchema +} from '@fastgpt/global/support/user/team/memberName'; +import { UNSET_TEAM_MEMBER_NAME } from '@fastgpt/global/support/user/team/constant'; + +describe('TeamMemberNameSchema', () => { + it('trims and accepts names up to 20 characters', () => { + expect(TeamMemberNameSchema.parse(' Alice ')).toBe('Alice'); + expect(TeamMemberNameSchema.parse('a'.repeat(20))).toBe('a'.repeat(20)); + }); + + it('rejects empty, overlong, and reserved names', () => { + expect(TeamMemberNameSchema.safeParse(' ').success).toBe(false); + expect(TeamMemberNameSchema.safeParse('a'.repeat(21)).success).toBe(false); + expect(TeamMemberNameSchema.safeParse(UNSET_TEAM_MEMBER_NAME).success).toBe(false); + }); + + it('separates strict normalization from fallback validation', () => { + expect(normalizeTeamMemberName(' Bob ')).toBe('Bob'); + expect(getValidTeamMemberName(UNSET_TEAM_MEMBER_NAME)).toBeUndefined(); + expect(getValidTeamMemberName('')).toBeUndefined(); + }); +}); diff --git a/packages/service/support/user/lock.ts b/packages/service/support/user/lock.ts new file mode 100644 index 000000000000..50ed7f1c8d71 --- /dev/null +++ b/packages/service/support/user/lock.ts @@ -0,0 +1,14 @@ +import { LeaseCache } from '@fastgpt/dal/redis/caches'; +import { getLogger, LogCategories } from '../../common/logger'; + +const lockTtlMs = 10 * 60 * 1000; +const leaseCache = new LeaseCache({ logger: getLogger(LogCategories.INFRA.REDIS) }); + +/** 在团队维度串行化会同时修改团队成员关系或团队资源的操作。 */ +export const withTeamLock = async (teamId: string, fn: () => Promise) => + leaseCache.withLease({ + key: `team:${String(teamId)}`, + label: 'team-operation', + ttlMs: lockTtlMs, + fn: () => fn() + }); diff --git a/packages/service/support/user/team/invitationLink/type.ts b/packages/service/support/user/team/invitationLink/type.ts index 13fd6f85532b..2e2812ebe729 100644 --- a/packages/service/support/user/team/invitationLink/type.ts +++ b/packages/service/support/user/team/invitationLink/type.ts @@ -1,5 +1,3 @@ -import { type TeamMemberSchema } from '@fastgpt/global/support/user/team/type'; - export type InvitationSchemaType = { _id: string; linkId: string; @@ -8,6 +6,7 @@ export type InvitationSchemaType = { forbidden?: boolean; expires: Date; description: string; + creatorUsername?: string; members: string[]; }; @@ -34,4 +33,5 @@ export type InvitationLinkCreateType = { export type InvitationInfoType = InvitationSchemaType & { teamAvatar: string; teamName: string; + alreadyJoined: boolean; }; diff --git a/packages/service/test/support/user/lock.test.ts b/packages/service/test/support/user/lock.test.ts new file mode 100644 index 000000000000..d247dfa31e85 --- /dev/null +++ b/packages/service/test/support/user/lock.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LeaseCache } from '@fastgpt/dal/redis/caches'; +import { withTeamLock } from '@fastgpt/service/support/user/lock'; + +describe('team lock', () => { + let withLease: ReturnType; + + beforeEach(() => { + vi.restoreAllMocks(); + withLease = vi.spyOn(LeaseCache.prototype, 'withLease'); + withLease.mockImplementation(async ({ fn }) => fn({} as any)); + }); + + it('holds a team-scoped lease around the callback and returns its result', async () => { + const callback = vi.fn().mockResolvedValue('done'); + + await expect(withTeamLock('team-1', callback)).resolves.toBe('done'); + + expect(withLease).toHaveBeenCalledWith({ + key: 'team:team-1', + label: 'team-operation', + ttlMs: 600000, + fn: expect.any(Function) + }); + expect(callback).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json index daf6604c2bc9..b617be75fc12 100644 --- a/packages/web/i18n/en/account_team.json +++ b/packages/web/i18n/en/account_team.json @@ -106,6 +106,25 @@ "group": "group", "group_name": "Group name", "handle_invitation": "Handle Team invitation", + "team_invitation": "Team invitation", + "set_member_name_title": "Set member name", + "member_name_label": "Team member name", + "member_name_placeholder": "Enter your team member name", + "invite_member_name_placeholder": "Set your name in this team (20 characters max)", + "member_name_required": "Enter a member name", + "member_name_limit": "Use 20 characters or fewer", + "join_team_success": "Joined the team successfully", + "already_joined": "You have already joined this team", + "switch_team_failed": "Team switch failed. Switch manually from the team entry later", + "invitation_rejected": "You rejected the invitation from {{source}}", + "force_member_name": "Set your team member name", + "set_member_name": "Confirm", + "confirm_member_name": "Confirm", + "cancel_member_name": "Cancel", + "reject_invitation": "Reject invitation", + "join_team": "Join team", + "invited_by": "Invited by {{source}}", + "invitation_creator_fallback": "team administrator", "has_forbidden": "Forbidden", "has_invited": "Invited", "ignore": "Ignore", diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 0729a61b27b6..81d56d370745 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -240,7 +240,7 @@ "confirm_update": "confirm_update", "contact_business": "Contact Sales", "contact_email": "Email us: {{email}}", - "contact_way": "Notification Received", + "contact_way": "Contact information", "contribute_app_template": "Contribute Template", "copy_successful": "Copied Successfully", "copy_to_clipboard": "Copy to Clipboard", @@ -1083,7 +1083,7 @@ "support.user.auth.get_code_again": "s Get Again", "support.user.captcha_placeholder": "Please enter the verification code", "support.user.info.bind_notification_error": "Abnormal binding notification account", - "support.user.info.bind_notification_hint": "Please bind the notification receiving account to ensure that you can receive important reminders such as account information and plan information in a timely manner.", + "support.user.info.bind_notification_hint": "Please bind your contact information to ensure that you can receive important information such as account and plan updates in a timely manner.", "support.user.info.bind_notification_success": "Binding notification account successful", "support.user.info.code_required": "Verification code cannot be empty", "support.user.info.notification_receiving_hint": "Notification reception", diff --git a/packages/web/i18n/ko-KR/account_team.json b/packages/web/i18n/ko-KR/account_team.json index 90f3a603cd6a..afa8d185c51e 100644 --- a/packages/web/i18n/ko-KR/account_team.json +++ b/packages/web/i18n/ko-KR/account_team.json @@ -102,6 +102,24 @@ "group": "그룹", "group_name": "그룹 이름", "handle_invitation": "팀 초대 처리", + "team_invitation": "팀 초대", + "set_member_name_title": "멤버 이름 설정", + "member_name_label": "팀 멤버 이름", + "member_name_placeholder": "팀 멤버 이름을 입력하세요", + "invite_member_name_placeholder": "이 팀에서 사용할 이름을 설정하세요(20자 이내)", + "member_name_required": "멤버 이름을 입력하세요", + "member_name_limit": "20자 이하로 입력하세요", + "join_team_success": "팀에 성공적으로 가입했습니다", + "already_joined": "이미 이 팀에 가입했습니다", + "switch_team_failed": "팀 전환에 실패했습니다. 나중에 팀 메뉴에서 수동으로 전환하세요", + "invitation_rejected": "{{source}}님의 초대를 거절했습니다", + "force_member_name": "팀 멤버 이름 설정", + "set_member_name": "확인", + "confirm_member_name": "확인", + "cancel_member_name": "취소", + "reject_invitation": "초대 거절", + "invited_by": "{{source}}님의 초대", + "invitation_creator_fallback": "팀 관리자", "has_forbidden": "비활성화됨", "has_invited": "초대됨", "ignore": "무시", diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json index aa8b164d9ffe..fb68e73c3c48 100644 --- a/packages/web/i18n/zh-CN/account_team.json +++ b/packages/web/i18n/zh-CN/account_team.json @@ -106,6 +106,25 @@ "group": "群组", "group_name": "群组名称", "handle_invitation": "处理团队邀请", + "team_invitation": "团队邀请", + "set_member_name_title": "设置成员名", + "member_name_label": "团队成员名", + "member_name_placeholder": "请输入团队成员名", + "invite_member_name_placeholder": "请设置您在该团队内的名称(不超过20个字符)", + "member_name_required": "请输入成员名", + "member_name_limit": "请控制字符数在20以内", + "join_team_success": "加入团队成功", + "already_joined": "您已加入该团队", + "switch_team_failed": "团队切换失败,请稍后从团队入口手动切换", + "invitation_rejected": "您已拒绝来自 {{source}} 的邀请", + "force_member_name": "设置团队成员名", + "set_member_name": "确认设置", + "confirm_member_name": "确定", + "cancel_member_name": "取消", + "reject_invitation": "拒绝邀请", + "join_team": "加入团队", + "invited_by": "来自 {{source}} 的邀请", + "invitation_creator_fallback": "团队管理员", "has_forbidden": "已失效", "has_invited": "已邀请", "ignore": "忽略", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 9cff264ac8c2..68d05f519ae5 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -240,7 +240,7 @@ "confirm_update": "确认更新", "contact_business": "联系商务", "contact_email": "邮箱联系:{{email}}", - "contact_way": "通知接收", + "contact_way": "联系方式", "contribute_app_template": "贡献模板", "copy_successful": "复制成功", "copy_to_clipboard": "复制到剪贴板", @@ -1083,7 +1083,7 @@ "support.user.auth.get_code_again": "s后重新获取", "support.user.captcha_placeholder": "请输入验证码", "support.user.info.bind_notification_error": "绑定通知账号异常", - "support.user.info.bind_notification_hint": "请绑定通知接收账号,以确保您能及时正常接收账号信息、套餐信息等重要提醒。", + "support.user.info.bind_notification_hint": "请绑定联系方式,以确保您能及时正常接收账号信息、套餐信息等重要信息", "support.user.info.bind_notification_success": "绑定通知账号成功", "support.user.info.code_required": "验证码不能为空", "support.user.info.notification_receiving_hint": "通知接收", diff --git a/packages/web/i18n/zh-Hant/account_team.json b/packages/web/i18n/zh-Hant/account_team.json index f7868c56675f..43b9f5cda7fa 100644 --- a/packages/web/i18n/zh-Hant/account_team.json +++ b/packages/web/i18n/zh-Hant/account_team.json @@ -106,6 +106,25 @@ "group": "群組", "group_name": "群組名稱", "handle_invitation": "處理團隊邀請", + "team_invitation": "團隊邀請", + "set_member_name_title": "設定成員名", + "member_name_label": "團隊成員名", + "member_name_placeholder": "請輸入團隊成員名", + "invite_member_name_placeholder": "請設定您在該團隊內的名稱(不超過20個字符)", + "member_name_required": "請輸入成員名", + "member_name_limit": "請控制字符數在20以內", + "join_team_success": "加入團隊成功", + "already_joined": "您已加入該團隊", + "switch_team_failed": "團隊切換失敗,請稍後從團隊入口手動切換", + "invitation_rejected": "您已拒絕來自 {{source}} 的邀請", + "force_member_name": "設定團隊成員名", + "set_member_name": "確認設定", + "confirm_member_name": "確定", + "cancel_member_name": "取消", + "reject_invitation": "拒絕邀請", + "join_team": "加入團隊", + "invited_by": "來自 {{source}} 的邀請", + "invitation_creator_fallback": "團隊管理員", "has_forbidden": "已失效", "has_invited": "已邀請", "ignore": "忽略", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index c9f3cc8e3ade..2e66542ab133 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -240,7 +240,7 @@ "confirm_update": "確認更新", "contact_business": "聯絡業務", "contact_email": "電子郵件:{{email}}", - "contact_way": "通知接收", + "contact_way": "聯絡方式", "contribute_app_template": "貢獻範本", "copy_successful": "複製成功", "copy_to_clipboard": "複製到剪貼簿", @@ -1083,7 +1083,7 @@ "support.user.auth.get_code_again": "秒後重新取得", "support.user.captcha_placeholder": "請輸入驗證碼", "support.user.info.bind_notification_error": "綁定通知帳號異常", - "support.user.info.bind_notification_hint": "請綁定通知接收帳號,以確保您能及時正常接收帳號資訊、套餐資訊等重要提醒。", + "support.user.info.bind_notification_hint": "請綁定聯絡方式,以確保您能及時正常接收帳號資訊、套餐資訊等重要資訊", "support.user.info.bind_notification_success": "綁定通知帳號成功", "support.user.info.code_required": "驗證碼不能為空", "support.user.info.notification_receiving_hint": "通知接收", diff --git a/pro b/pro index 1ed762aa9316..98ff06a64366 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 1ed762aa93161d90e92448e5a73132626798fefe +Subproject commit 98ff06a6436633954ab2f241d7a3aeea8ff586f5 diff --git a/projects/app/src/components/Layout/ForceMemberNameModal.tsx b/projects/app/src/components/Layout/ForceMemberNameModal.tsx new file mode 100644 index 000000000000..14014cd30ece --- /dev/null +++ b/projects/app/src/components/Layout/ForceMemberNameModal.tsx @@ -0,0 +1,110 @@ +import { Box, Button, FormControl, FormErrorMessage, Input } from '@chakra-ui/react'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useMemo, useState } from 'react'; +import { putUpdateMemberName } from '@/web/support/user/team/api'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { TeamMemberNameSchema } from '@fastgpt/global/support/user/team/memberName'; +import { UNSET_TEAM_MEMBER_NAME } from '@fastgpt/global/support/user/team/constant'; + +/** 强制补齐当前团队成员名,刷新失败或仍返回保留值时保留弹窗并允许重试。 */ +const ForceMemberNameModal = ({ onSuccess }: { onSuccess: () => void }) => { + const { t } = useClientTranslation('account_team'); + const { initUserInfo, userInfo } = useUserStore(); + const [memberName, setMemberName] = useState(userInfo?.username ?? ''); + const [hasInteracted, setHasInteracted] = useState(false); + + const nameError = useMemo(() => { + if (!memberName) return t('account_team:member_name_required'); + return TeamMemberNameSchema.safeParse(memberName).success + ? '' + : t('account_team:member_name_limit'); + }, [memberName, t]); + + const showNameError = hasInteracted && !!nameError; + + const { runAsync: updateName, loading } = useRequest( + async () => { + const normalizedName = TeamMemberNameSchema.parse(memberName); + await putUpdateMemberName(normalizedName); + return initUserInfo(); + }, + { + manual: true, + onSuccess(userInfo) { + if (userInfo?.team?.memberName !== UNSET_TEAM_MEMBER_NAME) { + onSuccess(); + } + } + } + ); + + return ( + { + setHasInteracted(true); + if (!nameError) void updateName(); + }} + > + {t('account_team:confirm_member_name')} + + } + > + + + {t('account_team:member_name_label')} + + { + setHasInteracted(true); + setMemberName(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !nameError && !loading) { + event.preventDefault(); + void updateName(); + } + }} + /> + {showNameError && {nameError}} + + + ); +}; + +export default ForceMemberNameModal; diff --git a/projects/app/src/components/Layout/PostLoginActionOrchestrator.tsx b/projects/app/src/components/Layout/PostLoginActionOrchestrator.tsx new file mode 100644 index 000000000000..16b5e2ae7b3e --- /dev/null +++ b/projects/app/src/components/Layout/PostLoginActionOrchestrator.tsx @@ -0,0 +1,218 @@ +import dynamic from 'next/dynamic'; +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { getInviteLinkIdFromRoute } from '@/web/support/user/loginRedirect/invitation'; +import { UNSET_TEAM_MEMBER_NAME } from '@fastgpt/global/support/user/team/constant'; +import type { GetUnreadInformResponseType } from '@fastgpt/global/openapi/support/user/inform/api'; +import type { UserInformSchema } from '@fastgpt/global/support/user/inform/type'; +import { + finishPostLoginAction, + getNextPostLoginAction, + isPostLoginActionRoute, + startPostLoginAction, + type OneTimePostLoginAction, + type PostLoginActionState +} from './postLoginAction'; + +const HandleInviteModal = dynamic( + () => import('@/pageComponents/account/team/Invite/HandleInviteModal'), + { ssr: false } +); +const ForceMemberNameModal = dynamic(() => import('./ForceMemberNameModal'), { ssr: false }); +const ResetExpiredPswModal = dynamic( + () => import('@/components/support/user/safe/ResetExpiredPswModal'), + { ssr: false } +); +const SystemMsgModal = dynamic(() => import('@/components/support/user/inform/SystemMsgModal'), { + ssr: false +}); +const ImportantInform = dynamic(() => import('@/components/support/user/inform/ImportantInform'), { + ssr: false +}); +const UpdateContact = dynamic(() => import('@/components/support/user/inform/UpdateContactModal'), { + ssr: false +}); +const ActivityAdModal = dynamic(() => import('@/components/support/activity/ActivityAdModal'), { + ssr: false +}); +const EnterpriseAuthNoticeModal = dynamic( + () => import('@/components/support/user/inform/EnterpriseAuthNoticeModal'), + { ssr: false } +); + +const CONTACT_HANDLED_KEY_PREFIX = 'fastgpt:login-action:bind-contact-handled:v3:'; + +const getContactHandledKey = (userId: string) => `${CONTACT_HANDLED_KEY_PREFIX}${userId}`; + +type PostLoginActionOrchestratorProps = { + importantInforms: UserInformSchema[]; + importantInformQueryError: boolean; + refetchImportantInforms: () => Promise<{ + isError?: boolean; + data?: GetUnreadInformResponseType; + }>; + unreadQueryFetched: boolean; +}; + +/** + * 串行编排登录后的用户动作,保证邀请、成员名、联系方式和通知类弹窗不会同时出现。 + * 业务组件只负责自身展示和完成回调,动作顺序与启动条件集中在这里维护。 + */ +const PostLoginActionOrchestrator = ({ + importantInforms, + importantInformQueryError, + refetchImportantInforms, + unreadQueryFetched +}: PostLoginActionOrchestratorProps) => { + const router = useRouter(); + const { feConfigs } = useSystemStore(); + const { userInfo } = useUserStore(); + const userId = userInfo?._id; + const teamId = userInfo?.team?.teamId; + const inviteLinkId = getInviteLinkIdFromRoute(router.asPath); + const isPlus = !!feConfigs?.isPlus; + const canRunPostLoginActions = isPostLoginActionRoute(router.pathname); + const runKey = userId && teamId ? `${userId}:${teamId}` : ''; + + const [runState, setRunState] = useState({ + key: '', + completed: new Set() + }); + const [invitationProgress, setInvitationProgress] = useState<{ + key: string; + linkId: string; + active: boolean; + }>({ key: '', linkId: '', active: false }); + + const contactHandled = + !!userId && window.localStorage.getItem(getContactHandledKey(userId)) === '1'; + const invitationInProgress = invitationProgress.key === runKey && invitationProgress.active; + const invitationActionLinkId = invitationInProgress ? invitationProgress.linkId : inviteLinkId; + + const shouldShowContact = + isPlus && + !!feConfigs?.bind_notification_method?.length && + !userInfo?.contact && + !!userInfo?.team?.permission?.isOwner; + + const canStart = + router.isReady && + canRunPostLoginActions && + isPlus && + !!userId && + !!teamId && + unreadQueryFetched; + + const finishOneTimeAction = useCallback( + (action: OneTimePostLoginAction) => { + setRunState((state) => finishPostLoginAction({ state, key: runKey, action })); + }, + [runKey] + ); + + const finishImportantInform = useCallback(() => { + setRunState((state) => + finishPostLoginAction({ state, key: runKey, action: 'importantInform' }) + ); + }, [runKey]); + + const finishInvitation = useCallback(() => { + setInvitationProgress({ key: runKey, linkId: '', active: false }); + finishOneTimeAction('invitation'); + }, [finishOneTimeAction, runKey]); + + const startInvitation = useCallback(() => { + setInvitationProgress({ key: runKey, linkId: inviteLinkId, active: true }); + }, [inviteLinkId, runKey]); + + const currentAction = runState.key === runKey ? runState.currentAction : undefined; + const completed = + runState.key === runKey ? runState.completed : new Set(); + const nextAction = getNextPostLoginAction({ + canStart, + currentAction, + completed, + inviteLinkId: invitationActionLinkId, + hasPendingMemberName: userInfo?.team?.memberName === UNSET_TEAM_MEMBER_NAME, + shouldShowContact, + contactHandled, + isPlus, + hasImportantInform: importantInforms.length > 0 + }); + + useEffect(() => { + if (!canStart || !runKey || currentAction || !nextAction) return; + + // The effect commits the derived action into the orchestrator lock before the next external update. + // eslint-disable-next-line react-hooks/set-state-in-effect + setRunState((state) => startPostLoginAction({ state, key: runKey, action: nextAction })); + }, [canStart, currentAction, nextAction, runKey]); + + const activeAction = canStart ? currentAction : undefined; + + const finishContact = useCallback(() => { + if (userId) { + window.localStorage.setItem(getContactHandledKey(userId), '1'); + } + finishOneTimeAction('contact'); + }, [finishOneTimeAction, userId]); + + if (activeAction === 'invitation' && invitationActionLinkId) { + return ( + + ); + } + + if (activeAction === 'memberName') { + return finishOneTimeAction('memberName')} />; + } + + if (activeAction === 'resetExpiredPassword') { + return ( + finishOneTimeAction('resetExpiredPassword')} /> + ); + } + + if (activeAction === 'contact') { + return ; + } + + if (activeAction === 'systemMessage') { + return finishOneTimeAction('systemMessage')} />; + } + + if (activeAction === 'importantInform') { + return ( + + ); + } + + if (activeAction === 'activityAd') { + return finishOneTimeAction('activityAd')} />; + } + + if (activeAction === 'enterpriseAuthNotice') { + return ( + finishOneTimeAction('enterpriseAuthNotice')} + /> + ); + } + + return null; +}; + +export default PostLoginActionOrchestrator; diff --git a/projects/app/src/components/Layout/index.tsx b/projects/app/src/components/Layout/index.tsx index e2d04b1ff267..9d66e95f49b3 100644 --- a/projects/app/src/components/Layout/index.tsx +++ b/projects/app/src/components/Layout/index.tsx @@ -22,33 +22,14 @@ import { useUserModelStore } from '@/web/core/ai/model/useUserModelStore'; const Navbar = dynamic(() => import('./navbar')); const NavbarPhone = dynamic(() => import('./navbarPhone')); -const ResetExpiredPswModal = dynamic( - () => import('@/components/support/user/safe/ResetExpiredPswModal'), - { ssr: false } -); const NotSufficientModal = dynamic(() => import('@/components/support/wallet/NotSufficientModal'), { ssr: false }); -const SystemMsgModal = dynamic(() => import('@/components/support/user/inform/SystemMsgModal'), { - ssr: false -}); -const EnterpriseAuthNoticeModal = dynamic( - () => import('@/components/support/user/inform/EnterpriseAuthNoticeModal'), - { - ssr: false - } -); -const ImportantInform = dynamic(() => import('@/components/support/user/inform/ImportantInform'), { - ssr: false -}); -const UpdateContact = dynamic(() => import('@/components/support/user/inform/UpdateContactModal'), { - ssr: false -}); const ManualCopyModal = dynamic( () => import('@fastgpt/web/hooks/useCopyData').then((mod) => mod.ManualCopyModal), { ssr: false } ); -const ActivityAdModal = dynamic(() => import('@/components/support/activity/ActivityAdModal'), { +const PostLoginActionOrchestrator = dynamic(() => import('./PostLoginActionOrchestrator'), { ssr: false }); const ProModal = dynamic(() => import('@/components/ProTip/ProModal'), { @@ -94,7 +75,7 @@ const Layout = ({ children }: { children: JSX.Element }) => { const { Loading } = useLoading(); const { setLastRoute, loading, feConfigs, showProModal, setShowProModal } = useSystemStore(); const { isPc } = useSystem(); - const { userInfo, isUpdateNotification, setIsUpdateNotification } = useUserStore(); + const { userInfo } = useUserStore(); const modelLoginGeneration = useUserModelStore((state) => state.loginGeneration); const { setUserDefaultLng, setShareDefaultLng } = useI18nLng(); const checkedModelIdentityRef = useRef(); @@ -108,19 +89,18 @@ const Layout = ({ children }: { children: JSX.Element }) => { ); const isHideNavbar = !!pcUnShowLayoutRoute[router.pathname]; - // System hook - const { data, refetch: refetchUnRead } = useQuery(['getUnreadCount'], getUnreadCount, { + const { + data, + refetch: refetchUnRead, + isFetched: unreadQueryFetched, + isError: unreadQueryError + } = useQuery(['getUnreadCount', userInfo?._id], getUnreadCount, { enabled: !!userInfo && !!feConfigs.isPlus, refetchInterval: 30000 }); - const unread = data?.unReadCount || 0; - const importantInforms = data?.importantInforms || []; - const showUpdateNotification = - isUpdateNotification && - feConfigs?.bind_notification_method && - feConfigs?.bind_notification_method.length > 0 && - !userInfo?.contact && - !!userInfo?.team.permission.isOwner; + const isUnreadDataError = unreadQueryError || data === undefined || data === 0; + const unread = data !== 0 ? (data?.unReadCount ?? 0) : 0; + const importantInforms = data !== 0 ? (data?.importantInforms ?? []) : []; const syncDefaultLanguage = router.pathname === '/chat/share' ? setShareDefaultLng : setUserDefaultLng; @@ -223,21 +203,16 @@ const Layout = ({ children }: { children: JSX.Element }) => { {feConfigs?.isPlus && ( <> - - {showUpdateNotification && ( - setIsUpdateNotification(false)} mode="contact" /> - )} - {!!userInfo && importantInforms.length > 0 && ( - - )} - {router.pathname !== '/account/cancel' && } )} - - - + {showProModal && setShowProModal(false)} />} diff --git a/projects/app/src/components/Layout/postLoginAction.ts b/projects/app/src/components/Layout/postLoginAction.ts new file mode 100644 index 000000000000..b2902de622c6 --- /dev/null +++ b/projects/app/src/components/Layout/postLoginAction.ts @@ -0,0 +1,138 @@ +export type PostLoginAction = + | 'invitation' + | 'memberName' + | 'resetExpiredPassword' + | 'contact' + | 'systemMessage' + | 'importantInform' + | 'activityAd' + | 'enterpriseAuthNotice'; + +export type OneTimePostLoginAction = Exclude; + +export type PostLoginActionState = { + key: string; + completed: ReadonlySet; + currentAction?: PostLoginAction; +}; + +export const POST_LOGIN_ACTION_EXCLUDED_ROUTES = new Set([ + '/', + '/login', + '/login/provider', + '/login/fastlogin', + '/login/sso', + '/appStore', + '/account/cancel', + '/chat', + '/chat/share', + '/tools/price', + '/price', + '/logout' +]); + +/** 判断当前路径是否允许启动登录后的引导动作;注销页面必须保持流程专注。 */ +export const isPostLoginActionRoute = (pathname: string) => + !POST_LOGIN_ACTION_EXCLUDED_ROUTES.has(pathname); + +/** + * 按设计文档规定的顺序选择下一个登录后动作。 + * 当前动作优先保持不变;重要通知不进入一次性完成集合,后续新通知可以再次触发。 + */ +export const getNextPostLoginAction = ({ + canStart, + currentAction, + completed, + inviteLinkId, + hasPendingMemberName, + shouldShowContact, + contactHandled, + isPlus, + hasImportantInform +}: { + canStart: boolean; + currentAction?: PostLoginAction; + completed: ReadonlySet; + inviteLinkId: string; + hasPendingMemberName: boolean; + shouldShowContact: boolean; + contactHandled: boolean; + isPlus: boolean; + hasImportantInform: boolean; +}) => { + if (!canStart) return undefined; + if (currentAction) return currentAction; + + const candidates: Array = [ + inviteLinkId ? 'invitation' : undefined, + hasPendingMemberName ? 'memberName' : undefined, + isPlus ? 'resetExpiredPassword' : undefined, + shouldShowContact && !contactHandled ? 'contact' : undefined, + isPlus ? 'systemMessage' : undefined, + isPlus && hasImportantInform ? 'importantInform' : undefined, + isPlus ? 'activityAd' : undefined, + isPlus ? 'enterpriseAuthNotice' : undefined + ]; + + return candidates.find( + (action) => action && (action === 'importantInform' || !completed.has(action)) + ); +}; + +/** 锁定当前动作,防止通知轮询或其他派生状态变化抢占正在展示的弹窗。 */ +export const startPostLoginAction = ({ + state, + key, + action +}: { + state: PostLoginActionState; + key: string; + action: PostLoginAction; +}): PostLoginActionState => { + if (state.key !== key) { + return { + key, + completed: new Set(), + currentAction: action + }; + } + + if (state.currentAction) return state; + + return { + ...state, + currentAction: action + }; +}; + +/** + * 释放当前动作。一次性动作写入 completed;重要通知只释放当前锁,不记录永久完成状态。 + * key 和 currentAction 都必须匹配,避免旧弹窗的异步回调污染新用户或新团队状态。 + */ +export const finishPostLoginAction = ({ + state, + key, + action +}: { + state: PostLoginActionState; + key: string; + action: PostLoginAction; +}): PostLoginActionState => { + if (state.key !== key || state.currentAction !== action) return state; + + if (action === 'importantInform') { + return { + ...state, + currentAction: undefined + }; + } + + const completed = new Set(state.completed); + completed.add(action); + + return { + ...state, + completed, + currentAction: undefined + }; +}; diff --git a/projects/app/src/components/support/activity/ActivityAdModal.tsx b/projects/app/src/components/support/activity/ActivityAdModal.tsx index 64f18905b31f..77c9f1a9f90b 100644 --- a/projects/app/src/components/support/activity/ActivityAdModal.tsx +++ b/projects/app/src/components/support/activity/ActivityAdModal.tsx @@ -22,7 +22,13 @@ import { accountCancellationActiveStatuses } from '@fastgpt/global/support/user/ const CLOSED_AD_KEY = 'logout-activity-ad'; const CLOSED_AD_DURATION = 24 * 60 * 60 * 1000; // 24 hours -const ActivityAdModal = () => { +const ActivityAdModal = ({ + enabled = true, + onFinish +}: { + enabled?: boolean; + onFinish?: () => void; +}) => { const { isOpen, onOpen, onClose } = useDisclosure(); const { t } = useTranslation(); const { feConfigs } = useSystemStore(); @@ -45,14 +51,18 @@ const ActivityAdModal = () => { const { data } = useRequest( async () => { - if (!feConfigs?.isPlus || !userInfo || isCancellationRestricted) return; + if (!enabled || !feConfigs?.isPlus || !userInfo || isCancellationRestricted) return; return getActivityAd(); }, { manual: false, onSuccess(res) { const shouldShowAd = (() => { - if (!res?.id) return false; + if (!enabled) return false; + if (!res?.id) { + onFinish?.(); + return false; + } if (!closedData) return true; try { @@ -72,9 +82,14 @@ const ActivityAdModal = () => { if (res?.activityAdImage && shouldShowAd) { onOpen(); + } else { + onFinish?.(); } }, - refreshDeps: [isCancellationRestricted, userInfo] + onError() { + if (enabled) onFinish?.(); + }, + refreshDeps: [enabled, isCancellationRestricted, userInfo] } ); @@ -83,7 +98,8 @@ const ActivityAdModal = () => { setClosedData(JSON.stringify({ timestamp: Date.now(), adId: data.id })); } onClose(); - }, [data, onClose, setClosedData]); + onFinish?.(); + }, [data, onClose, onFinish, setClosedData]); const handleJoin = useCallback(() => { if (data?.activityAdLink) { @@ -92,9 +108,10 @@ const ActivityAdModal = () => { handleClose(); } else { window.open(data.activityAdLink, '_blank'); + handleClose(); } } - }, [data, handleClose, router]); + }, [data?.activityAdLink, handleClose, router]); if (!data?.activityAdImage || !userInfo) { return null; diff --git a/projects/app/src/components/support/user/inform/EnterpriseAuthNoticeModal.tsx b/projects/app/src/components/support/user/inform/EnterpriseAuthNoticeModal.tsx index 1a674882deaa..7cd63388099d 100644 --- a/projects/app/src/components/support/user/inform/EnterpriseAuthNoticeModal.tsx +++ b/projects/app/src/components/support/user/inform/EnterpriseAuthNoticeModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, Button, Flex, Link, Text } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import MyModal from '@fastgpt/web/components/v2/common/MyModal'; @@ -49,7 +49,13 @@ const BenefitItem = ({ children }: { children: React.ReactNode }) => ( ); -const EnterpriseAuthNoticeModal = () => { +const EnterpriseAuthNoticeModal = ({ + enabled = true, + onFinish +}: { + enabled?: boolean; + onFinish?: () => void; +}) => { const router = useRouter(); const { t } = useTranslation(); const { feConfigs } = useSystemStore(); @@ -66,20 +72,20 @@ const EnterpriseAuthNoticeModal = () => { hasTeamManagePer: userInfo?.team?.permission?.hasManagePer }); const shouldCheckEnterpriseAuthNotice = - router.pathname === '/dashboard/agent' && + enabled && !!feConfigs?.show_enterprise_auth && !!teamId && canCheckEnterpriseAuthNotice && !isAccountCancellationPending && !enterpriseAuthNoticeReadTeamIds?.includes(teamId); - const { data: enterpriseAuthStatus } = useQuery( - ['getEnterpriseAuthNoticeStatus', teamId], - getEnterpriseAuthStatus, - { - enabled: shouldCheckEnterpriseAuthNotice, - staleTime: 30000 - } - ); + const { + data: enterpriseAuthStatus, + isError: enterpriseAuthStatusError, + isFetched: enterpriseAuthStatusFetched + } = useQuery(['getEnterpriseAuthNoticeStatus', teamId], getEnterpriseAuthStatus, { + enabled: shouldCheckEnterpriseAuthNotice, + staleTime: 30000 + }); const canShowEnterpriseAuthNotice = canManageEnterpriseAuth({ statusCanManage: enterpriseAuthStatus?.canManage, isTeamOwner: userInfo?.team?.permission?.isOwner, @@ -100,6 +106,24 @@ const EnterpriseAuthNoticeModal = () => { ] ); + useEffect(() => { + if (!enabled) return; + if (!shouldCheckEnterpriseAuthNotice) { + onFinish?.(); + return; + } + if (enterpriseAuthStatusFetched && (enterpriseAuthStatusError || !showEnterpriseAuthNotice)) { + onFinish?.(); + } + }, [ + enabled, + enterpriseAuthStatusError, + enterpriseAuthStatusFetched, + onFinish, + shouldCheckEnterpriseAuthNotice, + showEnterpriseAuthNotice + ]); + const markAsRead = useCallback(() => { if (teamId) { setEnterpriseAuthNoticeRead(teamId); @@ -109,18 +133,20 @@ const EnterpriseAuthNoticeModal = () => { const onClose = useCallback(() => { markAsRead(); setIsClosed(true); - }, [markAsRead]); + onFinish?.(); + }, [markAsRead, onFinish]); const onClickCertificationLink = useCallback( async (event: React.MouseEvent) => { event.preventDefault(); markAsRead(); + onFinish?.(); await router.push(certificationHref); }, - [markAsRead, router] + [markAsRead, onFinish, router] ); - if (!showEnterpriseAuthNotice || isClosed) return null; + if (!enabled || !showEnterpriseAuthNotice || isClosed) return null; return ( void; + refetch: () => Promise; + enabled?: boolean; + queryError: boolean; + onResolved?: () => void; }) => { + const [visibleInforms, setVisibleInforms] = useState(informs); + const dismissedIdsRef = useRef(new Set()); + const { runAsync: onClickClose } = useRequest( async (id: string) => { await readInform(id); + return id; }, { - onSuccess: () => { - refetch(); + onSuccess: (id) => { + dismissedIdsRef.current.add(id); + setVisibleInforms((current) => current.filter((inform) => inform._id !== id)); + // The local state controls the current modal; refetch only synchronizes the parent cache. + void refetch().catch(() => undefined); }, + onError: () => undefined, errorToast: 'Failed to read the inform' } ); + useEffect(() => { + if (queryError) return; + + // Refetch 可能在主从延迟期间返回空列表或仍包含已读项,不能用它覆盖本地待展示队列。 + setVisibleInforms((current) => { + const currentIds = new Set(current.map((inform) => inform._id)); + const newInforms = informs.filter( + (inform) => !dismissedIdsRef.current.has(inform._id) && !currentIds.has(inform._id) + ); + return [...current, ...newInforms]; + }); + }, [informs, queryError]); + + useEffect(() => { + if (enabled && !queryError && visibleInforms.length === 0) { + onResolved?.(); + } + }, [enabled, onResolved, queryError, visibleInforms.length]); + + if (!enabled) return null; + return ( - {informs.map((inform) => ( + {visibleInforms.map((inform) => ( import('@/components/Markdown'), { ssr: false }); -const SystemMsgModal = () => { +const SystemMsgModal = ({ + enabled = true, + onFinish +}: { + enabled?: boolean; + onFinish?: () => void; +}) => { const { t } = useTranslation(); const { userInfo, systemMsgReadId, setSysMsgReadId } = useUserStore(); @@ -18,18 +24,24 @@ const SystemMsgModal = () => { const { data } = useRequest( async () => { - if (!userInfo?._id) { + if (!enabled || !userInfo?._id) { return; } return getSystemMsgModalData(); }, { - refreshDeps: [systemMsgReadId, userInfo?._id], + refreshDeps: [enabled, systemMsgReadId, userInfo?._id], manual: false, onSuccess(res) { + if (!enabled) return; if (res?.content && (!systemMsgReadId || res.id !== systemMsgReadId)) { onOpen(); + } else { + onFinish?.(); } + }, + onError() { + if (enabled) onFinish?.(); } } ); @@ -43,7 +55,8 @@ const SystemMsgModal = () => { }); onClose(); - }, [data, onClose, setSysMsgReadId]); + onFinish?.(); + }, [data, onClose, onFinish, setSysMsgReadId]); return isOpen ? ( diff --git a/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx b/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx index da9d1646f107..6a2661bd7a53 100644 --- a/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx +++ b/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx @@ -16,7 +16,13 @@ type FormType = { confirmPsw: string; }; -const ResetPswModal = () => { +const ResetPswModal = ({ + enabled = true, + onFinish +}: { + enabled?: boolean; + onFinish?: () => void; +}) => { const { t } = useTranslation(); const { toast } = useToast(); const { userInfo } = useUserStore(); @@ -38,7 +44,7 @@ const ResetPswModal = () => { loading: isFetching } = useRequest( async () => { - if (!userInfo?._id) { + if (!enabled || !userInfo?._id) { return false; } if (isAccountCancellationPending) { @@ -48,7 +54,13 @@ const ResetPswModal = () => { }, { manual: false, - refreshDeps: [userInfo?._id, isAccountCancellationPending] + refreshDeps: [enabled, userInfo?._id, isAccountCancellationPending], + onSuccess(res) { + if (enabled && !res) onFinish?.(); + }, + onError() { + if (enabled) onFinish?.(); + } } ); @@ -73,7 +85,7 @@ const ResetPswModal = () => { } }; - return passwordExpired ? ( + return enabled && passwordExpired ? ( diff --git a/projects/app/src/pageComponents/account/info/MemberNameModal.tsx b/projects/app/src/pageComponents/account/info/MemberNameModal.tsx new file mode 100644 index 000000000000..9009c3627a64 --- /dev/null +++ b/projects/app/src/pageComponents/account/info/MemberNameModal.tsx @@ -0,0 +1,117 @@ +import { Box, Button, FormControl, FormErrorMessage, Input } from '@chakra-ui/react'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useMemo, useState } from 'react'; +import { TeamMemberNameSchema } from '@fastgpt/global/support/user/team/memberName'; +import { putUpdateMemberName } from '@/web/support/user/team/api'; +import { useUserStore } from '@/web/support/user/useUserStore'; + +/** 修改当前团队成员名,成功后刷新用户信息并关闭弹窗。 */ +const MemberNameModal = ({ memberName, onClose }: { memberName: string; onClose: () => void }) => { + const { t } = useClientTranslation('account_team'); + const { initUserInfo } = useUserStore(); + const [value, setValue] = useState(memberName); + const [hasInteracted, setHasInteracted] = useState(false); + + const nameError = useMemo(() => { + if (!value) return t('account_team:member_name_required'); + return TeamMemberNameSchema.safeParse(value).success ? '' : t('account_team:member_name_limit'); + }, [t, value]); + const showNameError = hasInteracted && !!nameError; + + const { runAsync: updateName, loading } = useRequest( + async () => { + const normalizedName = TeamMemberNameSchema.parse(value); + await putUpdateMemberName(normalizedName); + await initUserInfo(); + }, + { + manual: true, + onSuccess: onClose + } + ); + + return ( + + + + + } + > + + + {t('account_team:member_name_label')} + + { + setHasInteracted(true); + setValue(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !nameError && !loading) { + event.preventDefault(); + void updateName(); + } + }} + /> + {showNameError && {nameError}} + + + ); +}; + +export default MemberNameModal; diff --git a/projects/app/src/pageComponents/account/team/Invite/HandleInviteModal.tsx b/projects/app/src/pageComponents/account/team/Invite/HandleInviteModal.tsx index 0decdb51ac04..babb4a911d91 100644 --- a/projects/app/src/pageComponents/account/team/Invite/HandleInviteModal.tsx +++ b/projects/app/src/pageComponents/account/team/Invite/HandleInviteModal.tsx @@ -1,77 +1,245 @@ -import { getInvitationInfo, postAcceptInvitationLink } from '@/web/support/user/team/api'; -import { Box, Button, Flex, ModalBody, ModalCloseButton } from '@chakra-ui/react'; +import { Box, Button, FormControl, Input, Flex } from '@chakra-ui/react'; import Avatar from '@fastgpt/web/components/common/Avatar'; -import MyModal from '@fastgpt/web/components/common/MyModal'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; +import { useToast } from '@fastgpt/web/hooks/useToast'; import { useRouter } from 'next/router'; -import { useContextSelector } from 'use-context-selector'; -import { TeamContext } from '../context'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + getInvitationInfo, + postAcceptInvitationWithMemberName, + putSwitchTeam +} from '@/web/support/user/team/api'; +import { clearInviteLinkFromRoute } from '@/web/support/user/loginRedirect/invitation'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { TeamMemberNameSchema } from '@fastgpt/global/support/user/team/memberName'; -function Invite({ invitelinkid }: { invitelinkid: string }) { +/** + * 登录后处理团队邀请。接受时一次提交成员名和邀请,拒绝或无效邀请只清理当前上下文。 + * 团队切换失败不回滚接受结果,也不自动重试,继续留在当前 session。 + */ +const HandleInviteModal = ({ + inviteLinkId, + onStart, + onFinish +}: { + inviteLinkId: string; + onStart?: () => void; + onFinish?: () => void; +}) => { const router = useRouter(); const { t } = useClientTranslation('account_team'); + const { toast } = useToast(); + const { initUserInfo } = useUserStore(); + const { feConfigs } = useSystemStore(); + const isMultiTeamMode = feConfigs?.teamMode !== 'single'; + const [memberName, setMemberName] = useState(''); + const [hasInteracted, setHasInteracted] = useState(false); + const alreadyJoinedNotifiedRef = useRef(false); - const { onSwitchTeam } = useContextSelector(TeamContext, (v) => v); + const clearInvitationContext = useCallback(async () => { + const nextRoute = clearInviteLinkFromRoute(router.asPath); + await router.replace(nextRoute, undefined, { shallow: true }); + }, [router]); - const onClose = () => { - router.push('/account/team'); - }; + const finishInvitation = useCallback(async () => { + await clearInvitationContext(); + onFinish?.(); + }, [clearInvitationContext, onFinish]); - const { data: invitationInfo } = useRequest(() => getInvitationInfo(invitelinkid), { + const { data: invitationInfo } = useRequest(() => getInvitationInfo(inviteLinkId), { manual: false, - onError: onClose + onError: () => { + void finishInvitation(); + } }); + useEffect(() => { + if (!invitationInfo?.alreadyJoined || alreadyJoinedNotifiedRef.current) return; + + alreadyJoinedNotifiedRef.current = true; + toast({ status: 'error', title: t('account_team:already_joined') }); + void finishInvitation(); + }, [finishInvitation, invitationInfo?.alreadyJoined, t, toast]); + + const nameError = useMemo(() => { + if (!memberName) return t('account_team:member_name_required'); + const result = TeamMemberNameSchema.safeParse(memberName); + return result.success ? '' : t('account_team:member_name_limit'); + }, [memberName, t]); + const showNameError = hasInteracted && !!nameError; + const inviterName = + invitationInfo?.creatorUsername || t('account_team:invitation_creator_fallback'); + const { runAsync: acceptInvitation, loading: accepting } = useRequest( - () => postAcceptInvitationLink(invitelinkid), + async () => { + const normalizedMemberName = TeamMemberNameSchema.parse(memberName); + onStart?.(); + return postAcceptInvitationWithMemberName({ + linkId: inviteLinkId, + memberName: normalizedMemberName + }); + }, { manual: true, - successToast: t('common:Success'), - onSuccess: async () => { - onSwitchTeam(invitationInfo!.teamId); - onClose(); + onSuccess: async ({ teamId }) => { + toast({ status: 'success', title: t('account_team:join_team_success') }); + try { + await putSwitchTeam(teamId); + await initUserInfo(); + await clearInvitationContext(); + router.reload(); + } catch { + toast({ status: 'warning', title: t('account_team:switch_team_failed') }); + await initUserInfo(); + await clearInvitationContext(); + } + onFinish?.(); } } ); - return invitationInfo ? ( + const rejectInvitation = async () => { + await finishInvitation(); + toast({ + status: 'info', + title: t('account_team:invitation_rejected', { source: inviterName }) + }); + }; + + if (!invitationInfo || invitationInfo.alreadyJoined) return null; + + return ( - - - - - {invitationInfo.teamName} - + isOpen + title={t(`account_team:${isMultiTeamMode ? 'team_invitation' : 'set_member_name_title'}`)} + closeOnOverlayClick={false} + onClose={isMultiTeamMode ? rejectInvitation : undefined} + showCloseButton={isMultiTeamMode} + borderRadius="10px" + footer={ + <> + {isMultiTeamMode && ( + + )} - - - + + } + > + + {isMultiTeamMode && ( + + + + + {invitationInfo.teamName} + + + {t('account_team:invited_by', { source: inviterName })} + + + + )} + + + + {t('account_team:member_name_label')} + + {showNameError && ( + + {nameError} + + )} + + { + setHasInteracted(true); + setMemberName(event.target.value); + }} + onBlur={() => setHasInteracted(true)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !nameError && !accepting) { + event.preventDefault(); + void acceptInvitation(); + } + }} + /> + + - ) : null; -} + ); +}; -export default Invite; +export default HandleInviteModal; diff --git a/projects/app/src/pageComponents/account/team/MemberTable.tsx b/projects/app/src/pageComponents/account/team/MemberTable.tsx index 4c701c5daa4a..259a31853e39 100644 --- a/projects/app/src/pageComponents/account/team/MemberTable.tsx +++ b/projects/app/src/pageComponents/account/team/MemberTable.tsx @@ -396,7 +396,7 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) { ))} - + diff --git a/projects/app/src/pages/account/info/index.tsx b/projects/app/src/pages/account/info/index.tsx index 4d22e896b22d..fa4f638c6264 100644 --- a/projects/app/src/pages/account/info/index.tsx +++ b/projects/app/src/pages/account/info/index.tsx @@ -23,7 +23,6 @@ import Avatar from '@fastgpt/web/components/common/Avatar'; import MyIcon from '@fastgpt/web/components/common/Icon'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import { formatStorePrice2Read } from '@fastgpt/global/support/wallet/usage/tools'; -import { putUpdateMemberName } from '@/web/support/user/team/api'; import { getDocPath } from '@/web/common/system/doc'; import { StandardSubLevelEnum, @@ -77,6 +76,9 @@ const EnterpriseAuthStatusRow = dynamic( const ModelPriceModal = dynamic(() => import('@/components/core/ai/ModelTable').then((mod) => mod.ModelPriceModal) ); +const MemberNameModal = dynamic(() => import('@/pageComponents/account/info/MemberNameModal'), { + ssr: false +}); const Info = () => { const { isPc } = useSystem(); @@ -122,7 +124,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { const theme = useTheme(); const { feConfigs, initd } = useSystemStore(); const { t } = useClientTranslation('account_info'); - const { userInfo, updateUserInfo, teamPlanStatus, initUserInfo } = useUserStore(); + const { userInfo, updateUserInfo, teamPlanStatus } = useUserStore(); const { reset } = useForm({ defaultValues: { avatar: userInfo?.avatar ?? undefined, @@ -150,6 +152,11 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { onClose: onCloseUpdateContact, onOpen: onOpenUpdateContact } = useDisclosure(); + const { + isOpen: isOpenMemberName, + onClose: onCloseMemberName, + onOpen: onOpenMemberName + } = useDisclosure(); const onClickSave = useCallback( async (data: UserType) => { @@ -352,19 +359,16 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { { - const val = e.target.value; - if (val === userInfo?.team?.memberName) return; - try { - await putUpdateMemberName(val); - initUserInfo(); - } catch {} + cursor={isSyncMember ? 'not-allowed' : 'pointer'} + onClick={() => { + if (!isSyncMember) onOpenMemberName(); }} /> @@ -411,6 +415,12 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { )} {isOpenUpdatePsw && } {isOpenUpdateContact && } + {isOpenMemberName && ( + + )} ); }; diff --git a/projects/app/src/pages/account/team/index.tsx b/projects/app/src/pages/account/team/index.tsx index f492c0d5d321..d286c04fa41e 100644 --- a/projects/app/src/pages/account/team/index.tsx +++ b/projects/app/src/pages/account/team/index.tsx @@ -23,9 +23,6 @@ const PermissionManage = dynamic( const AuditLog = dynamic(() => import('@/pageComponents/account/team/Audit/index')); const GroupManage = dynamic(() => import('@/pageComponents/account/team/GroupManage/index')); const OrgManage = dynamic(() => import('@/pageComponents/account/team/OrgManage/index')); -const HandleInviteModal = dynamic( - () => import('@/pageComponents/account/team/Invite/HandleInviteModal') -); export enum TeamTabEnum { member = 'member', @@ -38,15 +35,6 @@ export enum TeamTabEnum { const Team = () => { const router = useRouter(); - const invitelinkid = useMemo(() => { - const _id = router.query.invitelinkid; - if (!_id && typeof _id !== 'string') { - return ''; - } else { - return _id as string; - } - }, [router.query.invitelinkid]); - const { teamTab = TeamTabEnum.member } = router.query as { teamTab: `${TeamTabEnum}` }; const { t } = useClientTranslation(['account', 'account_team', 'user']); @@ -176,7 +164,6 @@ const Team = () => { {teamTab === TeamTabEnum.audit && } - {invitelinkid && } ); }; diff --git a/projects/app/src/pages/login/index.tsx b/projects/app/src/pages/login/index.tsx index 0302d4f7e25d..f2ffe7040da6 100644 --- a/projects/app/src/pages/login/index.tsx +++ b/projects/app/src/pages/login/index.tsx @@ -4,9 +4,6 @@ import { serviceSideProps } from '@/web/common/i18n/utils'; import { clearToken } from '@/web/support/user/auth'; import { useMount } from 'ahooks'; import LoginModal from '@/pageComponents/login/LoginModal'; -import { postAcceptInvitationLink } from '@/web/support/user/team/api'; -import { useToast } from '@fastgpt/web/hooks/useToast'; -import { useTranslation } from 'next-i18next'; import { useUserStore } from '@/web/support/user/useUserStore'; import { subRoute } from '@fastgpt/web/common/system/utils'; import { validateRedirectUrl } from '@/web/common/utils/uri'; @@ -20,8 +17,6 @@ const Login = () => { lastRoute: string; lastTmbId?: string; }; - const { t } = useTranslation(); - const { toast } = useToast(); const { setUserInfo } = useUserStore(); const resolveLoginRedirect = useLoginRedirectAfterLogin(); @@ -30,18 +25,6 @@ const Login = () => { const decodeLastRoute = validateRedirectUrl(lastRoute); const navigateTo = await (async () => { - if (res.user.team.status !== 'active') { - if (decodeLastRoute.includes('/account/team?invitelinkid=')) { - const id = decodeLastRoute.split('invitelinkid=')[1]; - await postAcceptInvitationLink(id); - return '/dashboard/agent'; - } else { - toast({ - status: 'warning', - title: t('common:not_active_team') - }); - } - } if (decodeLastRoute.startsWith(`${subRoute}/config`)) { return '/dashboard/agent'; } @@ -64,7 +47,7 @@ const Login = () => { router.replace(targetRoute); } }, - [lastRoute, lastTmbId, resolveLoginRedirect, router, setUserInfo, t, toast] + [lastRoute, lastTmbId, resolveLoginRedirect, router, setUserInfo] ); useMount(() => { diff --git a/projects/app/src/pages/login/provider.tsx b/projects/app/src/pages/login/provider.tsx index 8198a650e70e..d71e735ecef1 100644 --- a/projects/app/src/pages/login/provider.tsx +++ b/projects/app/src/pages/login/provider.tsx @@ -17,7 +17,6 @@ import { getMsclkid, onFastGPTLoginSuccess } from '@/web/support/marketing/utils'; -import { postAcceptInvitationLink } from '@/web/support/user/team/api'; import { retryFn } from '@fastgpt/global/common/system/utils'; import { validateRedirectUrl } from '@/web/common/utils/uri'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; @@ -51,19 +50,6 @@ const provider = () => { const decodeLastRoute = validateRedirectUrl(lastRoute); const navigateTo = await (async () => { - if (res.user.team.status !== 'active') { - if (decodeLastRoute.includes('/account/team?invitelinkid=')) { - const id = decodeLastRoute.split('invitelinkid=')[1]; - await postAcceptInvitationLink(id); - return '/dashboard/agent'; - } else { - toast({ - status: 'warning', - title: t('common:not_active_team') - }); - } - } - return decodeLastRoute; })(); @@ -82,7 +68,7 @@ const provider = () => { router.replace(targetRoute); } }, - [lastRoute, lastTmbId, resolveLoginRedirect, router, setUserInfo, t, toast] + [lastRoute, lastTmbId, resolveLoginRedirect, router, setUserInfo] ); const authProps = useCallback( diff --git a/projects/app/src/web/support/user/inform/api.ts b/projects/app/src/web/support/user/inform/api.ts index f699ab90bd61..c60d34077043 100644 --- a/projects/app/src/web/support/user/inform/api.ts +++ b/projects/app/src/web/support/user/inform/api.ts @@ -1,5 +1,6 @@ import { GET, POST } from '@/web/common/api/request'; import type { UserInformType } from '@fastgpt/global/support/user/inform/type'; +import type { GetUnreadInformResponseType } from '@fastgpt/global/openapi/support/user/inform/api'; import type { SystemMsgModalValueType } from '@fastgpt/global/openapi/admin/support/user/inform/api'; import type { PaginationProps, PaginationResponse } from '@fastgpt/global/openapi/api'; @@ -7,10 +8,7 @@ export const getInforms = (data: PaginationProps) => POST>(`/proApi/support/user/inform/list`, data); export const getUnreadCount = () => - GET<{ - unReadCount: number; - importantInforms: UserInformType[]; - }>(`/proApi/support/user/inform/countUnread`); + GET('/proApi/support/user/inform/countUnread'); export const readInform = (id: string) => GET(`/proApi/support/user/inform/read`, { id }); export const getSystemMsgModalData = () => diff --git a/projects/app/src/web/support/user/loginRedirect/invitation.ts b/projects/app/src/web/support/user/loginRedirect/invitation.ts new file mode 100644 index 000000000000..84da84cefab1 --- /dev/null +++ b/projects/app/src/web/support/user/loginRedirect/invitation.ts @@ -0,0 +1,18 @@ +const INVITATION_QUERY_KEYS = ['invitelinkid', 'inviteLinkId'] as const; + +/** 从登录回跳地址中兼容解析历史和新邀请参数。 */ +export const getInviteLinkIdFromRoute = (route: string) => { + try { + const url = new URL(route, 'http://fastgpt.local'); + return INVITATION_QUERY_KEYS.map((key) => url.searchParams.get(key)).find(Boolean) || ''; + } catch { + return ''; + } +}; + +/** 仅清除邀请参数,保留其他 query、路径和 hash。 */ +export const clearInviteLinkFromRoute = (route: string) => { + const url = new URL(route, 'http://fastgpt.local'); + INVITATION_QUERY_KEYS.forEach((key) => url.searchParams.delete(key)); + return `${url.pathname}${url.search}${url.hash}`; +}; diff --git a/projects/app/src/web/support/user/team/api.ts b/projects/app/src/web/support/user/team/api.ts index 383ae4fa60e3..c2d4885abe0e 100644 --- a/projects/app/src/web/support/user/team/api.ts +++ b/projects/app/src/web/support/user/team/api.ts @@ -77,11 +77,14 @@ export const postCreateInvitationLink = (data: InvitationLinkCreateType) => export const getInvitationLinkList = () => GET(`/proApi/support/user/team/invitationLink/list`); -export const postAcceptInvitationLink = (linkId: string) => - POST(`/proApi/support/user/team/invitationLink/accept`, { linkId }); +export const postAcceptInvitationWithMemberName = (data: { linkId: string; memberName: string }) => + POST<{ teamId: string; tmbId: string }>( + `/proApi/support/user/team/invitationLink/acceptWithMemberName`, + data + ); export const getInvitationInfo = (linkId: string) => - GET(`/proApi/support/user/team/invitationLink/info`, { linkId }); + GET(`/proApi/support/user/team/invitationLink/info`, { linkId }); export const putForbidInvitationLink = (linkId: string) => PUT(`/proApi/support/user/team/invitationLink/forbid`, { linkId }); diff --git a/projects/app/src/web/support/user/useUserStore.ts b/projects/app/src/web/support/user/useUserStore.ts index fda21fc793b0..997d8b4328b9 100644 --- a/projects/app/src/web/support/user/useUserStore.ts +++ b/projects/app/src/web/support/user/useUserStore.ts @@ -14,9 +14,6 @@ type State = { enterpriseAuthNoticeReadTeamIds: string[]; setEnterpriseAuthNoticeRead: (teamId: string) => void; - isUpdateNotification: boolean; - setIsUpdateNotification: (val: boolean) => void; - userInfo: UserType | null; isTeamAdmin: boolean; initUserInfo: () => Promise; @@ -49,13 +46,6 @@ export const useUserStore = create()( }); }, - isUpdateNotification: true, - setIsUpdateNotification(val: boolean) { - set((state) => { - state.isUpdateNotification = val; - }); - }, - userInfo: null, isTeamAdmin: false, async initUserInfo() { @@ -119,8 +109,7 @@ export const useUserStore = create()( name: 'userStore', partialize: (state) => ({ systemMsgReadId: state.systemMsgReadId, - enterpriseAuthNoticeReadTeamIds: state.enterpriseAuthNoticeReadTeamIds, - isUpdateNotification: state.isUpdateNotification + enterpriseAuthNoticeReadTeamIds: state.enterpriseAuthNoticeReadTeamIds }) } ) diff --git a/projects/app/test/components/Layout/postLoginAction.test.ts b/projects/app/test/components/Layout/postLoginAction.test.ts new file mode 100644 index 000000000000..44d6a2a90746 --- /dev/null +++ b/projects/app/test/components/Layout/postLoginAction.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from 'vitest'; +import { + finishPostLoginAction, + getNextPostLoginAction, + isPostLoginActionRoute, + startPostLoginAction, + type OneTimePostLoginAction, + type PostLoginAction, + type PostLoginActionState +} from '@/components/Layout/postLoginAction'; + +const getAction = ( + completed: OneTimePostLoginAction[] = [], + hasImportantInform = true, + currentAction?: PostLoginAction +) => + getNextPostLoginAction({ + canStart: true, + currentAction, + completed: new Set(completed), + inviteLinkId: 'invite-1', + hasPendingMemberName: true, + shouldShowContact: true, + contactHandled: false, + isPlus: true, + hasImportantInform + }); + +describe('post login action order', () => { + it('returns actions in the documented order', () => { + expect(getAction()).toBe('invitation'); + expect(getAction(['invitation'])).toBe('memberName'); + expect(getAction(['invitation', 'memberName'])).toBe('resetExpiredPassword'); + expect(getAction(['invitation', 'memberName', 'resetExpiredPassword'])).toBe('contact'); + expect(getAction(['invitation', 'memberName', 'resetExpiredPassword', 'contact'])).toBe( + 'systemMessage' + ); + expect( + getAction(['invitation', 'memberName', 'resetExpiredPassword', 'contact', 'systemMessage']) + ).toBe('importantInform'); + expect( + getAction( + ['invitation', 'memberName', 'resetExpiredPassword', 'contact', 'systemMessage'], + false + ) + ).toBe('activityAd'); + expect( + getAction( + [ + 'invitation', + 'memberName', + 'resetExpiredPassword', + 'contact', + 'systemMessage', + 'activityAd' + ], + false + ) + ).toBe('enterpriseAuthNotice'); + }); + + it('skips contact after it has been handled and returns no action when all candidates are complete', () => { + expect( + getNextPostLoginAction({ + canStart: true, + completed: new Set([ + 'memberName', + 'resetExpiredPassword', + 'contact', + 'systemMessage', + 'activityAd', + 'enterpriseAuthNotice' + ]), + inviteLinkId: '', + hasPendingMemberName: false, + shouldShowContact: true, + contactHandled: true, + isPlus: true, + hasImportantInform: false + }) + ).toBeUndefined(); + + expect( + getNextPostLoginAction({ + canStart: true, + completed: new Set([ + 'memberName', + 'resetExpiredPassword', + 'systemMessage', + 'activityAd', + 'enterpriseAuthNotice' + ]), + inviteLinkId: '', + hasPendingMemberName: false, + shouldShowContact: true, + contactHandled: true, + isPlus: true, + hasImportantInform: false + }) + ).toBeUndefined(); + }); + + it('does not let a current action bypass the startup guard', () => { + expect( + getNextPostLoginAction({ + canStart: false, + currentAction: 'activityAd', + completed: new Set(), + inviteLinkId: '', + hasPendingMemberName: false, + shouldShowContact: false, + contactHandled: false, + isPlus: true, + hasImportantInform: false + }) + ).toBeUndefined(); + }); + + it('supports non-plus member-name actions without adding plus-only actions', () => { + expect( + getNextPostLoginAction({ + canStart: true, + completed: new Set(), + inviteLinkId: '', + hasPendingMemberName: true, + shouldShowContact: false, + contactHandled: false, + isPlus: false, + hasImportantInform: true + }) + ).toBe('memberName'); + }); + it('skips actions completed after a failed or dismissed attempt', () => { + expect(getAction(['invitation', 'memberName', 'resetExpiredPassword', 'contact'])).toBe( + 'systemMessage' + ); + expect( + getNextPostLoginAction({ + canStart: true, + completed: new Set([ + 'invitation', + 'memberName', + 'resetExpiredPassword', + 'contact', + 'systemMessage', + 'activityAd' + ]), + inviteLinkId: '', + hasPendingMemberName: false, + shouldShowContact: false, + contactHandled: true, + isPlus: true, + hasImportantInform: false + }) + ).toBe('enterpriseAuthNotice'); + }); + + it('keeps the current action ahead of newly arrived notifications', () => { + expect(getAction([], true, 'activityAd')).toBe('activityAd'); + }); + + it('can enqueue important notifications again after the first login flow', () => { + const completed: OneTimePostLoginAction[] = [ + 'invitation', + 'memberName', + 'resetExpiredPassword', + 'contact', + 'systemMessage', + 'activityAd', + 'enterpriseAuthNotice' + ]; + + expect(getAction(completed, true)).toBe('importantInform'); + expect(getAction(completed, false)).toBeUndefined(); + }); + + it('does not start before the derived startup conditions are ready', () => { + expect( + getNextPostLoginAction({ + canStart: false, + completed: new Set(), + inviteLinkId: 'invite-1', + hasPendingMemberName: true, + shouldShowContact: true, + contactHandled: false, + isPlus: true, + hasImportantInform: true + }) + ).toBeUndefined(); + }); + + it('recognizes every route excluded from post-login actions', () => { + expect( + [ + '/', + '/login', + '/login/provider', + '/login/fastlogin', + '/login/sso', + '/appStore', + '/account/cancel', + '/chat', + '/chat/share', + '/tools/price', + '/price', + '/logout' + ].every((pathname) => !isPostLoginActionRoute(pathname)) + ).toBe(true); + expect(isPostLoginActionRoute('/dashboard/agent')).toBe(true); + }); + it('does not start on the account cancellation page', () => { + expect(isPostLoginActionRoute('/account/cancel')).toBe(false); + expect( + getNextPostLoginAction({ + canStart: isPostLoginActionRoute('/account/cancel'), + completed: new Set(), + inviteLinkId: 'invite-1', + hasPendingMemberName: true, + shouldShowContact: true, + contactHandled: false, + isPlus: true, + hasImportantInform: true + }) + ).toBeUndefined(); + }); +}); + +describe('post login action state', () => { + const initialState: PostLoginActionState = { + key: 'user-1:team-1', + completed: new Set(['memberName']) + }; + + it('locks one current action and preserves it for the same user and team', () => { + const started = startPostLoginAction({ + state: initialState, + key: 'user-1:team-1', + action: 'activityAd' + }); + + expect(started.currentAction).toBe('activityAd'); + expect( + startPostLoginAction({ + state: started, + key: 'user-1:team-1', + action: 'importantInform' + }) + ).toBe(started); + }); + + it('resets completed actions when the user or team key changes', () => { + const started = startPostLoginAction({ + state: initialState, + key: 'user-2:team-2', + action: 'importantInform' + }); + + expect(started).toEqual({ + key: 'user-2:team-2', + completed: new Set(), + currentAction: 'importantInform' + }); + }); + + it('releases important notifications without marking them permanently complete', () => { + const started = startPostLoginAction({ + state: initialState, + key: 'user-1:team-1', + action: 'importantInform' + }); + + expect( + finishPostLoginAction({ + state: started, + key: 'user-1:team-1', + action: 'importantInform' + }) + ).toEqual(initialState); + }); + + it('does not release an action when the current action or key does not match', () => { + const started = startPostLoginAction({ + state: initialState, + key: 'user-1:team-1', + action: 'activityAd' + }); + + expect( + finishPostLoginAction({ + state: started, + key: 'user-1:team-1', + action: 'systemMessage' + }) + ).toBe(started); + expect( + finishPostLoginAction({ + state: started, + key: 'user-2:team-2', + action: 'activityAd' + }) + ).toBe(started); + }); + it('marks one-time actions complete when releasing them', () => { + const started = startPostLoginAction({ + state: initialState, + key: 'user-1:team-1', + action: 'activityAd' + }); + + expect( + finishPostLoginAction({ + state: started, + key: 'user-1:team-1', + action: 'activityAd' + }) + ).toEqual({ + key: 'user-1:team-1', + completed: new Set(['memberName', 'activityAd']), + currentAction: undefined + }); + }); + + it('ignores stale completion callbacks from another user or team', () => { + const started = startPostLoginAction({ + state: initialState, + key: 'user-1:team-1', + action: 'activityAd' + }); + + expect( + finishPostLoginAction({ + state: started, + key: 'user-2:team-2', + action: 'activityAd' + }) + ).toBe(started); + }); +}); diff --git a/projects/app/test/web/support/user/loginRedirect.test.ts b/projects/app/test/web/support/user/loginRedirect.test.ts index 202c2b81b53f..4a6da617dacf 100644 --- a/projects/app/test/web/support/user/loginRedirect.test.ts +++ b/projects/app/test/web/support/user/loginRedirect.test.ts @@ -7,6 +7,10 @@ import { import { restoreWorkflowLocalDraftAfterLogin } from '../../../../src/web/core/workflow/localDraft/useWorkflowLocalDraftRestore'; import { setCurrentAuthTmbId } from '../../../../src/web/support/user/currentAuthTmbId'; import { getAuthLoginRedirectPath } from '../../../../src/web/support/user/loginRedirect/url'; +import { + clearInviteLinkFromRoute, + getInviteLinkIdFromRoute +} from '../../../../src/web/support/user/loginRedirect/invitation'; import type { UserType } from '@fastgpt/global/support/user/type'; vi.mock('@/web/core/app/api/version', () => ({ @@ -205,6 +209,20 @@ describe('login redirect helpers', () => { expect(route).toBe('/app/detail?appId=app-1'); }); + it('should parse both invitation query spellings and clear only invitation context', () => { + const route = '/account/team?foo=bar&inviteLinkId=invite-1#members'; + + expect(getInviteLinkIdFromRoute(route)).toBe('invite-1'); + expect(clearInviteLinkFromRoute(route)).toBe('/account/team?foo=bar#members'); + expect(getInviteLinkIdFromRoute('/account/team?invitelinkid=invite-2')).toBe('invite-2'); + expect( + getInviteLinkIdFromRoute('https://fastgpt.example.com/account/team?inviteLinkId=invite-3') + ).toBe('invite-3'); + expect( + clearInviteLinkFromRoute('/account/team?invitelinkid=invite-2&inviteLinkId=invite-3') + ).toBe('/account/team'); + }); + it('should restore draft even when login fallback route is not the workflow detail page', async () => { saveDraftToStorage(); const saveDraft = vi.fn().mockResolvedValue(undefined);