diff --git a/frontend/src/api/organization/index.ts b/frontend/src/api/organization/index.ts index e73ba1957..664053591 100644 --- a/frontend/src/api/organization/index.ts +++ b/frontend/src/api/organization/index.ts @@ -34,14 +34,29 @@ export interface Organization { updated_at: string } +/** + * OrganizationMember represents one row in the organization's per-tenant + * member list (Plan 3 / migration 000045). Each row is a (org, tenant) + * tuple; the user fields describe the *representative* user attached to + * that row for display/audit, not the member identity itself. + * + * - `tenant_id` + `tenant_name` are the canonical member identity. + * - `representative_user_id` is the post-Plan-3 explicit alias; `user_id` + * is kept for backward compatibility and points at the same value. + * - Two users belonging to the *same* tenant produce a single row here + * (UNIQUE(org_id, tenant_id)); the rep is the user who first brought + * the tenant in. + */ export interface OrganizationMember { id: string user_id: string + representative_user_id?: string username: string email: string avatar?: string role: 'admin' | 'editor' | 'viewer' tenant_id: number + tenant_name?: string joined_at: string } @@ -221,9 +236,19 @@ export interface RequestRoleUpgradeRequest { message?: string // Optional message explaining the reason } +/** + * InviteMemberRequest enrols a *tenant* into the organization (Plan 3). + * - `tenant_id` is the preferred identity for the invitee. + * - `representative_user_id` is optional metadata for the OTM row's + * display/audit; when omitted the server picks a sensible default. + * - `user_id` is kept for backward compatibility with pre-Plan-3 callers + * (the server resolves the user's tenant if `tenant_id` is unset). + */ export interface InviteMemberRequest { - user_id: string // User ID to invite - role: 'admin' | 'editor' | 'viewer' // Role to assign + tenant_id?: number + representative_user_id?: string + user_id?: string + role: 'admin' | 'editor' | 'viewer' } export interface UserSearchResult { @@ -233,6 +258,20 @@ export interface UserSearchResult { avatar?: string } +/** + * TenantInviteCandidate is one row in the search-tenants-for-invite picker. + * The picker is tenant-centric; the representative_* fields describe the + * user that caused the tenant to appear in the search (for display only). + */ +export interface TenantInviteCandidate { + tenant_id: number + tenant_name: string + representative_user_id: string + representative_username: string + representative_email: string + representative_avatar?: string +} + export interface ListSharesResponse { shares: KnowledgeBaseShare[] total: number @@ -696,19 +735,34 @@ export async function listOrgAgentShares(orgId: string): Promise> { + try { + const response = await get(`/api/v1/organizations/${orgId}/search-tenants?q=${encodeURIComponent(query)}&limit=${limit}`) + return response as unknown as ApiResponse + } catch (error: any) { + return { success: false, message: error.message || 'Failed to search tenants' } + } +} + +/** + * @deprecated Use `searchTenantsForInvite`. Kept for callers that haven't + * migrated yet; the backend serves the tenant-grouped shape from the + * legacy `/search-users` path as well. */ export async function searchUsersForInvite( orgId: string, query: string, limit: number = 10 -): Promise> { - try { - const response = await get(`/api/v1/organizations/${orgId}/search-users?q=${encodeURIComponent(query)}&limit=${limit}`) - return response as unknown as ApiResponse - } catch (error: any) { - return { success: false, message: error.message || 'Failed to search users' } - } +): Promise> { + return searchTenantsForInvite(orgId, query, limit) } /** diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 4f26f39d4..7b03ce1d1 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -3398,7 +3398,7 @@ export default { editTitle: 'Space Settings', detailTitle: 'Space Details', myRoleDesc: 'Your role in this space determines your permissions', - membersDesc: 'View and manage space members, adjust member roles', + membersDesc: 'View and manage space members and their roles. Each member represents a tenant — all users in the same tenant share access to this space.', sharedDesc: 'View all knowledge bases shared to this space', noSharedKB: 'No shared knowledge bases yet', noSharedKBTip: 'Knowledge base owners can share their knowledge bases to this space in KB settings', @@ -3501,9 +3501,13 @@ export default { button: 'Add Member', dialogTitle: 'Add Member', tip: 'Added users will immediately become space members and can access shared knowledge bases.', + tipTenant: 'Membership is at the tenant level: once a tenant joins, all of its users share access to this space. Results below are deduplicated by tenant.', searchUser: 'Select User', + searchTenant: 'Select Tenant', searchPlaceholder: 'Search by username or email...', + searchTenantPlaceholder: 'Search by tenant name, username or email...', searchHint: 'Type at least 2 characters to search', + searchTenantHint: 'Type at least 2 characters; results are deduplicated by tenant and exclude tenants already in this space', selectRole: 'Assign Role', confirmBtn: 'Add', success: 'Member added successfully', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index a19bd8a0e..524657afe 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -3448,7 +3448,7 @@ export default { editTitle: "스페이스 설정", detailTitle: "스페이스 세부정보", myRoleDesc: "이 스페이스에서의 귀하의 역할은 귀하의 권한 범위를 결정합니다.", - membersDesc: "스페이스 구성원을 보고 관리하며 구성원 역할을 조정합니다.", + membersDesc: "스페이스 구성원과 역할을 조회·관리합니다. 각 구성원은 하나의 테넌트를 의미하며, 같은 테넌트의 모든 사용자가 이 스페이스 접근 권한을 공유합니다.", sharedDesc: "이 스페이스에 공유된 모든 지식베이스 보기", noSharedKB: "아직 공유 지식베이스가 없습니다.", noSharedKBTip: @@ -3562,9 +3562,13 @@ export default { button: "회원 추가", dialogTitle: "회원 추가", tip: "추가된 사용자는 즉시 스페이스 구성원이 되며, 스페이스에 공유된 지식베이스에 접근할 수 있습니다.", + tipTenant: "구성원의 최소 단위는 테넌트입니다: 한 테넌트가 가입하면 해당 테넌트의 모든 사용자가 이 스페이스 접근 권한을 공유합니다. 아래 결과는 테넌트 기준으로 중복 제거됩니다.", searchUser: "사용자 선택", + searchTenant: "테넌트 선택", searchPlaceholder: "검색하려면 사용자 이름이나 이메일을 입력하세요..", + searchTenantPlaceholder: "테넌트 이름, 사용자 이름 또는 이메일로 검색...", searchHint: "검색을 시작하려면 2자 이상 입력하세요.", + searchTenantHint: "2자 이상 입력하세요. 결과는 테넌트 기준으로 중복 제거되며 이미 가입한 테넌트는 제외됩니다.", selectRole: "역할 할당", confirmBtn: "추가", success: "회원이 추가되었습니다.", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 27c1de61c..84de400f8 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -3924,7 +3924,7 @@ export default { editTitle: 'Space Settings', detailTitle: 'Space Details', myRoleDesc: 'Your role in this space determines your permissions', - membersDesc: 'View and manage space members, adjust member roles', + membersDesc: 'Просмотр и управление участниками пространства и их ролями. Каждый участник — это один тенант: все пользователи внутри тенанта получают одинаковый доступ к пространству.', sharedDesc: 'View all knowledge bases shared to this space', noSharedKB: 'No shared knowledge bases yet', noSharedKBTip: 'Knowledge base owners can share their knowledge bases to this space in KB settings', @@ -4027,9 +4027,13 @@ export default { button: 'Add Member', dialogTitle: 'Add Member', tip: 'Added users will immediately become space members and can access shared knowledge bases.', + tipTenant: 'Членство задаётся на уровне тенанта: после присоединения тенанта все его пользователи получают доступ к этому пространству. Результаты ниже сгруппированы по тенанту.', searchUser: 'Select User', + searchTenant: 'Выбрать тенант', searchPlaceholder: 'Search by username or email...', + searchTenantPlaceholder: 'Поиск по имени тенанта, пользователя или email...', searchHint: 'Type at least 2 characters to search', + searchTenantHint: 'Введите не менее 2 символов; результаты сгруппированы по тенанту и не включают уже добавленных.', selectRole: 'Assign Role', confirmBtn: 'Add', success: 'Member added successfully', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 2c44535bf..5375b0c83 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3297,7 +3297,7 @@ export default { confirmTitle: "确认加入空间", confirm: "确认加入", preview: "预览并加入", - memberCount: "{count} 名成员", + memberCount: "{count} 个成员", shareCount: "{count} 个共享知识库", agentShareCount: "{count} 个智能体", alreadyMember: "您已经是该空间的成员", @@ -3393,7 +3393,7 @@ export default { editTitle: "空间设置", detailTitle: "空间详情", myRoleDesc: "您在此空间中的角色决定了您的权限范围", - membersDesc: "查看和管理空间成员,调整成员角色", + membersDesc: "查看和管理空间成员、调整成员角色。每个成员对应一个租户,同一租户下的所有用户共享该空间的访问权限。", sharedDesc: "查看共享到此空间的所有知识库", noSharedKB: "暂无共享的知识库", noSharedKBTip: "知识库拥有者可以在知识库设置中将其共享到此空间", @@ -3428,7 +3428,7 @@ export default { requireApprovalDesc: "开启后,新成员加入需要管理员审核", searchable: "开放可被搜索", searchableDesc: "开启后,空间将出现在「加入空间」的搜索列表中,他人可搜索并申请加入,无需邀请码", - memberLimit: "成员人数上限", + memberLimit: "成员数量上限", memberLimitDesc: "超过上限后无法再添加新成员;设为 0 表示不限制", memberLimitPlaceholder: "0 表示不限制", memberLimitHint: "当前成员数:{count}", @@ -3496,9 +3496,13 @@ export default { button: "添加成员", dialogTitle: "添加成员", tip: "添加的用户将立即成为空间成员,可以访问空间内共享的知识库。", + tipTenant: "空间成员的最小单位是租户:选定一个租户后,其下全部用户都将共享该空间的访问权限。下方搜索结果按租户去重。", searchUser: "选择用户", + searchTenant: "选择租户", searchPlaceholder: "输入用户名或邮箱搜索...", + searchTenantPlaceholder: "输入租户名、用户名或邮箱搜索...", searchHint: "输入至少2个字符开始搜索", + searchTenantHint: "输入至少2个字符开始搜索,按租户去重并自动过滤已加入的租户", selectRole: "分配角色", confirmBtn: "添加", success: "成员添加成功", diff --git a/frontend/src/views/organization/OrganizationSettingsModal.vue b/frontend/src/views/organization/OrganizationSettingsModal.vue index 44ed185db..0163cd1b9 100644 --- a/frontend/src/views/organization/OrganizationSettingsModal.vue +++ b/frontend/src/views/organization/OrganizationSettingsModal.vue @@ -6,7 +6,7 @@ @@ -17,19 +17,17 @@
-
- +
+ {{ item.label }} {{ item.badge }} + :class="['nav-item-badge', { 'nav-item-badge-count': item.key === 'sharedKb' || item.key === 'sharedAgents' }]">{{ + item.badge }}
@@ -48,7 +46,7 @@

{{ $t('organization.editor.basicTitle') }}

{{ $t('organization.editor.basicDesc') }}

- +
@@ -58,13 +56,8 @@
- +
{{ $t('organization.avatar') }} @@ -73,35 +66,22 @@

{{ $t('organization.avatarPickerHint') }}

-
- + {{ $t('organization.avatarClear') }}
- +
@@ -113,13 +93,9 @@

{{ $t('organization.editor.descriptionTip') }}

- + :autosize="{ minRows: 3, maxRows: 6 }" :maxlength="500" :disabled="!isAdmin" />
@@ -146,7 +122,8 @@ - + @@ -154,28 +131,24 @@

{{ remainingValidityText }}

- +
- +
- {{ $t('organization.settings.inviteLinkValidity') }} + {{ $t('organization.settings.inviteLinkValidity') + }}

{{ $t('organization.settings.inviteLinkValidityDesc') }}

- +
- +
- +
@@ -191,9 +164,9 @@
- +
- +
@@ -201,16 +174,13 @@ {{ $t('organization.settings.requireApproval') }}
- + {{ $t('organization.settings.requireApprovalDesc') }}
- +
- +
@@ -218,16 +188,13 @@ {{ $t('organization.settings.searchable') }}
- + {{ $t('organization.settings.searchableDesc') }}
- +
- +
@@ -236,15 +203,14 @@

{{ $t('organization.settings.memberLimitDesc') }}

- - {{ $t('organization.settings.memberLimitHint', { count: orgInfo?.member_count ?? 0 }) }} + + {{ $t('organization.settings.memberLimitHint', { + count: + orgInfo?.member_count + ?? 0 + }) }}
@@ -276,11 +242,20 @@ {{ $t('common.me') }}
- {{ $t('organization.editor.viewerPerm1') }} - {{ $t('organization.editor.editorPerm1') }} - {{ $t('organization.editor.useSharedAgentsPerm') }} - {{ $t('organization.editor.shareKBPerm') }} - {{ $t('organization.editor.adminPerm1') }} + {{ + $t('organization.editor.viewerPerm1') + }} + {{ + $t('organization.editor.editorPerm1') + }} + {{ + $t('organization.editor.useSharedAgentsPerm') }} + {{ + $t('organization.editor.shareKBPerm') + }} + {{ + $t('organization.editor.adminPerm1') + }}
@@ -290,11 +265,20 @@ {{ $t('common.me') }}
- {{ $t('organization.editor.viewerPerm1') }} - {{ $t('organization.editor.editorPerm1') }} - {{ $t('organization.editor.useSharedAgentsPerm') }} - {{ $t('organization.editor.shareKBPerm') }} - {{ $t('organization.editor.adminPerm1') }} + {{ + $t('organization.editor.viewerPerm1') + }} + {{ + $t('organization.editor.editorPerm1') + }} + {{ + $t('organization.editor.useSharedAgentsPerm') }} + {{ + $t('organization.editor.shareKBPerm') + }} + {{ + $t('organization.editor.adminPerm1') + }}
@@ -304,24 +288,30 @@ {{ $t('common.me') }}
- {{ $t('organization.editor.viewerPerm1') }} - {{ $t('organization.editor.editorPerm1') }} - {{ $t('organization.editor.useSharedAgentsPerm') }} - {{ $t('organization.editor.shareKBPerm') }} - {{ $t('organization.editor.adminPerm1') }} + {{ + $t('organization.editor.viewerPerm1') + }} + {{ + $t('organization.editor.editorPerm1') + }} + {{ + $t('organization.editor.useSharedAgentsPerm') }} + {{ + $t('organization.editor.shareKBPerm') + }} + {{ + $t('organization.editor.adminPerm1') + }}
- + - {{ hasPendingUpgrade ? $t('organization.upgrade.pending') : $t('organization.upgrade.requestUpgrade') }} + {{ hasPendingUpgrade ? $t('organization.upgrade.pending') : + $t('organization.upgrade.requestUpgrade') }}
@@ -329,69 +319,47 @@
- + {{ $t('organization.addMember.button') }}
- +
-
+
- {{ member.username }} - {{ $t('common.me') }} + {{ memberPrimaryLabel(member) }} + {{ $t('common.me') + }} - {{ member.email }} + {{ memberSecondaryLabel(member) }}
- + {{ $t(`organization.role.${member.role}`) }} ({{ $t('organization.owner') }})
- +
@@ -420,11 +388,7 @@

{{ $t('organization.settings.noPendingRequests') }}

-
+
@@ -432,38 +396,34 @@
{{ req.username || req.email || req.user_id }} - + {{ $t('organization.upgrade.upgradeRequest') }} {{ req.email }}

{{ req.message }}

- {{ $t('organization.upgrade.currentRole') }}:{{ roleLabel(req.prev_role) }} → {{ roleLabel(req.requested_role) }} + {{ $t('organization.upgrade.currentRole') }}:{{ roleLabel(req.prev_role) }} → {{ + roleLabel(req.requested_role) }} - {{ $t('organization.invite.requestRole') }}:{{ roleLabel(req.requested_role) }} + {{ $t('organization.invite.requestRole') }}:{{ + roleLabel(req.requested_role) }} {{ formatDate(req.created_at) }}
{{ $t('organization.settings.assignRole') }} - +
- + {{ $t('organization.settings.approve') }} - + {{ $t('organization.settings.reject') }}
@@ -497,12 +457,8 @@

{{ $t('organization.settings.noSharedKBTip') }}

-
+
@@ -521,31 +477,32 @@
- - {{ $t('organization.settings.sharePermissionLabel') }}: {{ (share.permission === 'editor' || share.permission === 'admin') ? $t('organization.share.permissionEditable') : $t('organization.share.permissionReadonly') }} + + {{ $t('organization.settings.sharePermissionLabel') }}: {{ (share.permission === + 'editor' || + share.permission === 'admin') ? $t('organization.share.permissionEditable') : + $t('organization.share.permissionReadonly') }} - - {{ $t('organization.settings.myPermissionLabel') }}: {{ ((share.my_permission ?? share.permission) === 'editor' || (share.my_permission ?? share.permission) === 'admin') ? $t('organization.share.permissionEditable') : $t('organization.share.permissionReadonly') }} + + {{ $t('organization.settings.myPermissionLabel') }}: {{ ((share.my_permission ?? + share.permission) === + 'editor' || (share.my_permission ?? share.permission) === 'admin') ? + $t('organization.share.permissionEditable') : + $t('organization.share.permissionReadonly') }}
- + :cancel-btn="{ content: $t('common.cancel') }" @confirm="handleRemoveShare(share)"> - + @@ -562,7 +519,8 @@

{{ $t('organization.settings.sharedAgents') }}

{{ $t('organization.settings.sharedAgentsDesc') }}

- + {{ $t('organization.settings.sharedAgentsKbHintShort') }} @@ -579,26 +537,27 @@

{{ $t('organization.settings.noSharedAgentsTip') }}

-
+
{{ share.agent_name || share.agent_id }}
- {{ share.shared_by_username }} - {{ formatDate(share.created_at) }} + {{ + share.shared_by_username }} + {{ formatDate(share.created_at) + }}
- - + +
@@ -610,13 +569,11 @@ -
+
-
{{ agentScopePopover.share.agent_name || agentScopePopover.share.agent_id }}
+
{{ agentScopePopover.share.agent_name || + agentScopePopover.share.agent_id + }}
{{ agentScopePopover.share.shared_by_username }} @@ -632,7 +589,8 @@
@@ -642,12 +600,7 @@ @@ -658,25 +611,15 @@ - +

{{ $t('organization.detail.removeMemberConfirm', { name: removingMember?.username }) }}

- +

{{ $t('organization.upgrade.currentRole') }}: @@ -686,51 +629,36 @@

- +
- +
- - + +
-

{{ $t('organization.addMember.tip') }}

- +

{{ $t('organization.addMember.tipTenant') }}

+
- - -

{{ $t('organization.addMember.searchHint') }}

+ + +

{{ $t('organization.addMember.searchTenantHint') }}

- +
@@ -756,13 +684,14 @@ import { removeShare, removeAgentShare, requestRoleUpgrade, - searchUsersForInvite, + searchTenantsForInvite, inviteMember, type Organization, type OrganizationMember, type KnowledgeBaseShare, type AgentShareResponse, - type JoinRequestResponse + type JoinRequestResponse, + type TenantInviteCandidate } from '@/api/organization' import { useOrganizationStore } from '@/stores/organization' import { useAuthStore } from '@/stores/auth' @@ -818,12 +747,14 @@ const upgradeForm = ref({ message: '' }) -// 添加成员相关状态 +// 添加成员(按租户邀请)相关状态。Plan 3 之后,邀请实际上是把 +// 一整个租户拉进空间;这里的「搜索结果」是租户候选列表,每条带一个 +// 代表用户用于展示。`selectedTenantId` 是真正提交给后端的 tenant_id。 const showAddMemberDialog = ref(false) const addMemberSubmitting = ref(false) -const userSearchLoading = ref(false) -const userSearchResults = ref<{ id: string; username: string; email: string; avatar?: string }[]>([]) -const selectedUser = ref('') +const tenantSearchLoading = ref(false) +const tenantSearchResults = ref([]) +const selectedTenantId = ref(null) const addMemberRole = ref<'admin' | 'editor' | 'viewer'>('viewer') const formData = ref({ @@ -910,12 +841,19 @@ const addMemberRoleOptions = computed(() => [ { label: t('organization.role.admin'), value: 'admin' }, ]) -// 用户搜索选项 -const userSearchOptions = computed(() => - userSearchResults.value.map(user => ({ - label: `${user.username} · ${user.email}`, - value: user.id, - })) +// 租户搜索结果选项。主标签展示租户名,括号里附带代表用户名(不再展示 +// 邮箱、不带"代表:"前缀,避免冗长和译文别扭);租户名缺失时回退到 +// 代表用户名 / 租户 ID。 +const tenantSearchOptions = computed(() => + tenantSearchResults.value.map((c) => { + const tenantLabel = c.tenant_name || c.representative_username || `tenant#${c.tenant_id}` + const showsTenantName = !!c.tenant_name + const label = + showsTenantName && c.representative_username + ? `${tenantLabel}(${c.representative_username})` + : tenantLabel + return { label, value: c.tenant_id } + }) ) const modalTitle = computed(() => { @@ -964,12 +902,30 @@ const roleOptions = computed(() => [ const filteredMembers = computed(() => { const query = memberSearchQuery.value.toLowerCase() if (!query) return members.value - return members.value.filter(m => - m.username.toLowerCase().includes(query) || - m.email.toLowerCase().includes(query) + return members.value.filter((m) => + (m.tenant_name || '').toLowerCase().includes(query) || + (m.username || '').toLowerCase().includes(query) || + (m.email || '').toLowerCase().includes(query) ) }) +// 成员行的主标题:优先展示「租户名」,回退到代表用户名 / 租户 ID。Plan 3 +// 之后每一行成员都对应一个租户,UI 必须先于代表用户呈现租户身份, +// 否则用户会误以为这是按"人"加进来的。 +const memberPrimaryLabel = (m: OrganizationMember): string => { + return m.tenant_name || m.username || `tenant#${m.tenant_id}` +} + +// 副标题:主标题展示的是租户名时,副标题展示代表用户名;如果主标题已经 +// 是用户名(无 tenant_name 时的回退),副标题留空,避免重复信息。 +// 邮箱在租户成员列表里没什么用(不是邀请人需要联系的对象),不展示。 +const memberSecondaryLabel = (m: OrganizationMember): string => { + if (m.tenant_name && m.username) { + return m.username + } + return '' +} + // Owner identification is tenant-keyed after Plan 3 (#1303): the org's // pinned owner_tenant_id (migration 000046) is the authority on which // row in the per-tenant members list represents the owner. Falling @@ -1229,7 +1185,7 @@ const handleRemoveMember = (member: OrganizationMember) => { const confirmRemoveMember = async () => { if (!removingMember.value || !props.orgId) return - + try { const res = await removeMember(props.orgId, removingMember.value.tenant_id) if (res.success) { @@ -1246,7 +1202,7 @@ const confirmRemoveMember = async () => { const handleSubmitUpgrade = async () => { if (!props.orgId) return - + upgradeSubmitting.value = true try { const res = await requestRoleUpgrade(props.orgId, { @@ -1269,41 +1225,47 @@ const handleSubmitUpgrade = async () => { } } -// 添加成员:搜索用户 -let userSearchTimer: ReturnType | null = null -const handleUserSearch = (query: string) => { - if (userSearchTimer) { - clearTimeout(userSearchTimer) +// 添加成员:搜索租户(按租户名 / 用户名 / 邮箱模糊匹配,按 tenant_id 去重) +let tenantSearchTimer: ReturnType | null = null +const handleTenantSearch = (query: string) => { + if (tenantSearchTimer) { + clearTimeout(tenantSearchTimer) } if (!query || query.length < 2) { - userSearchResults.value = [] + tenantSearchResults.value = [] return } - userSearchTimer = setTimeout(async () => { + tenantSearchTimer = setTimeout(async () => { if (!props.orgId) return - userSearchLoading.value = true + tenantSearchLoading.value = true try { - const res = await searchUsersForInvite(props.orgId, query, 10) + const res = await searchTenantsForInvite(props.orgId, query, 10) if (res.success && res.data) { - userSearchResults.value = res.data + tenantSearchResults.value = res.data } } catch (error) { - console.error('Failed to search users:', error) + console.error('Failed to search tenants:', error) } finally { - userSearchLoading.value = false + tenantSearchLoading.value = false } }, 300) } -// 添加成员:提交 +// 添加成员:把选中的租户拉入空间。后端要求 tenant_id;representative_user_id +// 仅做展示/审计用,所以把搜索结果中代表用户也一并带上。 const handleAddMember = async () => { - if (!props.orgId || !selectedUser.value) return - + if (!props.orgId || selectedTenantId.value == null) return + + const candidate = tenantSearchResults.value.find( + (c) => c.tenant_id === selectedTenantId.value + ) + addMemberSubmitting.value = true try { const res = await inviteMember(props.orgId, { - user_id: selectedUser.value, - role: addMemberRole.value + tenant_id: selectedTenantId.value, + representative_user_id: candidate?.representative_user_id, + role: addMemberRole.value, }) if (res.success) { MessagePlugin.success(t('organization.addMember.success')) @@ -1322,9 +1284,9 @@ const handleAddMember = async () => { // 重置添加成员弹窗 const resetAddMemberDialog = () => { - selectedUser.value = '' + selectedTenantId.value = null addMemberRole.value = 'viewer' - userSearchResults.value = [] + tenantSearchResults.value = [] } const fallbackCopyText = (text: string) => { @@ -1918,9 +1880,11 @@ watch(currentSection, (section) => { border-radius: 12px; transition: background 0.2s ease; } + .avatar-trigger-wrap:hover { background: var(--td-bg-color-container-hover); } + .avatar-change-hint { font-size: 11px; color: var(--td-text-color-placeholder); @@ -1932,6 +1896,7 @@ watch(currentSection, (section) => { align-items: center; gap: 12px; } + .name-input-wrapper .name-input { flex: 1; min-width: 0; @@ -1942,18 +1907,21 @@ watch(currentSection, (section) => { padding: 12px; min-width: 260px; } + .avatar-popover-title { margin: 0 0 10px 0; font-size: 12px; color: var(--td-text-color-secondary); line-height: 1.4; } + .avatar-popover-content .avatar-emoji-grid { display: flex; flex-wrap: wrap; gap: 6px; max-width: 280px; } + .avatar-popover-content .avatar-emoji-btn { display: flex; align-items: center; @@ -1968,19 +1936,23 @@ watch(currentSection, (section) => { cursor: pointer; transition: border-color 0.2s ease, background 0.2s ease; } + .avatar-popover-content .avatar-emoji-btn:hover { border-color: var(--td-brand-color); background: rgba(7, 192, 95, 0.06); } + .avatar-popover-content .avatar-emoji-btn.is-selected { border-color: var(--td-brand-color); background: rgba(7, 192, 95, 0.12); } + .avatar-popover-content .avatar-clear-btn { margin-top: 10px; color: var(--td-text-color-secondary); font-size: 12px; } + .avatar-popover-content .avatar-clear-btn:hover { color: var(--td-brand-color-active); } @@ -2384,7 +2356,10 @@ watch(currentSection, (section) => { &.small { padding: 24px 16px; - .empty-text { margin: 0; } + + .empty-text { + margin: 0; + } } } @@ -2528,6 +2503,7 @@ watch(currentSection, (section) => { color: var(--td-text-color-secondary); white-space: nowrap; } + .request-role-select { min-width: 100px; } @@ -2576,7 +2552,7 @@ watch(currentSection, (section) => { color: var(--td-brand-color); } - & .shared-icon-org { + & .shared-icon-org { background: rgba(7, 192, 95, 0.08); color: var(--td-brand-color-active); } @@ -2860,6 +2836,7 @@ watch(currentSection, (section) => { .agent-scope-popover-fade-leave-active { transition: opacity 0.12s ease; } + .agent-scope-popover-fade-enter-from, .agent-scope-popover-fade-leave-to { opacity: 0; diff --git a/internal/application/service/organization.go b/internal/application/service/organization.go index 52647b157..0ca702e35 100644 --- a/internal/application/service/organization.go +++ b/internal/application/service/organization.go @@ -325,20 +325,22 @@ func (s *organizationService) JoinByOrganizationID(ctx context.Context, orgID st return org, nil } -// DeleteOrganization deletes an organization. Only the owner user may delete. -// (We keep the user-level check here intentionally — Org ownership is -// product-defined as the user who created it, not the tenant they were -// in at the time.) +// DeleteOrganization deletes an organization. Post-Plan-3 the gate is +// tenant-keyed: the caller's tenant must be the persisted owner tenant +// (org.OwnerTenantID, set on creation by migration 000046). For legacy +// rows where OwnerTenantID is still 0 we fall back to the old user-level +// rule so pre-backfill orgs remain deletable by their original creator. func (s *organizationService) DeleteOrganization(ctx context.Context, id string, userID string, tenantID uint64) error { org, err := s.orgRepo.GetByID(ctx, id) if err != nil { return err } - if org.OwnerID != userID { + isOwnerTenant := org.OwnerTenantID != 0 && org.OwnerTenantID == tenantID + isLegacyOwnerUser := org.OwnerTenantID == 0 && org.OwnerID == userID + if !isOwnerTenant && !isLegacyOwnerUser { return ErrOrgPermissionDenied } - _ = tenantID // accepted for handler symmetry; not part of the gate if err := s.shareRepo.DeleteByOrganizationID(ctx, id); err != nil { logger.Warnf(ctx, "Failed to delete KB shares for organization %s: %v", id, err) diff --git a/internal/handler/organization.go b/internal/handler/organization.go index 9970d0a53..82c2884ad 100644 --- a/internal/handler/organization.go +++ b/internal/handler/organization.go @@ -23,9 +23,14 @@ type OrganizationHandler struct { agentShareService interfaces.AgentShareService customAgentService interfaces.CustomAgentService userService interfaces.UserService - kbService interfaces.KnowledgeBaseService - knowledgeRepo interfaces.KnowledgeRepository - chunkRepo interfaces.ChunkRepository + // tenantService is used to resolve tenant_name in member listings + // and to back the tenant-centric invite picker. Plan 3 lifts org + // membership to the tenant level, so the UI needs to surface the + // tenant identity rather than the representative user alone. + tenantService interfaces.TenantService + kbService interfaces.KnowledgeBaseService + knowledgeRepo interfaces.KnowledgeRepository + chunkRepo interfaces.ChunkRepository } // NewOrganizationHandler creates a new organization handler @@ -35,6 +40,7 @@ func NewOrganizationHandler( agentShareService interfaces.AgentShareService, customAgentService interfaces.CustomAgentService, userService interfaces.UserService, + tenantService interfaces.TenantService, kbService interfaces.KnowledgeBaseService, knowledgeRepo interfaces.KnowledgeRepository, chunkRepo interfaces.ChunkRepository, @@ -45,6 +51,7 @@ func NewOrganizationHandler( agentShareService: agentShareService, customAgentService: customAgentService, userService: userService, + tenantService: tenantService, kbService: kbService, knowledgeRepo: knowledgeRepo, chunkRepo: chunkRepo, @@ -366,14 +373,25 @@ func (h *OrganizationHandler) ListMembers(c *gin.Context) { return } + // Collect tenant IDs to resolve tenant names in one round-trip. + tenantIDs := make([]uint64, 0, len(members)) + for _, m := range members { + tenantIDs = append(tenantIDs, m.TenantID) + } + tenantByID, _ := h.tenantService.GetTenantsByIDs(ctx, tenantIDs) + response := make([]types.OrganizationMemberResponse, 0, len(members)) for _, m := range members { resp := types.OrganizationMemberResponse{ - ID: m.ID, - UserID: m.RepresentativeUserID, - Role: string(m.Role), - TenantID: m.TenantID, - JoinedAt: m.CreatedAt, + ID: m.ID, + UserID: m.RepresentativeUserID, + RepresentativeUserID: m.RepresentativeUserID, + Role: string(m.Role), + TenantID: m.TenantID, + JoinedAt: m.CreatedAt, + } + if t, ok := tenantByID[m.TenantID]; ok && t != nil { + resp.TenantName = t.Name } if m.RepresentativeUser != nil { resp.Username = m.RepresentativeUser.Username @@ -835,14 +853,18 @@ func (h *OrganizationHandler) LeaveOrganization(c *gin.Context) { userID := c.GetString(types.UserIDContextKey.String()) tenantID := c.GetUint64(types.TenantIDContextKey.String()) - // Check if user is the owner + // Check if caller's tenant is the owner tenant. Post-Plan-3, "owner + // can't leave" is a tenant-level rule: the owner_tenant_id row is the + // one that may not depart the org. Legacy rows with OwnerTenantID == 0 + // fall back to the user-level rule so we don't break pre-000046 data. org, err := h.orgService.GetOrganization(ctx, orgID) if err != nil { c.Error(apperrors.NewNotFoundError("Organization not found")) return } - if org.OwnerID == userID { + isOwnerTenant := org.OwnerTenantID != 0 && org.OwnerTenantID == tenantID + if isOwnerTenant || (org.OwnerTenantID == 0 && org.OwnerID == userID) { c.Error(apperrors.NewForbiddenError("Organization owner cannot leave. Please transfer ownership or delete the organization.")) return } @@ -1709,7 +1731,12 @@ func (h *OrganizationHandler) toOrgResponse(ctx context.Context, org *types.Orga resp.MyRole = string(role) isAdmin = (role == types.OrgRoleAdmin) } - if isAdmin || org.OwnerID == currentUserID { + // Invite-code / pending-request visibility is keyed on whether the + // caller can administer the org. Post-Plan-3 that's "isAdmin in the + // caller's tenant context, OR the caller's tenant is the owner + // tenant"; we already computed isOwner with the tenant-first logic + // above, so reuse it instead of comparing user IDs again. + if isAdmin || isOwner { resp.InviteCode = org.InviteCode resp.InviteCodeExpiresAt = org.InviteCodeExpiresAt if n, err := h.orgService.CountPendingJoinRequests(ctx, org.ID); err == nil { @@ -1725,26 +1752,33 @@ func (h *OrganizationHandler) toOrgResponse(ctx context.Context, org *types.Orga return resp } -// SearchUsersForInvite searches users for inviting to organization -// @Summary 搜索可邀请的用户 -// @Description 搜索用户(排除已有成员)用于邀请加入组织 +// SearchTenantsForInvite searches candidate tenants for inviting to organization. +// +// Plan 3 (#1303) makes the tenant the unit of membership. This endpoint replaces +// the older per-user search: it accepts a free-text query, looks up matching users +// (by username/email), groups them by their TenantID, resolves the tenant's +// canonical name, filters out tenants already in the org, and returns one row +// per candidate tenant with one representative user attached for display. +// +// @Summary 搜索可邀请的租户 +// @Description 搜索租户(排除已加入的租户)用于邀请加入组织;按租户去重,附带代表用户 // @Tags 组织管理 // @Produce json // @Param id path string true "组织ID" -// @Param q query string true "搜索关键词(用户名或邮箱)" +// @Param q query string true "搜索关键词(租户名、用户名或邮箱)" // @Param limit query int false "返回数量限制" default(10) // @Success 200 {object} map[string]interface{} // @Failure 403 {object} apperrors.AppError // @Security Bearer -// @Router /organizations/{id}/search-users [get] -func (h *OrganizationHandler) SearchUsersForInvite(c *gin.Context) { +// @Router /organizations/{id}/search-tenants [get] +func (h *OrganizationHandler) SearchTenantsForInvite(c *gin.Context) { ctx := c.Request.Context() orgID := c.Param("id") query := c.Query("q") tenantID := c.GetUint64(types.TenantIDContextKey.String()) - // Check admin permission: caller's tenant must be org admin + // Check admin permission: caller's tenant must be org admin. isAdmin, err := h.orgService.IsTenantOrgAdmin(ctx, orgID, tenantID) if err != nil || !isAdmin { c.Error(apperrors.NewForbiddenError("Only organization admins can invite members")) @@ -1754,57 +1788,141 @@ func (h *OrganizationHandler) SearchUsersForInvite(c *gin.Context) { if query == "" { c.JSON(http.StatusOK, gin.H{ "success": true, - "data": []interface{}{}, + "data": []types.TenantInviteCandidate{}, }) return } - // Get limit from query limit := 10 if l := c.Query("limit"); l != "" { - if _, err := c.GetQuery("limit"); err { - limit = 10 + if n, errConv := strconv.Atoi(l); errConv == nil && n > 0 && n <= 50 { + limit = n } } - // Search users - users, err := h.userService.SearchUsers(ctx, query, limit+20) // fetch more to filter out existing members - if err != nil { - logger.Errorf(ctx, "Failed to search users: %v", err) - c.Error(apperrors.NewInternalServerError("Failed to search users")) - return - } - - // Get existing tenant members; exclude users whose tenant is already a member + // Exclude tenants already in the org. existingMembers, _ := h.orgService.ListTenantMembers(ctx, orgID) - existingTenantIDs := make(map[uint64]bool) + existingTenantIDs := make(map[uint64]bool, len(existingMembers)) for _, m := range existingMembers { existingTenantIDs[m.TenantID] = true } - // Filter out users whose tenant is already a member - var result []gin.H - for _, u := range users { + // 1) Match users by query and group by TenantID. We over-fetch so the + // de-duplication after filtering "already a member" tenants still + // leaves us with enough candidates to fill `limit`. + users, err := h.userService.SearchUsers(ctx, query, limit*3+20) + if err != nil { + logger.Errorf(ctx, "Failed to search users: %v", err) + c.Error(apperrors.NewInternalServerError("Failed to search candidates")) + return + } + + // 2) Direct tenant-name match (admins may want to invite by tenant name). + // SearchTenants uses page/pageSize; pageSize=limit*2 is a safe ceiling + // given the soft cap of 50 above. + tenantsByName, _, _ := h.tenantService.SearchTenants(ctx, query, 0, 1, limit*2) + + // Insertion-ordered map: first match wins, so the first user that + // brought a tenant in becomes the representative. + type entry struct { + idx int // preserve search ordering + candidate types.TenantInviteCandidate + } + seen := make(map[uint64]*entry) + addUser := func(u *types.User) { + if u == nil || u.TenantID == 0 { + return + } if existingTenantIDs[u.TenantID] { + return + } + if _, ok := seen[u.TenantID]; ok { + return + } + seen[u.TenantID] = &entry{ + idx: len(seen), + candidate: types.TenantInviteCandidate{ + TenantID: u.TenantID, + RepresentativeUserID: u.ID, + RepresentativeUsername: u.Username, + RepresentativeEmail: u.Email, + RepresentativeAvatar: u.Avatar, + }, + } + } + for _, u := range users { + addUser(u) + } + addTenantByID := func(tid uint64) { + if tid == 0 || existingTenantIDs[tid] { + return + } + if _, ok := seen[tid]; ok { + return + } + seen[tid] = &entry{ + idx: len(seen), + candidate: types.TenantInviteCandidate{ + TenantID: tid, + }, + } + } + for _, t := range tenantsByName { + if t == nil { continue } - result = append(result, gin.H{ - "id": u.ID, - "username": u.Username, - "email": u.Email, - "avatar": u.Avatar, - }) - if len(result) >= limit { + addTenantByID(t.ID) + } + + // Resolve tenant names for all candidates in one round-trip. + ids := make([]uint64, 0, len(seen)) + for tid := range seen { + ids = append(ids, tid) + } + tenantByID, _ := h.tenantService.GetTenantsByIDs(ctx, ids) + for tid, e := range seen { + if t, ok := tenantByID[tid]; ok && t != nil { + e.candidate.TenantName = t.Name + } + } + + // Restore insertion order (idx is unique in [0, len(seen))). + byIdx := make([]types.TenantInviteCandidate, len(seen)) + for _, e := range seen { + byIdx[e.idx] = e.candidate + } + // Drop tenants we couldn't resolve a name for (defunct rows or + // deleted tenants) and cap at `limit`. + sorted := make([]types.TenantInviteCandidate, 0, limit) + for _, c := range byIdx { + if c.TenantName == "" { + continue + } + sorted = append(sorted, c) + if len(sorted) >= limit { break } } c.JSON(http.StatusOK, gin.H{ "success": true, - "data": result, + "data": sorted, }) } +// SearchUsersForInvite is retained as a thin compatibility shim that +// delegates to SearchTenantsForInvite, so older frontends still get +// tenant-grouped results without breaking the call site. The response +// shape here is (intentionally) the new tenant-candidate shape; the +// previous shape returned one row per matching user, which leaked the +// pre-Plan-3 mental model. +// +// @Deprecated Use SearchTenantsForInvite. Kept for one release. +// @Router /organizations/{id}/search-users [get] +func (h *OrganizationHandler) SearchUsersForInvite(c *gin.Context) { + h.SearchTenantsForInvite(c) +} + // InviteMember directly adds a user to organization // @Summary 邀请成员 // @Description 管理员直接添加用户为组织成员 @@ -1844,22 +1962,65 @@ func (h *OrganizationHandler) InviteMember(c *gin.Context) { return } - // Check if user exists - invitedUser, err := h.userService.GetUserByID(ctx, req.UserID) - if err != nil { - c.Error(apperrors.NewNotFoundError("User not found")) + // Plan 3: resolve the (target tenant, representative user) pair. + // + // - Preferred: caller supplies tenant_id directly (and optionally + // representative_user_id) — this matches the tenant-centric mental + // model and lets admins invite any user as the rep. + // - Legacy: caller supplies only user_id — handler looks up that + // user's tenant and uses the same user as the rep, preserving the + // pre-Plan-3 SDK contract. + targetTenantID := req.TenantID + representativeUserID := req.RepresentativeUserID + switch { + case targetTenantID != 0: + // Tenant-id path: validate the tenant exists; pick a sensible + // representative when the caller didn't pin one. + if _, err := h.tenantService.GetTenantByID(ctx, targetTenantID); err != nil { + c.Error(apperrors.NewNotFoundError("Tenant not found")) + return + } + if representativeUserID == "" { + // Fall back to the legacy user_id field if it was sent, so + // existing clients that learned to send both keep working. + representativeUserID = req.UserID + } + if representativeUserID != "" { + // If a representative is named, sanity-check it belongs to + // the target tenant. We don't hard-fail when it doesn't — + // the membership row is keyed by tenant_id, the rep field + // is informational — but we strip the inconsistent value + // so the audit log doesn't lie. + if u, err := h.userService.GetUserByID(ctx, representativeUserID); err != nil || u == nil || u.TenantID != targetTenantID { + logger.Warnf(ctx, "representative_user_id %s does not belong to tenant %d; dropping", + secutils.SanitizeForLog(representativeUserID), targetTenantID) + representativeUserID = "" + } + } + case req.UserID != "": + // Legacy path: resolve target tenant from the user. + invitedUser, err := h.userService.GetUserByID(ctx, req.UserID) + if err != nil { + c.Error(apperrors.NewNotFoundError("User not found")) + return + } + targetTenantID = invitedUser.TenantID + if representativeUserID == "" { + representativeUserID = req.UserID + } + default: + c.Error(apperrors.NewValidationError("Either tenant_id or user_id is required")) return } - // Check if invitee's tenant is already a member of this org - _, memberErr := h.orgService.GetTenantMember(ctx, orgID, invitedUser.TenantID) - if memberErr == nil { - c.Error(apperrors.NewValidationError("User's tenant is already a member of this organization")) + // Check if target tenant is already a member of this org. + if _, memberErr := h.orgService.GetTenantMember(ctx, orgID, targetTenantID); memberErr == nil { + c.Error(apperrors.NewValidationError("Tenant is already a member of this organization")) return } - // Add tenant member with the invited user as representative - if err := h.orgService.AddTenantMember(ctx, orgID, invitedUser.TenantID, req.UserID, req.Role); err != nil { + // Add tenant member with the chosen representative. + if err := h.orgService.AddTenantMember(ctx, orgID, targetTenantID, representativeUserID, req.Role); err != nil { logger.Errorf(ctx, "Failed to add member: %v", err) if errors.Is(err, service.ErrOrgMemberLimitReached) { c.Error(apperrors.NewValidationError("该空间成员已满,无法添加新成员")) @@ -1869,9 +2030,10 @@ func (h *OrganizationHandler) InviteMember(c *gin.Context) { return } - logger.Infof(ctx, "User %s invited user %s to organization %s with role %s", + logger.Infof(ctx, "User %s invited tenant %d (rep user %s) to organization %s with role %s", secutils.SanitizeForLog(userID), - secutils.SanitizeForLog(req.UserID), + targetTenantID, + secutils.SanitizeForLog(representativeUserID), orgID, req.Role) diff --git a/internal/router/router.go b/internal/router/router.go index fe11db5b7..c193c6d9d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -854,7 +854,14 @@ func RegisterOrganizationRoutes(r *gin.RouterGroup, orgHandler *handler.Organiza // invite code is an admin action; the service layer additionally // requires the caller's tenant to be admin in the org. orgs.POST("/:id/invite-code", g.Admin(), orgHandler.GenerateInviteCode) - // Search users for invite (admin only) + // Search tenants for invite (admin only). Plan 3 changed the + // unit of membership to "tenant"; this endpoint returns + // candidate tenants (with one representative user attached) + // instead of one row per user. + orgs.GET("/:id/search-tenants", g.Admin(), orgHandler.SearchTenantsForInvite) + // Deprecated alias for /:id/search-tenants. Old frontends that + // still hit search-users will receive the tenant-grouped shape; + // the deprecation is documented in the handler. orgs.GET("/:id/search-users", g.Admin(), orgHandler.SearchUsersForInvite) // Invite member directly (admin only) orgs.POST("/:id/invite", g.Admin(), orgHandler.InviteMember) diff --git a/internal/types/organization.go b/internal/types/organization.go index e6a8c8f02..5f5f2e047 100644 --- a/internal/types/organization.go +++ b/internal/types/organization.go @@ -363,10 +363,26 @@ type RequestRoleUpgradeRequest struct { Message string `json:"message" binding:"max=500"` // Optional message explaining the reason } -// InviteMemberRequest represents a request to directly invite a user to organization +// InviteMemberRequest represents a request to directly invite a tenant to organization. +// +// Plan 3 (#1303) moved membership to the tenant level: an invitation enrols a whole +// tenant into the organization, with one user attached purely as the representative +// (display/audit). Callers SHOULD set TenantID and optionally +// RepresentativeUserID. For backward compatibility with older SDK callers that +// still send UserID alone, the handler resolves that user's TenantID and uses +// the user as the representative. type InviteMemberRequest struct { - UserID string `json:"user_id" binding:"required"` // User ID to invite - Role OrgMemberRole `json:"role" binding:"required"` // Role to assign: admin/editor/viewer + // TenantID is the tenant to enrol as an org member. Preferred field. + TenantID uint64 `json:"tenant_id"` + // RepresentativeUserID identifies the user attached to the OTM row for + // display/audit. Optional: when unset, the handler picks a stable default + // (the user from the legacy UserID field, or the tenant's owner). + RepresentativeUserID string `json:"representative_user_id"` + // UserID is retained for backward compatibility. When set without + // TenantID, the handler resolves the user's TenantID and uses this + // user as the representative. + UserID string `json:"user_id"` + Role OrgMemberRole `json:"role" binding:"required"` // Role to assign: admin/editor/viewer } // ShareKnowledgeBaseRequest represents a request to share a knowledge base @@ -410,16 +426,39 @@ type OrganizationResponse struct { UpdatedAt time.Time `json:"updated_at"` } -// OrganizationMemberResponse represents a member in API responses +// OrganizationMemberResponse represents a member in API responses. +// +// Post-Plan-3: every row is a (org, tenant) tuple. TenantID + TenantName +// are the primary identity; UserID / Username / Email / Avatar describe +// the representative user (informational, may be empty if the rep user +// was soft-deleted). RepresentativeUserID is the same value as UserID, +// kept as an explicit alias so frontends can stop relying on the +// misleading user_id field name. type OrganizationMemberResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Username string `json:"username"` - Email string `json:"email"` - Avatar string `json:"avatar"` - Role string `json:"role"` - TenantID uint64 `json:"tenant_id"` - JoinedAt time.Time `json:"joined_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + RepresentativeUserID string `json:"representative_user_id"` + Username string `json:"username"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Role string `json:"role"` + TenantID uint64 `json:"tenant_id"` + TenantName string `json:"tenant_name,omitempty"` + JoinedAt time.Time `json:"joined_at"` +} + +// TenantInviteCandidate is one row in the search-tenants-for-invite picker. +// Plan 3 invites a tenant; users serve as labels. We surface the tenant +// identity together with a "representative" user (the matching user that +// caused this tenant to show up in the search). Multiple users may belong +// to the same tenant; deduplication is by TenantID. +type TenantInviteCandidate struct { + TenantID uint64 `json:"tenant_id"` + TenantName string `json:"tenant_name"` + RepresentativeUserID string `json:"representative_user_id"` + RepresentativeUsername string `json:"representative_username"` + RepresentativeEmail string `json:"representative_email"` + RepresentativeAvatar string `json:"representative_avatar,omitempty"` } // KnowledgeBaseShareResponse represents a share record in API responses