feat(organization): transition membership model to tenant-level and enhance invite functionality

Updated the organization membership model to operate at the tenant level, aligning with Plan 3. This change introduces new interfaces and modifies existing ones to accommodate tenant-centric operations, including the `InviteMemberRequest` and `TenantInviteCandidate`. The search functionality for inviting members has been updated to reflect this shift, allowing for tenant-based searches instead of user-based. Additionally, internationalization files have been updated to ensure consistent messaging across languages regarding tenant membership and roles.

Refs: #1303
This commit is contained in:
wizardchen
2026-05-16 03:11:11 +08:00
committed by lyingbug
parent 9c88af3370
commit dc8d033b4d
10 changed files with 640 additions and 383 deletions

View File

@@ -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<ApiResponse<Lis
}
/**
* Search users for inviting to organization (excludes existing members)
* Search candidate tenants for inviting to organization (excludes tenants
* already in the org). The endpoint matches by tenant name, username, or
* email and de-duplicates results by tenant_id.
*/
export async function searchTenantsForInvite(
orgId: string,
query: string,
limit: number = 10
): Promise<ApiResponse<TenantInviteCandidate[]>> {
try {
const response = await get(`/api/v1/organizations/${orgId}/search-tenants?q=${encodeURIComponent(query)}&limit=${limit}`)
return response as unknown as ApiResponse<TenantInviteCandidate[]>
} 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<ApiResponse<UserSearchResult[]>> {
try {
const response = await get(`/api/v1/organizations/${orgId}/search-users?q=${encodeURIComponent(query)}&limit=${limit}`)
return response as unknown as ApiResponse<UserSearchResult[]>
} catch (error: any) {
return { success: false, message: error.message || 'Failed to search users' }
}
): Promise<ApiResponse<TenantInviteCandidate[]>> {
return searchTenantsForInvite(orgId, query, limit)
}
/**

View File

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

View File

@@ -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: "회원이 추가되었습니다.",

View File

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

View File

@@ -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: "成员添加成功",

View File

@@ -6,7 +6,7 @@
<!-- 关闭按钮 -->
<button class="close-btn" @click="handleClose" :aria-label="$t('common.close')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
@@ -17,19 +17,17 @@
<h2 class="sidebar-title">{{ modalTitle }}</h2>
</div>
<div class="settings-nav">
<div
v-for="item in navItems"
:key="item.key"
:class="['nav-item', { 'active': currentSection === item.key }]"
@click="currentSection = item.key"
>
<img v-if="item.key === 'sharedAgents'" :src="currentSection === 'sharedAgents' ? agentIconActiveSrc : agentIconSrc" class="nav-icon nav-icon-img" alt="" aria-hidden="true" />
<div v-for="item in navItems" :key="item.key"
:class="['nav-item', { 'active': currentSection === item.key }]" @click="currentSection = item.key">
<img v-if="item.key === 'sharedAgents'"
:src="currentSection === 'sharedAgents' ? agentIconActiveSrc : agentIconSrc"
class="nav-icon nav-icon-img" alt="" aria-hidden="true" />
<t-icon v-else :name="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
<span
v-if="item.badge != null && (item.key === 'sharedKb' || item.key === 'sharedAgents' ? true : item.badge > 0)"
:class="['nav-item-badge', { 'nav-item-badge-count': item.key === 'sharedKb' || item.key === 'sharedAgents' }]"
>{{ item.badge }}</span>
:class="['nav-item-badge', { 'nav-item-badge-count': item.key === 'sharedKb' || item.key === 'sharedAgents' }]">{{
item.badge }}</span>
</div>
</div>
</div>
@@ -48,7 +46,7 @@
<h2>{{ $t('organization.editor.basicTitle') }}</h2>
<p class="section-description">{{ $t('organization.editor.basicDesc') }}</p>
</div>
<div class="settings-group">
<!-- 空间名称与头像一行展示头像点击弹出 Emoji 选择 -->
<div class="setting-row">
@@ -58,13 +56,8 @@
</div>
<div class="setting-control">
<div class="name-input-wrapper">
<t-popup
v-model="avatarPopoverVisible"
trigger="click"
placement="bottom-left"
:disabled="!isAdmin"
overlay-class-name="avatar-emoji-popover"
>
<t-popup v-model="avatarPopoverVisible" trigger="click" placement="bottom-left"
:disabled="!isAdmin" overlay-class-name="avatar-emoji-popover">
<div class="avatar-trigger-wrap">
<SpaceAvatar :name="formData.name || '?'" :avatar="formData.avatar" size="medium" />
<span v-if="isAdmin" class="avatar-change-hint">{{ $t('organization.avatar') }}</span>
@@ -73,35 +66,22 @@
<div class="avatar-popover-content" @click.stop>
<p class="avatar-popover-title">{{ $t('organization.avatarPickerHint') }}</p>
<div class="avatar-emoji-grid">
<button
v-for="emoji in avatarEmojiOptions"
:key="emoji"
type="button"
<button v-for="emoji in avatarEmojiOptions" :key="emoji" type="button"
class="avatar-emoji-btn"
:class="{ 'is-selected': formData.avatar === 'emoji:' + emoji }"
@click="selectAvatarEmoji(emoji)"
>
@click="selectAvatarEmoji(emoji)">
{{ emoji }}
</button>
</div>
<t-button
v-if="formData.avatar"
variant="text"
size="small"
class="avatar-clear-btn"
@click="clearAvatarEmoji"
>
<t-button v-if="formData.avatar" variant="text" size="small" class="avatar-clear-btn"
@click="clearAvatarEmoji">
{{ $t('organization.avatarClear') }}
</t-button>
</div>
</template>
</t-popup>
<t-input
v-model="formData.name"
:placeholder="$t('organization.namePlaceholder')"
:disabled="!isAdmin"
class="name-input"
/>
<t-input v-model="formData.name" :placeholder="$t('organization.namePlaceholder')"
:disabled="!isAdmin" class="name-input" />
</div>
</div>
</div>
@@ -113,13 +93,9 @@
<p class="desc">{{ $t('organization.editor.descriptionTip') }}</p>
</div>
<div class="setting-control">
<t-textarea
v-model="formData.description"
<t-textarea v-model="formData.description"
:placeholder="$t('organization.descriptionPlaceholder')"
:autosize="{ minRows: 3, maxRows: 6 }"
:maxlength="500"
:disabled="!isAdmin"
/>
:autosize="{ minRows: 3, maxRows: 6 }" :maxlength="500" :disabled="!isAdmin" />
</div>
</div>
@@ -146,7 +122,8 @@
</t-button>
</t-tooltip>
<t-tooltip :content="$t('organization.refreshInviteCode')">
<t-button variant="text" size="small" @click="refreshInviteCode" :loading="refreshingCode">
<t-button variant="text" size="small" @click="refreshInviteCode"
:loading="refreshingCode">
<t-icon name="refresh" />
</t-button>
</t-tooltip>
@@ -154,28 +131,24 @@
</div>
<p v-if="inviteCode" class="invite-remaining">{{ remainingValidityText }}</p>
</div>
<div class="invite-divider"></div>
<!-- 邀请链接有效期 -->
<div class="invite-method">
<div class="invite-method-header">
<t-icon name="time" class="invite-icon" />
<span class="invite-method-title">{{ $t('organization.settings.inviteLinkValidity') }}</span>
<span class="invite-method-title">{{ $t('organization.settings.inviteLinkValidity')
}}</span>
</div>
<p class="invite-validity-desc">{{ $t('organization.settings.inviteLinkValidityDesc') }}</p>
<t-select
v-model="formData.invite_code_validity_days"
:options="inviteValidityOptions"
size="small"
class="invite-validity-select"
:disabled="!isAdmin"
@change="handleValidityChange"
/>
<t-select v-model="formData.invite_code_validity_days" :options="inviteValidityOptions"
size="small" class="invite-validity-select" :disabled="!isAdmin"
@change="handleValidityChange" />
</div>
<div class="invite-divider"></div>
<!-- 邀请链接 -->
<div class="invite-method">
<div class="invite-method-header">
@@ -191,9 +164,9 @@
</t-tooltip>
</div>
</div>
<div class="invite-divider"></div>
<!-- 需要审核开关 -->
<div class="invite-method">
<div class="invite-method-header">
@@ -201,16 +174,13 @@
<span class="invite-method-title">{{ $t('organization.settings.requireApproval') }}</span>
</div>
<div class="approval-toggle">
<t-switch
v-model="formData.require_approval"
@change="handleApprovalToggle"
/>
<t-switch v-model="formData.require_approval" @change="handleApprovalToggle" />
<span class="approval-desc">{{ $t('organization.settings.requireApprovalDesc') }}</span>
</div>
</div>
<div class="invite-divider"></div>
<!-- 开放可被搜索 -->
<div class="invite-method">
<div class="invite-method-header">
@@ -218,16 +188,13 @@
<span class="invite-method-title">{{ $t('organization.settings.searchable') }}</span>
</div>
<div class="approval-toggle">
<t-switch
v-model="formData.searchable"
@change="handleSearchableToggle"
/>
<t-switch v-model="formData.searchable" @change="handleSearchableToggle" />
<span class="approval-desc">{{ $t('organization.settings.searchableDesc') }}</span>
</div>
</div>
<div class="invite-divider"></div>
<!-- 成员人数上限 -->
<div class="invite-method">
<div class="invite-method-header">
@@ -236,15 +203,14 @@
</div>
<p class="invite-validity-desc">{{ $t('organization.settings.memberLimitDesc') }}</p>
<div class="member-limit-input-row">
<t-input-number
v-model="formData.member_limit"
:min="0"
:max="10000"
:placeholder="$t('organization.settings.memberLimitPlaceholder')"
theme="normal"
style="width: 140px;"
/>
<span class="member-limit-hint">{{ $t('organization.settings.memberLimitHint', { count: orgInfo?.member_count ?? 0 }) }}</span>
<t-input-number v-model="formData.member_limit" :min="0" :max="10000"
:placeholder="$t('organization.settings.memberLimitPlaceholder')" theme="normal"
style="width: 140px;" />
<span class="member-limit-hint">{{ $t('organization.settings.memberLimitHint', {
count:
orgInfo?.member_count
?? 0
}) }}</span>
</div>
</div>
</div>
@@ -276,11 +242,20 @@
<span v-if="orgInfo?.my_role === 'admin'" class="me-badge">{{ $t('common.me') }}</span>
</div>
<div class="perm-items">
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.viewerPerm1') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.editorPerm1') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.useSharedAgentsPerm') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.shareKBPerm') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.adminPerm1') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.viewerPerm1')
}}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.editorPerm1')
}}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.useSharedAgentsPerm') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.shareKBPerm')
}}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.adminPerm1')
}}</span>
</div>
</div>
<div :class="['perm-role-block', 'editor', { 'is-me': orgInfo?.my_role === 'editor' }]">
@@ -290,11 +265,20 @@
<span v-if="orgInfo?.my_role === 'editor'" class="me-badge">{{ $t('common.me') }}</span>
</div>
<div class="perm-items">
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.viewerPerm1') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.editorPerm1') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.useSharedAgentsPerm') }}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{ $t('organization.editor.shareKBPerm') }}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{ $t('organization.editor.adminPerm1') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.viewerPerm1')
}}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.editorPerm1')
}}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.useSharedAgentsPerm') }}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{
$t('organization.editor.shareKBPerm')
}}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{
$t('organization.editor.adminPerm1')
}}</span>
</div>
</div>
<div :class="['perm-role-block', 'viewer', { 'is-me': orgInfo?.my_role === 'viewer' }]">
@@ -304,24 +288,30 @@
<span v-if="orgInfo?.my_role === 'viewer'" class="me-badge">{{ $t('common.me') }}</span>
</div>
<div class="perm-items">
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.viewerPerm1') }}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{ $t('organization.editor.editorPerm1') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{ $t('organization.editor.useSharedAgentsPerm') }}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{ $t('organization.editor.shareKBPerm') }}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{ $t('organization.editor.adminPerm1') }}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.viewerPerm1')
}}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{
$t('organization.editor.editorPerm1')
}}</span>
<span class="perm-item has"><t-icon name="check" size="12px" />{{
$t('organization.editor.useSharedAgentsPerm') }}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{
$t('organization.editor.shareKBPerm')
}}</span>
<span class="perm-item no"><t-icon name="close" size="12px" />{{
$t('organization.editor.adminPerm1')
}}</span>
</div>
</div>
</div>
<!-- 申请权限升级按钮非管理员可见 -->
<div v-if="canRequestUpgrade" class="permissions-upgrade-action">
<t-button
variant="outline"
size="small"
@click="showUpgradeDialog = true"
:disabled="hasPendingUpgrade"
>
<t-button variant="outline" size="small" @click="showUpgradeDialog = true"
:disabled="hasPendingUpgrade">
<template #icon><t-icon name="arrow-up" /></template>
{{ hasPendingUpgrade ? $t('organization.upgrade.pending') : $t('organization.upgrade.requestUpgrade') }}
{{ hasPendingUpgrade ? $t('organization.upgrade.pending') :
$t('organization.upgrade.requestUpgrade') }}
</t-button>
</div>
</div>
@@ -329,69 +319,47 @@
<div class="settings-group members-group">
<div class="members-header">
<div class="members-search">
<t-input
v-model="memberSearchQuery"
:placeholder="$t('common.search')"
clearable
>
<t-input v-model="memberSearchQuery" :placeholder="$t('common.search')" clearable>
<template #prefix-icon>
<t-icon name="search" />
</template>
</t-input>
</div>
<t-button
v-if="isAdmin"
variant="outline"
size="small"
@click="showAddMemberDialog = true"
>
<t-button v-if="isAdmin" variant="outline" size="small" @click="showAddMemberDialog = true">
<template #icon><t-icon name="user-add" /></template>
{{ $t('organization.addMember.button') }}
</t-button>
</div>
<t-loading :loading="membersLoading">
<div class="members-list">
<div
v-for="member in filteredMembers"
:key="member.id"
class="member-item"
:class="{
'is-owner': isOwnerMember(member),
'is-me': member.user_id === authStore.currentUserId
}"
>
<div v-for="member in filteredMembers" :key="member.id" class="member-item" :class="{
'is-owner': isOwnerMember(member),
'is-me': member.user_id === authStore.currentUserId
}">
<div class="member-avatar" :class="{ 'is-me': member.user_id === authStore.currentUserId }">
<img v-if="member.avatar" :src="member.avatar" alt="" />
<t-icon v-else name="user" size="20px" />
</div>
<div class="member-info">
<span class="member-name">
{{ member.username }}
<span v-if="member.user_id === authStore.currentUserId" class="me-tag">{{ $t('common.me') }}</span>
{{ memberPrimaryLabel(member) }}
<span v-if="member.user_id === authStore.currentUserId" class="me-tag">{{ $t('common.me')
}}</span>
</span>
<span class="member-email">{{ member.email }}</span>
<span class="member-email">{{ memberSecondaryLabel(member) }}</span>
</div>
<div class="member-role">
<t-select
v-if="isAdmin && !isOwnerMember(member)"
v-model="member.role"
:options="roleOptions"
size="small"
@change="(val: string) => handleRoleChange(member, val)"
/>
<t-select v-if="isAdmin && !isOwnerMember(member)" v-model="member.role"
:options="roleOptions" size="small"
@change="(val: string) => handleRoleChange(member, val)" />
<t-tag v-else size="small" :theme="getRoleTheme(member.role)">
{{ $t(`organization.role.${member.role}`) }}
<span v-if="isOwnerMember(member)">({{ $t('organization.owner') }})</span>
</t-tag>
</div>
<div v-if="isAdmin && !isOwnerMember(member)" class="member-actions">
<t-button
variant="text"
theme="danger"
size="small"
@click="handleRemoveMember(member)"
>
<t-button variant="text" theme="danger" size="small" @click="handleRemoveMember(member)">
<t-icon name="delete" />
</t-button>
</div>
@@ -420,11 +388,7 @@
<p class="empty-text">{{ $t('organization.settings.noPendingRequests') }}</p>
</div>
<div v-else class="join-requests-list">
<div
v-for="req in joinRequests"
:key="req.id"
class="join-request-item"
>
<div v-for="req in joinRequests" :key="req.id" class="join-request-item">
<div class="request-user">
<div class="request-avatar">
<t-icon name="user" size="20px" />
@@ -432,38 +396,34 @@
<div class="request-info">
<span class="request-name">
{{ req.username || req.email || req.user_id }}
<t-tag
v-if="req.request_type === 'upgrade'"
size="small"
theme="warning"
class="request-type-tag"
>
<t-tag v-if="req.request_type === 'upgrade'" size="small" theme="warning"
class="request-type-tag">
{{ $t('organization.upgrade.upgradeRequest') }}
</t-tag>
</span>
<span class="request-email">{{ req.email }}</span>
<p v-if="req.message" class="request-message">{{ req.message }}</p>
<span v-if="req.request_type === 'upgrade' && req.prev_role" class="request-prev-role">
{{ $t('organization.upgrade.currentRole') }}{{ roleLabel(req.prev_role) }} {{ roleLabel(req.requested_role) }}
{{ $t('organization.upgrade.currentRole') }}{{ roleLabel(req.prev_role) }} {{
roleLabel(req.requested_role) }}
</span>
<span v-else class="request-requested-role">{{ $t('organization.invite.requestRole') }}{{ roleLabel(req.requested_role) }}</span>
<span v-else class="request-requested-role">{{ $t('organization.invite.requestRole') }}{{
roleLabel(req.requested_role) }}</span>
<span class="request-time">{{ formatDate(req.created_at) }}</span>
</div>
</div>
<div class="request-actions">
<div class="request-assign-role">
<span class="request-assign-label">{{ $t('organization.settings.assignRole') }}</span>
<t-select
v-model="assignRoleMap[req.id]"
class="request-role-select"
:options="orgRoleOptions"
size="small"
/>
<t-select v-model="assignRoleMap[req.id]" class="request-role-select"
:options="orgRoleOptions" size="small" />
</div>
<t-button theme="primary" size="small" :loading="reviewingRequestId === req.id" @click="handleApproveRequest(req)">
<t-button theme="primary" size="small" :loading="reviewingRequestId === req.id"
@click="handleApproveRequest(req)">
{{ $t('organization.settings.approve') }}
</t-button>
<t-button theme="default" variant="outline" size="small" :loading="reviewingRequestId === req.id" @click="handleRejectRequest(req)">
<t-button theme="default" variant="outline" size="small"
:loading="reviewingRequestId === req.id" @click="handleRejectRequest(req)">
{{ $t('organization.settings.reject') }}
</t-button>
</div>
@@ -497,12 +457,8 @@
<p class="empty-subtext">{{ $t('organization.settings.noSharedKBTip') }}</p>
</div>
<div v-else class="shared-list">
<div
v-for="share in sharedKnowledgeBases"
:key="share.id"
class="shared-item"
@click="handleShareClick(share)"
>
<div v-for="share in sharedKnowledgeBases" :key="share.id" class="shared-item"
@click="handleShareClick(share)">
<div class="shared-icon shared-icon-kb">
<img src="@/assets/img/zhishiku.svg" class="shared-icon-kb-img" alt="" aria-hidden="true" />
</div>
@@ -521,31 +477,32 @@
</div>
<div class="shared-permissions">
<t-tooltip :content="$t('organization.settings.sharePermissionLabel')" placement="top">
<t-tag size="small" :theme="getPermissionTheme(share.permission)" variant="outline" class="perm-tag">
{{ $t('organization.settings.sharePermissionLabel') }}: {{ (share.permission === 'editor' || share.permission === 'admin') ? $t('organization.share.permissionEditable') : $t('organization.share.permissionReadonly') }}
<t-tag size="small" :theme="getPermissionTheme(share.permission)" variant="outline"
class="perm-tag">
{{ $t('organization.settings.sharePermissionLabel') }}: {{ (share.permission ===
'editor' ||
share.permission === 'admin') ? $t('organization.share.permissionEditable') :
$t('organization.share.permissionReadonly') }}
</t-tag>
</t-tooltip>
<t-tooltip :content="$t('organization.settings.permissionCalcTip')" placement="top">
<t-tag size="small" :theme="getPermissionTheme(share.my_permission ?? share.permission)" class="perm-tag">
{{ $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-tag size="small" :theme="getPermissionTheme(share.my_permission ?? share.permission)"
class="perm-tag">
{{ $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-tag>
</t-tooltip>
</div>
<t-popconfirm
v-if="isAdmin"
<t-popconfirm v-if="isAdmin"
:content="$t('organization.settings.removeShareConfirm', { name: share.knowledge_base_name || share.knowledge_base_id })"
:confirm-btn="{ content: $t('common.confirm'), theme: 'danger' }"
:cancel-btn="{ content: $t('common.cancel') }"
@confirm="handleRemoveShare(share)"
>
:cancel-btn="{ content: $t('common.cancel') }" @confirm="handleRemoveShare(share)">
<t-tooltip :content="$t('organization.settings.removeShareFromOrg')" placement="top">
<t-button
variant="text"
size="small"
theme="danger"
class="shared-remove-btn"
@click.stop
>
<t-button variant="text" size="small" theme="danger" class="shared-remove-btn"
@click.stop>
<t-icon name="delete" size="16px" />
</t-button>
</t-tooltip>
@@ -562,7 +519,8 @@
<h2>{{ $t('organization.settings.sharedAgents') }}</h2>
<p class="section-description">{{ $t('organization.settings.sharedAgentsDesc') }}</p>
<p class="section-description permission-calc-hint">
<t-tooltip :content="$t('organization.settings.sharedAgentsKbHint')" placement="top" :show-delay="300">
<t-tooltip :content="$t('organization.settings.sharedAgentsKbHint')" placement="top"
:show-delay="300">
<span class="hint-inner">
<t-icon name="info-circle" size="14px" />
{{ $t('organization.settings.sharedAgentsKbHintShort') }}
@@ -579,26 +537,27 @@
<p class="empty-subtext">{{ $t('organization.settings.noSharedAgentsTip') }}</p>
</div>
<div v-else class="shared-list">
<div
v-for="share in sharedAgents"
:key="share.id"
class="shared-item"
@mouseenter="onSharedAgentMouseEnter(share, $event)"
@mousemove="onSharedAgentMouseMove($event)"
@mouseleave="onSharedAgentMouseLeave"
>
<div v-for="share in sharedAgents" :key="share.id" class="shared-item"
@mouseenter="onSharedAgentMouseEnter(share, $event)" @mousemove="onSharedAgentMouseMove($event)"
@mouseleave="onSharedAgentMouseLeave">
<div class="shared-icon shared-icon-agent-wrap">
<AgentAvatar :name="share.agent_name || share.agent_id" size="small" />
</div>
<div class="shared-info">
<span class="shared-name">{{ share.agent_name || share.agent_id }}</span>
<div class="shared-meta">
<span v-if="share.shared_by_username" class="shared-by"><t-icon name="user" size="12px" />{{ share.shared_by_username }}</span>
<span class="shared-time"><t-icon name="time" size="12px" />{{ formatDate(share.created_at) }}</span>
<span v-if="share.shared_by_username" class="shared-by"><t-icon name="user" size="12px" />{{
share.shared_by_username }}</span>
<span class="shared-time"><t-icon name="time" size="12px" />{{ formatDate(share.created_at)
}}</span>
</div>
</div>
<t-popconfirm v-if="isAdmin" :content="$t('organization.settings.removeAgentShareConfirm', { name: share.agent_name || share.agent_id })" :confirm-btn="{ content: $t('common.confirm'), theme: 'danger' }" :cancel-btn="{ content: $t('common.cancel') }" @confirm="handleRemoveAgentShare(share)">
<t-button variant="text" size="small" theme="danger" class="shared-remove-btn" @click.stop><t-icon name="delete" size="16px" /></t-button>
<t-popconfirm v-if="isAdmin"
:content="$t('organization.settings.removeAgentShareConfirm', { name: share.agent_name || share.agent_id })"
:confirm-btn="{ content: $t('common.confirm'), theme: 'danger' }"
:cancel-btn="{ content: $t('common.cancel') }" @confirm="handleRemoveAgentShare(share)">
<t-button variant="text" size="small" theme="danger" class="shared-remove-btn"
@click.stop><t-icon name="delete" size="16px" /></t-button>
</t-popconfirm>
</div>
</div>
@@ -610,13 +569,11 @@
<!-- 共享智能体 hover 跟随气泡 -->
<Teleport to="body">
<Transition name="agent-scope-popover-fade">
<div
v-if="agentScopePopover"
class="agent-scope-popover-follow"
:style="agentScopePopoverStyle"
>
<div v-if="agentScopePopover" class="agent-scope-popover-follow" :style="agentScopePopoverStyle">
<div class="agent-scope-popover-card">
<div class="agent-scope-popover-name">{{ agentScopePopover.share.agent_name || agentScopePopover.share.agent_id }}</div>
<div class="agent-scope-popover-name">{{ agentScopePopover.share.agent_name ||
agentScopePopover.share.agent_id
}}</div>
<div class="agent-scope-popover-meta">
<span v-if="agentScopePopover.share.shared_by_username" class="popover-meta-item">
<t-icon name="user" size="12px" /> {{ agentScopePopover.share.shared_by_username }}
@@ -632,7 +589,8 @@
<template v-if="getAgentScopeTags(agentScopePopover.share).length">
<div class="agent-scope-popover-divider" />
<div class="agent-scope-popover-section-title">{{ $t('agent.shareScope.title') }}</div>
<div v-for="(tag, idx) in getAgentScopeTags(agentScopePopover.share)" :key="idx" class="agent-scope-popover-row">{{ tag }}</div>
<div v-for="(tag, idx) in getAgentScopeTags(agentScopePopover.share)" :key="idx"
class="agent-scope-popover-row">{{ tag }}</div>
</template>
</div>
</div>
@@ -642,12 +600,7 @@
<!-- 底部操作按钮 -->
<div class="settings-footer">
<t-button variant="outline" @click="handleClose">{{ $t('common.cancel') }}</t-button>
<t-button
v-if="isAdmin"
theme="primary"
:loading="submitting"
@click="handleSave"
>
<t-button v-if="isAdmin" theme="primary" :loading="submitting" @click="handleSave">
{{ isCreateMode ? $t('common.create') : $t('common.save') }}
</t-button>
</div>
@@ -658,25 +611,15 @@
</Transition>
<!-- 移除成员确认弹窗 -->
<t-dialog
v-model:visible="showRemoveDialog"
:header="$t('organization.detail.removeMemberTitle')"
theme="warning"
:confirm-btn="$t('common.confirm')"
:cancel-btn="$t('common.cancel')"
@confirm="confirmRemoveMember"
>
<t-dialog v-model:visible="showRemoveDialog" :header="$t('organization.detail.removeMemberTitle')" theme="warning"
:confirm-btn="$t('common.confirm')" :cancel-btn="$t('common.cancel')" @confirm="confirmRemoveMember">
<p>{{ $t('organization.detail.removeMemberConfirm', { name: removingMember?.username }) }}</p>
</t-dialog>
<!-- 申请权限升级弹窗 -->
<t-dialog
v-model:visible="showUpgradeDialog"
:header="$t('organization.upgrade.dialogTitle')"
:confirm-btn="{ content: $t('common.confirm'), loading: upgradeSubmitting }"
:cancel-btn="$t('common.cancel')"
@confirm="handleSubmitUpgrade"
>
<t-dialog v-model:visible="showUpgradeDialog" :header="$t('organization.upgrade.dialogTitle')"
:confirm-btn="{ content: $t('common.confirm'), loading: upgradeSubmitting }" :cancel-btn="$t('common.cancel')"
@confirm="handleSubmitUpgrade">
<div class="upgrade-dialog-content">
<p class="upgrade-current-role">
{{ $t('organization.upgrade.currentRole') }}
@@ -686,51 +629,36 @@
</p>
<div class="upgrade-form-item">
<label>{{ $t('organization.upgrade.selectRole') }}</label>
<t-select v-model="upgradeForm.requested_role" :options="upgradeRoleOptions" :placeholder="$t('organization.upgrade.selectRole')" />
<t-select v-model="upgradeForm.requested_role" :options="upgradeRoleOptions"
:placeholder="$t('organization.upgrade.selectRole')" />
</div>
<div class="upgrade-form-item">
<label>{{ $t('organization.upgrade.reason') }}</label>
<t-textarea
v-model="upgradeForm.message"
:placeholder="$t('organization.upgrade.reasonPlaceholder')"
:autosize="{ minRows: 2, maxRows: 4 }"
:maxlength="500"
/>
<t-textarea v-model="upgradeForm.message" :placeholder="$t('organization.upgrade.reasonPlaceholder')"
:autosize="{ minRows: 2, maxRows: 4 }" :maxlength="500" />
</div>
</div>
</t-dialog>
<!-- 添加成员弹窗 -->
<t-dialog
v-model:visible="showAddMemberDialog"
:header="$t('organization.addMember.dialogTitle')"
:confirm-btn="{ content: $t('organization.addMember.confirmBtn'), loading: addMemberSubmitting, disabled: !selectedUser }"
:cancel-btn="$t('common.cancel')"
@confirm="handleAddMember"
@close="resetAddMemberDialog"
width="420px"
>
<!-- 添加成员弹窗按租户邀请 -->
<t-dialog v-model:visible="showAddMemberDialog" :header="$t('organization.addMember.dialogTitle')"
:confirm-btn="{ content: $t('organization.addMember.confirmBtn'), loading: addMemberSubmitting, disabled: selectedTenantId == null }"
:cancel-btn="$t('common.cancel')" @confirm="handleAddMember" @close="resetAddMemberDialog" width="420px">
<div class="add-member-dialog">
<p class="add-member-tip">{{ $t('organization.addMember.tip') }}</p>
<p class="add-member-tip">{{ $t('organization.addMember.tipTenant') }}</p>
<div class="add-member-field">
<label>{{ $t('organization.addMember.searchUser') }}</label>
<t-select
v-model="selectedUser"
:placeholder="$t('organization.addMember.searchPlaceholder')"
filterable
:filter="() => true"
:loading="userSearchLoading"
@search="handleUserSearch"
clearable
:options="userSearchOptions"
/>
<p class="field-hint">{{ $t('organization.addMember.searchHint') }}</p>
<label>{{ $t('organization.addMember.searchTenant') }}</label>
<t-select v-model="selectedTenantId" :placeholder="$t('organization.addMember.searchTenantPlaceholder')"
filterable :filter="() => true" :loading="tenantSearchLoading" @search="handleTenantSearch" clearable
:options="tenantSearchOptions" />
<p class="field-hint">{{ $t('organization.addMember.searchTenantHint') }}</p>
</div>
<div class="add-member-field">
<label>{{ $t('organization.addMember.selectRole') }}</label>
<t-select v-model="addMemberRole" :options="addMemberRoleOptions" :placeholder="$t('organization.addMember.selectRole')" />
<t-select v-model="addMemberRole" :options="addMemberRoleOptions"
:placeholder="$t('organization.addMember.selectRole')" />
</div>
</div>
</t-dialog>
@@ -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<string>('')
const tenantSearchLoading = ref(false)
const tenantSearchResults = ref<TenantInviteCandidate[]>([])
const selectedTenantId = ref<number | null>(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<typeof setTimeout> | null = null
const handleUserSearch = (query: string) => {
if (userSearchTimer) {
clearTimeout(userSearchTimer)
// 添加成员:搜索租户(按租户名 / 用户名 / 邮箱模糊匹配,按 tenant_id 去重)
let tenantSearchTimer: ReturnType<typeof setTimeout> | 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_idrepresentative_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;

View File

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

View File

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

View File

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

View File

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