fix(rbac): polish tenant member UI; add /leave endpoint
Self-review of PR 3's TenantMembers.vue surfaced a handful of real bugs
and a few rough edges. This commit fixes them and adds the self-leave
endpoint that PR 3's design called out as a follow-up.
Frontend bug fixes
- Read err.status (not err.response.status). The axios interceptor in
utils/request.ts already flattens errors into { status, message,
... }, so the original branch-by-status code never matched and every
4xx fell through to the generic toast. Fixes 404/409 surfacing as
"userNotFound" / "alreadyMember" / "lastOwner" instead of "Something
went wrong".
- t-dialog @confirm replaces :on-confirm so the dialog stays open on
validation failure and on API error. Users can fix and retry without
retyping the email.
- Drop the t-select revert hack; with :model-value the rendered value
is already source-of-truth-on-row.role, so no setTimeout dance is
needed.
- Don't toast "no active tenant" on cold mount; watch the activeTenantId
ref and load once the auth store hydrates.
Frontend UX additions
- Empty state via t-empty (also covers the search-no-results case).
- Compact role-permissions matrix above the table, mirroring
OrganizationSettingsModal so Owners can see what each role grants
before promoting/inviting.
- Search box filtering by username/email (client-side, sufficient for
expected per-tenant member counts).
- "Leave Tenant" button — visible to any active member except the
last-Owner case (server still enforces).
Backend
- New POST /tenants/:id/leave route. Calls TenantMemberService.RemoveMember
with the caller's own user id so non-Owners can quit on their own.
Gated Viewer+ in the matrix; the existing last-Owner invariant in the
service still rejects last-Owner self-leave with 409.
- 3 new handler tests cover the happy path, the last-Owner 409 path,
and the missing-caller-id 401 path (defensive — auth middleware
always attaches a user, but the test pins the contract).
i18n
- Add tenantMember.permissions / search / leave / empty strings in all
four locales (en/zh/ko/ru).
This commit is contained in:
@@ -93,3 +93,13 @@ export async function removeMember(
|
||||
): Promise<SimpleResponse> {
|
||||
return (await del(`/api/v1/tenants/${tenantId}/members/${userId}`)) as unknown as SimpleResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit the tenant on your own. Same last-Owner invariant as
|
||||
* removeMember, but does NOT require Owner+ — any active member can
|
||||
* call it.
|
||||
* Backend: POST /api/v1/tenants/:id/leave (Viewer+).
|
||||
*/
|
||||
export async function leaveTenant(tenantId: number): Promise<SimpleResponse> {
|
||||
return (await post(`/api/v1/tenants/${tenantId}/leave`)) as unknown as SimpleResponse
|
||||
}
|
||||
|
||||
@@ -4309,6 +4309,18 @@ export default {
|
||||
totalCount: '{n} members',
|
||||
loading: 'Loading members…',
|
||||
retry: 'Retry',
|
||||
empty: 'No members yet. Invite a teammate to get started.',
|
||||
emptySearch: 'No members match "{q}".',
|
||||
searchPlaceholder: 'Search by name or email',
|
||||
permissions: {
|
||||
title: 'Role permissions',
|
||||
desc: 'What each role can do inside this tenant. Server-enforced; UI controls reflect the rules but are not the source of truth.',
|
||||
manageMembers: 'Manage members',
|
||||
manageTenantConfig: 'Edit tenant settings',
|
||||
manageInfra: 'Configure models / vector stores / IM channels',
|
||||
createOwnKB: 'Create and edit own KBs and agents',
|
||||
readAll: 'Read tenant content',
|
||||
},
|
||||
columns: {
|
||||
username: 'Name',
|
||||
email: 'Email',
|
||||
@@ -4337,6 +4349,13 @@ export default {
|
||||
confirm: 'Remove',
|
||||
success: 'Member removed',
|
||||
},
|
||||
leave: {
|
||||
button: 'Leave Tenant',
|
||||
confirmTitle: 'Leave this tenant?',
|
||||
confirmBody: 'You will lose access to all knowledge bases and agents in this tenant. You can be re-invited later.',
|
||||
confirm: 'Leave',
|
||||
success: 'You have left the tenant',
|
||||
},
|
||||
roleChange: {
|
||||
success: 'Role updated',
|
||||
},
|
||||
@@ -4346,9 +4365,10 @@ export default {
|
||||
roleRequired: 'Role is required',
|
||||
userNotFound: 'No registered user with this email. Ask them to sign up first.',
|
||||
alreadyMember: 'This user is already a member of the tenant.',
|
||||
lastOwner: 'Cannot demote or remove the last Owner. Promote another member to Owner first.',
|
||||
lastOwner: 'Cannot demote, remove, or leave as the last Owner. Promote another member to Owner first.',
|
||||
notFound: 'Membership not found.',
|
||||
noTenant: 'No active tenant.',
|
||||
invalidRole: 'Role must be one of owner / admin / contributor / viewer.',
|
||||
generic: 'Something went wrong. Please try again.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4369,6 +4369,18 @@ export default {
|
||||
totalCount: "총 {n}명",
|
||||
loading: "멤버를 불러오는 중…",
|
||||
retry: "다시 시도",
|
||||
empty: "아직 멤버가 없습니다. 동료를 초대해 시작하세요.",
|
||||
emptySearch: '"{q}"와 일치하는 멤버가 없습니다.',
|
||||
searchPlaceholder: "이름 또는 이메일로 검색",
|
||||
permissions: {
|
||||
title: "역할 권한",
|
||||
desc: "각 역할이 이 테넌트에서 할 수 있는 작업입니다. 서버에서 적용되며 UI 컨트롤은 표시만 합니다.",
|
||||
manageMembers: "멤버 관리",
|
||||
manageTenantConfig: "테넌트 설정 변경",
|
||||
manageInfra: "모델 / 벡터 저장소 / IM 채널 구성",
|
||||
createOwnKB: "내 지식 베이스 및 에이전트 생성/편집",
|
||||
readAll: "테넌트 콘텐츠 조회",
|
||||
},
|
||||
columns: {
|
||||
username: "이름",
|
||||
email: "이메일",
|
||||
@@ -4397,6 +4409,13 @@ export default {
|
||||
confirm: "제거",
|
||||
success: "멤버를 제거했습니다",
|
||||
},
|
||||
leave: {
|
||||
button: "테넌트 떠나기",
|
||||
confirmTitle: "이 테넌트에서 나가시겠어요?",
|
||||
confirmBody: "이 테넌트의 모든 지식 베이스와 에이전트에 접근할 수 없게 됩니다. 나중에 다시 초대받을 수 있습니다.",
|
||||
confirm: "나가기",
|
||||
success: "테넌트에서 나갔습니다",
|
||||
},
|
||||
roleChange: {
|
||||
success: "역할이 업데이트되었습니다",
|
||||
},
|
||||
@@ -4406,9 +4425,10 @@ export default {
|
||||
roleRequired: "역할을 선택하세요",
|
||||
userNotFound: "해당 이메일로 가입된 사용자가 없습니다. 먼저 가입을 요청하세요.",
|
||||
alreadyMember: "이미 이 테넌트의 멤버입니다.",
|
||||
lastOwner: "마지막 소유자를 강등하거나 제거할 수 없습니다. 다른 멤버를 먼저 소유자로 승격하세요.",
|
||||
lastOwner: "마지막 소유자를 강등, 제거, 또는 떠날 수 없습니다. 다른 멤버를 먼저 소유자로 승격하세요.",
|
||||
notFound: "멤버를 찾을 수 없습니다.",
|
||||
noTenant: "활성 테넌트가 없습니다.",
|
||||
invalidRole: "역할은 owner / admin / contributor / viewer 중 하나여야 합니다.",
|
||||
generic: "오류가 발생했습니다. 다시 시도해 주세요.",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4269,6 +4269,18 @@ export default {
|
||||
totalCount: 'Участников: {n}',
|
||||
loading: 'Загрузка участников…',
|
||||
retry: 'Повторить',
|
||||
empty: 'Участников пока нет. Пригласите коллегу, чтобы начать.',
|
||||
emptySearch: 'Нет участников по запросу "{q}".',
|
||||
searchPlaceholder: 'Поиск по имени или email',
|
||||
permissions: {
|
||||
title: 'Права ролей',
|
||||
desc: 'Что может делать каждая роль внутри этого тенанта. Контролируется сервером; элементы UI лишь отражают правила.',
|
||||
manageMembers: 'Управление участниками',
|
||||
manageTenantConfig: 'Изменение настроек тенанта',
|
||||
manageInfra: 'Настройка моделей / векторных хранилищ / IM-каналов',
|
||||
createOwnKB: 'Создание и редактирование своих БЗ и агентов',
|
||||
readAll: 'Чтение содержимого тенанта',
|
||||
},
|
||||
columns: {
|
||||
username: 'Имя',
|
||||
email: 'Email',
|
||||
@@ -4297,6 +4309,13 @@ export default {
|
||||
confirm: 'Удалить',
|
||||
success: 'Участник удалён',
|
||||
},
|
||||
leave: {
|
||||
button: 'Покинуть тенант',
|
||||
confirmTitle: 'Покинуть этот тенант?',
|
||||
confirmBody: 'Вы потеряете доступ ко всем базам знаний и агентам в этом тенанте. Позже вас можно будет пригласить снова.',
|
||||
confirm: 'Покинуть',
|
||||
success: 'Вы покинули тенант',
|
||||
},
|
||||
roleChange: {
|
||||
success: 'Роль обновлена',
|
||||
},
|
||||
@@ -4306,9 +4325,10 @@ export default {
|
||||
roleRequired: 'Выберите роль',
|
||||
userNotFound: 'Пользователь с таким email не зарегистрирован. Попросите его зарегистрироваться.',
|
||||
alreadyMember: 'Этот пользователь уже участник тенанта.',
|
||||
lastOwner: 'Нельзя понизить или удалить последнего Владельца. Сначала повысьте кого-то ещё до Владельца.',
|
||||
lastOwner: 'Нельзя понизить, удалить или покинуть как последний Владелец. Сначала повысьте кого-то ещё до Владельца.',
|
||||
notFound: 'Участник не найден.',
|
||||
noTenant: 'Нет активного тенанта.',
|
||||
invalidRole: 'Роль должна быть owner / admin / contributor / viewer.',
|
||||
generic: 'Что-то пошло не так. Попробуйте ещё раз.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4302,6 +4302,18 @@ export default {
|
||||
totalCount: "共 {n} 位成员",
|
||||
loading: "正在加载成员…",
|
||||
retry: "重试",
|
||||
empty: "尚未邀请任何成员。点击右上角邀请伙伴加入。",
|
||||
emptySearch: '没有匹配 "{q}" 的成员。',
|
||||
searchPlaceholder: "按姓名或邮箱搜索",
|
||||
permissions: {
|
||||
title: "角色权限说明",
|
||||
desc: "每个角色在当前租户内能做的事情。服务端强制执行,UI 控件仅作展示。",
|
||||
manageMembers: "管理成员",
|
||||
manageTenantConfig: "修改租户配置",
|
||||
manageInfra: "配置模型 / 向量库 / IM 通道",
|
||||
createOwnKB: "创建并编辑自己的知识库和智能体",
|
||||
readAll: "查看租户内容",
|
||||
},
|
||||
columns: {
|
||||
username: "姓名",
|
||||
email: "邮箱",
|
||||
@@ -4330,6 +4342,13 @@ export default {
|
||||
confirm: "移除",
|
||||
success: "已移除成员",
|
||||
},
|
||||
leave: {
|
||||
button: "退出租户",
|
||||
confirmTitle: "确认退出当前租户?",
|
||||
confirmBody: "退出后您将无法访问当前租户内的所有知识库和智能体。后续可被再次邀请加入。",
|
||||
confirm: "退出",
|
||||
success: "已退出当前租户",
|
||||
},
|
||||
roleChange: {
|
||||
success: "角色已更新",
|
||||
},
|
||||
@@ -4339,9 +4358,10 @@ export default {
|
||||
roleRequired: "请选择角色",
|
||||
userNotFound: "该邮箱尚未注册,请先邀请对方注册账号。",
|
||||
alreadyMember: "该用户已经是当前租户的成员。",
|
||||
lastOwner: "无法降级或移除最后一位 Owner,请先把其他成员提升为 Owner。",
|
||||
lastOwner: "无法降级、移除或退出最后一位 Owner,请先把其他成员提升为 Owner。",
|
||||
notFound: "未找到对应的成员。",
|
||||
noTenant: "当前没有可操作的租户。",
|
||||
invalidRole: "角色必须是 owner / admin / contributor / viewer 之一。",
|
||||
generic: "操作失败,请稍后重试。",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,19 +5,72 @@
|
||||
<p class="section-description">{{ $t('tenantMember.sectionDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Action bar: count + add-member button (Owner only). -->
|
||||
<!-- Compact role-permissions matrix. Mirrors the OrganizationSettingsModal
|
||||
pattern so Owners (and Admins, who can't add members) understand
|
||||
what each role can actually do before they invite or promote. -->
|
||||
<div class="permissions-compact">
|
||||
<div class="permissions-compact-header">
|
||||
<span class="permissions-compact-title">{{ $t('tenantMember.permissions.title') }}</span>
|
||||
<span class="permissions-compact-desc">{{ $t('tenantMember.permissions.desc') }}</span>
|
||||
</div>
|
||||
<div class="permissions-compact-grid">
|
||||
<div
|
||||
v-for="r in roleMatrixOrder"
|
||||
:key="r"
|
||||
:class="['perm-role-block', r, { 'is-me': currentRole === r }]"
|
||||
>
|
||||
<div class="perm-role-tag">
|
||||
<t-icon :name="roleMatrixIcon(r)" size="12px" />
|
||||
<span>{{ $t('tenantMember.role.' + r) }}</span>
|
||||
<span v-if="currentRole === r" class="me-badge">{{ $t('common.me') }}</span>
|
||||
</div>
|
||||
<div class="perm-items">
|
||||
<span
|
||||
v-for="(perm, i) in roleMatrix[r]"
|
||||
:key="i"
|
||||
:class="['perm-item', perm.has ? 'has' : 'no']"
|
||||
>
|
||||
<t-icon :name="perm.has ? 'check' : 'close'" size="12px" />
|
||||
{{ $t('tenantMember.permissions.' + perm.key) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action bar: count + search + add-member button (Owner only). -->
|
||||
<div class="action-bar">
|
||||
<span class="count">
|
||||
{{ $t('tenantMember.totalCount', { n: members.length }) }}
|
||||
</span>
|
||||
<t-button
|
||||
v-if="canManage"
|
||||
theme="primary"
|
||||
size="small"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
{{ $t('tenantMember.add.button') }}
|
||||
</t-button>
|
||||
<div class="action-bar-right">
|
||||
<t-input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('tenantMember.searchPlaceholder')"
|
||||
clearable
|
||||
size="small"
|
||||
style="width: 220px"
|
||||
>
|
||||
<template #prefix-icon><t-icon name="search" /></template>
|
||||
</t-input>
|
||||
<t-button
|
||||
v-if="canManage"
|
||||
theme="primary"
|
||||
size="small"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
{{ $t('tenantMember.add.button') }}
|
||||
</t-button>
|
||||
<t-button
|
||||
v-if="canLeave"
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
size="small"
|
||||
@click="confirmLeaveTenant"
|
||||
>
|
||||
{{ $t('tenantMember.leave.button') }}
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
@@ -35,11 +88,23 @@
|
||||
</t-alert>
|
||||
</div>
|
||||
|
||||
<!-- Empty state when there are no members at all OR the search query
|
||||
filters everything out. -->
|
||||
<div v-else-if="filteredMembers.length === 0" class="empty-state">
|
||||
<t-empty
|
||||
:description="
|
||||
searchQuery
|
||||
? $t('tenantMember.emptySearch', { q: searchQuery })
|
||||
: $t('tenantMember.empty')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Member table -->
|
||||
<t-table
|
||||
v-else
|
||||
row-key="user_id"
|
||||
:data="members"
|
||||
:data="filteredMembers"
|
||||
:columns="columns"
|
||||
size="medium"
|
||||
hover
|
||||
@@ -47,7 +112,7 @@
|
||||
>
|
||||
<template #role="{ row }">
|
||||
<t-select
|
||||
v-if="canManage && row.user_id !== authStore.user?.id"
|
||||
v-if="canManage && row.user_id !== currentUserId"
|
||||
:model-value="row.role"
|
||||
:options="roleOptions"
|
||||
size="small"
|
||||
@@ -61,7 +126,7 @@
|
||||
<template #joined_at="{ row }">{{ formatDate(row.joined_at) }}</template>
|
||||
<template #actions="{ row }">
|
||||
<t-button
|
||||
v-if="canManage && row.user_id !== authStore.user?.id"
|
||||
v-if="canManage && row.user_id !== currentUserId"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="small"
|
||||
@@ -72,14 +137,16 @@
|
||||
</template>
|
||||
</t-table>
|
||||
|
||||
<!-- Add dialog -->
|
||||
<!-- Add dialog. We use @confirm rather than :on-confirm so we can
|
||||
keep the dialog open on validation failure / API error and let
|
||||
the user retry without retyping. -->
|
||||
<t-dialog
|
||||
v-model:visible="addDialogVisible"
|
||||
:header="$t('tenantMember.add.dialogTitle')"
|
||||
:on-confirm="submitAdd"
|
||||
:confirm-btn="{ content: $t('tenantMember.add.submit'), loading: adding }"
|
||||
:cancel-btn="{ content: $t('common.cancel') }"
|
||||
width="480px"
|
||||
@confirm="submitAdd"
|
||||
>
|
||||
<t-form ref="addFormRef" :data="addForm" :rules="addFormRules" :label-width="80">
|
||||
<t-form-item :label="$t('tenantMember.add.emailLabel')" name="email">
|
||||
@@ -98,7 +165,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -107,6 +174,7 @@ import {
|
||||
addMember,
|
||||
updateMemberRole,
|
||||
removeMember,
|
||||
leaveTenant,
|
||||
type TenantMember,
|
||||
type TenantRole,
|
||||
} from '@/api/tenant/members'
|
||||
@@ -121,6 +189,7 @@ const error = ref('')
|
||||
const adding = ref(false)
|
||||
const addDialogVisible = ref(false)
|
||||
const addFormRef = ref<any>(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Add dialog model — reset on each open. Default role is contributor:
|
||||
// inviting a fresh member with viewer is too restrictive for the
|
||||
@@ -131,10 +200,21 @@ const addForm = reactive<{ email: string; role: TenantRole }>({
|
||||
role: 'contributor',
|
||||
})
|
||||
|
||||
// canManage gates every mutation widget. The server enforces the same
|
||||
// rule (Owner+); the UI gate just hides controls that would 403 anyway,
|
||||
// matching the security note in stores/auth.ts.
|
||||
const canManage = computed(() => authStore.currentTenantRole === 'owner')
|
||||
// Role-aware gates. The server enforces every mutation; UI gates here
|
||||
// are presentational only, matching the security note in stores/auth.ts.
|
||||
const currentRole = computed<TenantRole | ''>(() => (authStore.currentTenantRole || '') as TenantRole | '')
|
||||
const canManage = computed(() => currentRole.value === 'owner')
|
||||
// Anyone except the last Owner can leave; we additionally hide the
|
||||
// button for Owner-the-only-one because clicking would just bounce off
|
||||
// the server's last-Owner check. The server is still the source of
|
||||
// truth.
|
||||
const canLeave = computed(() => {
|
||||
if (!currentRole.value) return false
|
||||
if (currentRole.value !== 'owner') return true
|
||||
const owners = members.value.filter((m) => m.role === 'owner').length
|
||||
return owners > 1
|
||||
})
|
||||
const currentUserId = computed(() => authStore.user?.id ?? '')
|
||||
|
||||
// Use the active tenant id from the auth store; the route only allows
|
||||
// :id == active tenant (auth middleware enforces membership), so we
|
||||
@@ -148,6 +228,57 @@ const roleOptions = computed(() => [
|
||||
{ label: t('tenantMember.role.viewer'), value: 'viewer' },
|
||||
])
|
||||
|
||||
// Static role-permissions matrix. The keys reference i18n strings under
|
||||
// `tenantMember.permissions.*` so each locale can rephrase per culture.
|
||||
// Keep this aligned with the design-doc §4.3 matrix and the actual
|
||||
// PR 2 enforcement; if a permission moves between roles, update both
|
||||
// sides in the same PR.
|
||||
type RolePerm = { key: string; has: boolean }
|
||||
const roleMatrixOrder: TenantRole[] = ['owner', 'admin', 'contributor', 'viewer']
|
||||
const roleMatrix: Record<TenantRole, RolePerm[]> = {
|
||||
owner: [
|
||||
{ key: 'manageMembers', has: true },
|
||||
{ key: 'manageTenantConfig', has: true },
|
||||
{ key: 'manageInfra', has: true },
|
||||
{ key: 'createOwnKB', has: true },
|
||||
{ key: 'readAll', has: true },
|
||||
],
|
||||
admin: [
|
||||
{ key: 'manageMembers', has: false },
|
||||
{ key: 'manageTenantConfig', has: false },
|
||||
{ key: 'manageInfra', has: true },
|
||||
{ key: 'createOwnKB', has: true },
|
||||
{ key: 'readAll', has: true },
|
||||
],
|
||||
contributor: [
|
||||
{ key: 'manageMembers', has: false },
|
||||
{ key: 'manageTenantConfig', has: false },
|
||||
{ key: 'manageInfra', has: false },
|
||||
{ key: 'createOwnKB', has: true },
|
||||
{ key: 'readAll', has: true },
|
||||
],
|
||||
viewer: [
|
||||
{ key: 'manageMembers', has: false },
|
||||
{ key: 'manageTenantConfig', has: false },
|
||||
{ key: 'manageInfra', has: false },
|
||||
{ key: 'createOwnKB', has: false },
|
||||
{ key: 'readAll', has: true },
|
||||
],
|
||||
}
|
||||
|
||||
function roleMatrixIcon(role: TenantRole): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'crown'
|
||||
case 'admin':
|
||||
return 'user-safety'
|
||||
case 'contributor':
|
||||
return 'edit'
|
||||
default:
|
||||
return 'browse'
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{ colKey: 'username', title: t('tenantMember.columns.username'), ellipsis: true },
|
||||
{ colKey: 'email', title: t('tenantMember.columns.email'), ellipsis: true },
|
||||
@@ -195,9 +326,26 @@ function formatDate(s: string | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side filter by username/email; case-insensitive substring
|
||||
// match keeps the implementation tiny while covering the common
|
||||
// "find this person quickly" use case. Server-side search is overkill
|
||||
// for the expected per-tenant member counts.
|
||||
const filteredMembers = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return members.value
|
||||
return members.value.filter((m) => {
|
||||
return (
|
||||
(m.username || '').toLowerCase().includes(q) ||
|
||||
(m.email || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function loadMembers() {
|
||||
if (!activeTenantId.value) {
|
||||
error.value = t('tenantMember.errors.noTenant')
|
||||
// No active tenant yet — keep silent; the watch below will retry
|
||||
// once the auth store finishes hydrating. Showing an error toast
|
||||
// on cold-mount would flash on every refresh.
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
@@ -224,8 +372,9 @@ function openAddDialog() {
|
||||
|
||||
async function submitAdd() {
|
||||
// t-form's validate returns true on success or an object of field
|
||||
// errors on failure. We treat anything non-true as a validation
|
||||
// failure — keeps the dialog open with the inline messages shown.
|
||||
// errors on failure. Anything other than `true` => keep dialog open
|
||||
// with the inline messages shown. The @confirm event lets us control
|
||||
// visibility manually; :on-confirm would close on every return.
|
||||
const valid = await addFormRef.value?.validate?.()
|
||||
if (valid !== true) return
|
||||
|
||||
@@ -243,17 +392,18 @@ async function submitAdd() {
|
||||
MessagePlugin.error(resp.message || t('tenantMember.errors.generic'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Map the server's structured error responses (404 / 409 / 400) to
|
||||
// human-readable messages. The Axios layer surfaces them as
|
||||
// err.response?.data?.error in this codebase.
|
||||
const status = err?.response?.status
|
||||
const apiMsg = err?.response?.data?.error || err?.message
|
||||
// The axios interceptor already flattens errors to
|
||||
// { status, message, error?, ... }, so we read err.status directly
|
||||
// — err.response.status is undefined here.
|
||||
const status = err?.status
|
||||
if (status === 404) {
|
||||
MessagePlugin.error(t('tenantMember.errors.userNotFound'))
|
||||
} else if (status === 409) {
|
||||
MessagePlugin.error(t('tenantMember.errors.alreadyMember'))
|
||||
} else if (status === 400) {
|
||||
MessagePlugin.error(err?.message || t('tenantMember.errors.invalidRole'))
|
||||
} else {
|
||||
MessagePlugin.error(apiMsg || t('tenantMember.errors.generic'))
|
||||
MessagePlugin.error(err?.message || t('tenantMember.errors.generic'))
|
||||
}
|
||||
} finally {
|
||||
adding.value = false
|
||||
@@ -274,20 +424,16 @@ async function onRoleChange(row: TenantMember, newRole: string) {
|
||||
}
|
||||
MessagePlugin.error(resp.message || t('tenantMember.errors.generic'))
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status
|
||||
const status = err?.status
|
||||
if (status === 409) {
|
||||
MessagePlugin.error(t('tenantMember.errors.lastOwner'))
|
||||
} else if (status === 404) {
|
||||
MessagePlugin.error(t('tenantMember.errors.notFound'))
|
||||
} else {
|
||||
MessagePlugin.error(err?.response?.data?.error || err?.message || t('tenantMember.errors.generic'))
|
||||
MessagePlugin.error(err?.message || t('tenantMember.errors.generic'))
|
||||
}
|
||||
// Force the t-select to revert visually by mutating then restoring.
|
||||
// Vue's reactive system needs a real change to refire the bind.
|
||||
row.role = '' as TenantRole
|
||||
setTimeout(() => {
|
||||
row.role = prev
|
||||
}, 0)
|
||||
// The t-select is bound via :model-value (one-way), so its rendered
|
||||
// value stays at `prev` automatically — no DOM hack needed.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,13 +456,13 @@ function confirmRemove(row: TenantMember) {
|
||||
MessagePlugin.error(resp.message || t('tenantMember.errors.generic'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status
|
||||
const status = err?.status
|
||||
if (status === 409) {
|
||||
MessagePlugin.error(t('tenantMember.errors.lastOwner'))
|
||||
} else if (status === 404) {
|
||||
MessagePlugin.error(t('tenantMember.errors.notFound'))
|
||||
} else {
|
||||
MessagePlugin.error(err?.response?.data?.error || err?.message || t('tenantMember.errors.generic'))
|
||||
MessagePlugin.error(err?.message || t('tenantMember.errors.generic'))
|
||||
}
|
||||
} finally {
|
||||
dlg.destroy()
|
||||
@@ -326,8 +472,55 @@ function confirmRemove(row: TenantMember) {
|
||||
})
|
||||
}
|
||||
|
||||
function confirmLeaveTenant() {
|
||||
const dlg = DialogPlugin.confirm({
|
||||
header: t('tenantMember.leave.confirmTitle'),
|
||||
body: t('tenantMember.leave.confirmBody'),
|
||||
confirmBtn: { content: t('tenantMember.leave.confirm'), theme: 'danger' },
|
||||
cancelBtn: t('common.cancel'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const resp = await leaveTenant(activeTenantId.value)
|
||||
if (resp.success) {
|
||||
MessagePlugin.success(t('tenantMember.leave.success'))
|
||||
// The user is now no longer a member of this tenant. Logging
|
||||
// them out is the simplest correct behaviour; their next
|
||||
// login will land them in whatever home tenant they have
|
||||
// left, with PR 1's auto-promote covering the orphan case.
|
||||
authStore.logout()
|
||||
window.location.href = '/login'
|
||||
} else {
|
||||
MessagePlugin.error(resp.message || t('tenantMember.errors.generic'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
const status = err?.status
|
||||
if (status === 409) {
|
||||
MessagePlugin.error(t('tenantMember.errors.lastOwner'))
|
||||
} else {
|
||||
MessagePlugin.error(err?.message || t('tenantMember.errors.generic'))
|
||||
}
|
||||
} finally {
|
||||
dlg.destroy()
|
||||
}
|
||||
},
|
||||
onClose: () => dlg.destroy(),
|
||||
})
|
||||
}
|
||||
|
||||
// Re-load whenever the active tenant resolves (or changes via the
|
||||
// tenant switcher). onMounted alone would race with auth-store
|
||||
// hydration — currentTenantId is often 0 at the moment this component
|
||||
// mounts on a cold reload.
|
||||
watch(
|
||||
activeTenantId,
|
||||
(id) => {
|
||||
if (id) loadMembers()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadMembers()
|
||||
if (activeTenantId.value) loadMembers()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -352,16 +545,106 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.permissions-compact {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.permissions-compact-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.permissions-compact-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.permissions-compact-desc {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.permissions-compact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.perm-role-block {
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
background: var(--td-bg-color-container);
|
||||
|
||||
&.is-me {
|
||||
border-color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.perm-role-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
margin-bottom: 6px;
|
||||
|
||||
.me-badge {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--td-brand-color);
|
||||
padding: 1px 6px;
|
||||
background: var(--td-brand-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.perm-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
.perm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
&.has {
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
&.no {
|
||||
color: var(--td-text-color-disabled);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.action-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-inline,
|
||||
@@ -371,4 +654,10 @@ onMounted(() => {
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -310,3 +310,45 @@ func (h *TenantMemberHandler) RemoveMember(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// LeaveTenant godoc
|
||||
// @Summary 退出当前租户
|
||||
// @Description 调用方主动退出当前租户。等价于以自己的 user_id 调 RemoveMember,
|
||||
//
|
||||
// 但不需要 Owner 权限——非 Owner 也可以自助离开。最后一位 Owner 仍然不能离开
|
||||
// (需先把其他成员提升为 Owner),由服务层 ErrLastOwner 拦截。
|
||||
//
|
||||
// @Tags 租户成员
|
||||
// @Produce json
|
||||
// @Param id path string true "租户 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Security Bearer
|
||||
// @Router /tenants/{id}/leave [post]
|
||||
func (h *TenantMemberHandler) LeaveTenant(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
tenantID, ok := parseTenantIDFromPath(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
caller, ok := types.UserIDFromContext(ctx)
|
||||
if !ok || caller == "" {
|
||||
c.Error(apperrors.NewUnauthorizedError("caller user id missing from context"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.memberService.RemoveMember(ctx, caller, tenantID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMembershipNotFound):
|
||||
c.Error(apperrors.NewNotFoundError("you are not a member of this tenant"))
|
||||
case errors.Is(err, service.ErrLastOwner):
|
||||
c.Error(apperrors.NewConflictError(err.Error()))
|
||||
default:
|
||||
logger.Errorf(ctx, "LeaveTenant failed: user=%s tenant=%d err=%v",
|
||||
caller, tenantID, err)
|
||||
c.Error(apperrors.NewInternalServerError("failed to leave tenant").WithDetails(err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ func memberTestRouter(h *TenantMemberHandler) *gin.Engine {
|
||||
r.POST("/tenants/:id/members", h.AddMember)
|
||||
r.PUT("/tenants/:id/members/:user_id", h.UpdateMemberRole)
|
||||
r.DELETE("/tenants/:id/members/:user_id", h.RemoveMember)
|
||||
r.POST("/tenants/:id/leave", h.LeaveTenant)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -355,3 +356,65 @@ func TestTenantMember_RemoveMember_LastOwnerMaps409(t *testing.T) {
|
||||
t.Fatalf("last-owner remove must 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- LeaveTenant ----------
|
||||
|
||||
func TestTenantMember_LeaveTenant_HappyPath(t *testing.T) {
|
||||
// LeaveTenant must invoke RemoveMember with the caller's own user
|
||||
// id, regardless of any path :user_id segment. The route doesn't
|
||||
// have a :user_id; the caller's id comes from the auth context.
|
||||
ms := &stubMemberService{
|
||||
remove: func(_ context.Context, userID string, tenantID uint64) error {
|
||||
if userID != "u-self" || tenantID != 1 {
|
||||
t.Fatalf("LeaveTenant must use caller id; got user=%s tenant=%d", userID, tenantID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
h := NewTenantMemberHandler(ms, &stubMemberUserService{})
|
||||
|
||||
w := doJSON(t, memberTestRouter(h), http.MethodPost, "/tenants/1/leave", nil, "u-self")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantMember_LeaveTenant_LastOwnerMaps409(t *testing.T) {
|
||||
// The whole point of having a separate leave endpoint is that
|
||||
// non-Owners can quit, but the same last-Owner invariant still
|
||||
// applies — an Owner that's the only one left must transfer
|
||||
// ownership before they can leave.
|
||||
ms := &stubMemberService{
|
||||
remove: func(_ context.Context, _ string, _ uint64) error {
|
||||
return service.ErrLastOwner
|
||||
},
|
||||
}
|
||||
h := NewTenantMemberHandler(ms, &stubMemberUserService{})
|
||||
|
||||
w := doJSON(t, memberTestRouter(h), http.MethodPost, "/tenants/1/leave", nil, "u-only-owner")
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("last-owner self-leave must 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantMember_LeaveTenant_MissingCallerReturns401(t *testing.T) {
|
||||
// Defensive: if the auth middleware ever fails to attach a user id,
|
||||
// LeaveTenant must NOT silently call RemoveMember(""), it should
|
||||
// surface 401 so the caller knows their session is broken.
|
||||
called := false
|
||||
ms := &stubMemberService{
|
||||
remove: func(_ context.Context, _ string, _ uint64) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
h := NewTenantMemberHandler(ms, &stubMemberUserService{})
|
||||
|
||||
w := doJSON(t, memberTestRouter(h), http.MethodPost, "/tenants/1/leave", nil, "")
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing caller must 401, got %d", w.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("RemoveMember must not be called without a caller id")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +404,7 @@ func RegisterChatRoutes(r *gin.RouterGroup, handler *session.Handler, g *rbacGua
|
||||
// - POST /:id/members Owner+ (only Owner can add new members)
|
||||
// - PUT /:id/members/:user_id Owner+ (only Owner can change roles)
|
||||
// - DELETE /:id/members/:user_id Owner+ (only Owner can remove members)
|
||||
// - POST /:id/leave Viewer+ (any member can quit on their own)
|
||||
//
|
||||
// Cross-tenant superuser endpoints (/tenants/all, /tenants/search, POST
|
||||
// /tenants, GET /tenants, /tenants/kv/*) keep their existing handler-level
|
||||
@@ -437,11 +438,15 @@ func RegisterTenantRoutes(
|
||||
// Tenant member management (PR 3 of #1303). Listing is Viewer+
|
||||
// so any active member can see the roster; mutation is Owner+
|
||||
// because membership changes are the highest-impact tenant op.
|
||||
// /:id/leave is Viewer+ — any member can quit on their own; the
|
||||
// service still rejects when it would leave the tenant without
|
||||
// an Owner.
|
||||
if memberHandler != nil {
|
||||
tenantRoutes.GET("/:id/members", g.Viewer(), memberHandler.ListMembers)
|
||||
tenantRoutes.POST("/:id/members", g.Owner(), memberHandler.AddMember)
|
||||
tenantRoutes.PUT("/:id/members/:user_id", g.Owner(), memberHandler.UpdateMemberRole)
|
||||
tenantRoutes.DELETE("/:id/members/:user_id", g.Owner(), memberHandler.RemoveMember)
|
||||
tenantRoutes.POST("/:id/leave", g.Viewer(), memberHandler.LeaveTenant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user