diff --git a/frontend/src/api/tenant/members.ts b/frontend/src/api/tenant/members.ts index f2976578..b48e41b3 100644 --- a/frontend/src/api/tenant/members.ts +++ b/frontend/src/api/tenant/members.ts @@ -93,3 +93,13 @@ export async function removeMember( ): Promise { 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 { + return (await post(`/api/v1/tenants/${tenantId}/leave`)) as unknown as SimpleResponse +} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 88c4e284..d60719ae 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -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.', }, }, diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 05dc2850..ba7eccf5 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -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: "오류가 발생했습니다. 다시 시도해 주세요.", }, }, diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index fd43b82b..6eac3604 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -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: 'Что-то пошло не так. Попробуйте ещё раз.', }, }, diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 814330d3..5edd9cfc 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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: "操作失败,请稍后重试。", }, }, diff --git a/frontend/src/views/settings/TenantMembers.vue b/frontend/src/views/settings/TenantMembers.vue index 68a15753..63f68f2a 100644 --- a/frontend/src/views/settings/TenantMembers.vue +++ b/frontend/src/views/settings/TenantMembers.vue @@ -5,19 +5,72 @@

{{ $t('tenantMember.sectionDescription') }}

- + +
+
+ {{ $t('tenantMember.permissions.title') }} + {{ $t('tenantMember.permissions.desc') }} +
+
+
+
+ + {{ $t('tenantMember.role.' + r) }} + {{ $t('common.me') }} +
+
+ + + {{ $t('tenantMember.permissions.' + perm.key) }} + +
+
+
+
+ +
{{ $t('tenantMember.totalCount', { n: members.length }) }} - - {{ $t('tenantMember.add.button') }} - +
+ + + + + {{ $t('tenantMember.add.button') }} + + + {{ $t('tenantMember.leave.button') }} + +
@@ -35,11 +88,23 @@ + +
+ +
+ @@ -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; +} diff --git a/internal/handler/tenant_member.go b/internal/handler/tenant_member.go index 20e25a90..1f84f1b4 100644 --- a/internal/handler/tenant_member.go +++ b/internal/handler/tenant_member.go @@ -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}) +} diff --git a/internal/handler/tenant_member_test.go b/internal/handler/tenant_member_test.go index cebb21f1..3dc3fed1 100644 --- a/internal/handler/tenant_member_test.go +++ b/internal/handler/tenant_member_test.go @@ -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") + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 703390f1..df6c4721 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) } } }