已合并
fix:优化身份中心前端页面 #492
fix:优化身份中心前端页面 #492
已合并
王欣创建于 3 天前
33 个文件变更+1804-1191
@@ -157,18 +157,18 @@ SAML/OIDC Provider 必须先完成协议校验,再把验证后的 Claim 交给
157| 表 | 职责 |157| 表 | 职责 |
158|------|------|158|------|------|
159| `federation_connection` | 保存受信连接与本地组织的稳定绑定 |159| `federation_connection` | 保存受信连接与本地组织的稳定绑定 |
160-| `federated_identity` | 保存外部 Subject 到本地 `app_user.user_id` 的唯一映射;使用三个外部身份字段的稳定 SHA-256 摘要作为唯一键 |160+| `federated_identity` | 保存外部 Subject 到本地 `identity_user.user_id` 的唯一映射;使用三个外部身份字段的稳定 SHA-256 摘要作为唯一键 |
161| `federation_role_mapping` | 保存受信 Claim 精确值到本地角色的映射规则 |161| `federation_role_mapping` | 保存受信 Claim 精确值到本地角色的映射规则 |
162| `federation_login_state` | 保存有效期内的浏览器联合登录状态 |162| `federation_login_state` | 保存有效期内的浏览器联合登录状态 |
163| `federation_login_code` | 保存一次性换码的 SHA-256,不保存换码明文 |163| `federation_login_code` | 保存一次性换码的 SHA-256,不保存换码明文 |
164 164 
165`federation_connection` 中的组织绑定不允许通过普通组织删除接口破坏。虚拟用户仍是165`federation_connection` 中的组织绑定不允许通过普通组织删除接口破坏。虚拟用户仍是
166-标准 `app_user`,因此可直接使用现有 `/me`、`/me/orgs`、前端角色分流及已挂载的166+标准 `identity_user`,因此可直接使用现有 `/me`、`/me/orgs`、前端角色分流及已挂载的
167Manager 权限守卫。167Manager 权限守卫。
168 168 
169-身份库各类数据按职责分离:`app_user` 是本地用户和最终权限的唯一业务主体;169+身份库各类数据按职责分离:`identity_user` 是本地用户和最终权限的唯一业务主体;
170`auth_identity` 只保存本地用户名/口令等认证凭据;`federated_identity` 只保存稳定的170`auth_identity` 只保存本地用户名/口令等认证凭据;`federated_identity` 只保存稳定的
171-外部身份绑定和最近一次经 Provider 验证的属性;`org` 与 `user_org_membership` 管理本地171+外部身份绑定和最近一次经 Provider 验证的属性;`identity_org` 与 `identity_user_org_membership` 管理本地
172组织目录;`auth_session` 管理可撤销的 refresh token;access JWT 自包含且不落库。172组织目录;`auth_session` 管理可撤销的 refresh token;access JWT 自包含且不落库。
173外部身份摘要由 `connection_id``issuer``external_subject` 的原始 UTF-8 内容计算,173外部身份摘要由 `connection_id``issuer``external_subject` 的原始 UTF-8 内容计算,
174原字段仍完整保存并在读取时复核,因此身份匹配不依赖数据库字符集或大小写排序规则。174原字段仍完整保存并在读取时复核,因此身份匹配不依赖数据库字符集或大小写排序规则。
@@ -176,6 +176,36 @@ Manager 权限守卫。
176`federation_connection` 明确绑定到一个受控的本地虚拟组织,避免企业目录命名与平台176`federation_connection` 明确绑定到一个受控的本地虚拟组织,避免企业目录命名与平台
177业务组织发生碰撞。177业务组织发生碰撞。
178 178 
179+> **存量库升级**:框架只 `create_all` / 补列,不改主键与表名。涉及身份表时请按需手工执行:
180+>
181+> ```sql
182+> -- 1) 旧表名迁移(若仍是 app_user / org / iam_*)
183+> ALTER TABLE app_user RENAME TO identity_user;
184+> ALTER TABLE org RENAME TO identity_org;
185+> ALTER TABLE iam_user RENAME TO identity_user;
186+> ALTER TABLE iam_org RENAME TO identity_org;
187+> ALTER TABLE iam_user_org_membership RENAME TO identity_user_org_membership;
188+>
189+> -- 2) 自增主键 id(SQLite 需重建表;MySQL / PostgreSQL 示例)
190+> -- MySQL:
191+> ALTER TABLE identity_user ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT UNIQUE FIRST;
192+> ALTER TABLE identity_user DROP PRIMARY KEY, ADD PRIMARY KEY (id), ADD UNIQUE KEY uq_identity_user_user_id (user_id);
193+> ALTER TABLE identity_org ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT UNIQUE FIRST;
194+> ALTER TABLE identity_org DROP PRIMARY KEY, ADD PRIMARY KEY (id), ADD UNIQUE KEY uq_identity_org_group_id (group_id);
195+> -- PostgreSQL:
196+> ALTER TABLE identity_user ADD COLUMN id BIGSERIAL;
197+> ALTER TABLE identity_user DROP CONSTRAINT identity_user_pkey;
198+> ALTER TABLE identity_user ADD PRIMARY KEY (id);
199+> ALTER TABLE identity_user ADD CONSTRAINT uq_identity_user_user_id UNIQUE (user_id);
200+> ALTER TABLE identity_org ADD COLUMN id BIGSERIAL;
201+> ALTER TABLE identity_org DROP CONSTRAINT identity_org_pkey;
202+> ALTER TABLE identity_org ADD PRIMARY KEY (id);
203+> ALTER TABLE identity_org ADD CONSTRAINT uq_identity_org_group_id UNIQUE (group_id);
204+> ```
205+>
206+> -- 3) identity_org.name → display_name(若列仍叫 name)
207+> ALTER TABLE identity_org RENAME COLUMN name TO display_name;
208+ 
179---209---
180 210 
181## 生产 / 集成模式(统一入口)211## 生产 / 集成模式(统一入口)
Mapplications/manager/identity_center/src/identity_center/core/auth/service.py+31-21文件内容审核中,请稍后刷新重试
Mapplications/manager/identity_center/src/identity_center/core/iam/services.py+433-317文件内容审核中,请稍后刷新重试
@@ -42,7 +42,7 @@ class Settings(BaseSettings):
42 jwt_audience: str = Field(default="openjiuwen", validation_alias="IDENTITY_JWT_AUDIENCE")42 jwt_audience: str = Field(default="openjiuwen", validation_alias="IDENTITY_JWT_AUDIENCE")
43 access_ttl_seconds: int = Field(default=1800, validation_alias="IDENTITY_ACCESS_TTL")43 access_ttl_seconds: int = Field(default=1800, validation_alias="IDENTITY_ACCESS_TTL")
44 refresh_ttl_seconds: int = Field(default=7 * 24 * 3600, validation_alias="IDENTITY_REFRESH_TTL")44 refresh_ttl_seconds: int = Field(default=7 * 24 * 3600, validation_alias="IDENTITY_REFRESH_TTL")
45- # JWT 签名密钥落身份库(表 identity_jwt_signing_key,生成一次→落库→多副本读同一行)。45+ # JWT 签名密钥落身份库(表 auth_jwt_signing_key,生成一次→落库→多副本读同一行)。
46 46 
47 # ---- 联合认证(当前仓库仅提供显式开启的本地 Demo Provider)----47 # ---- 联合认证(当前仓库仅提供显式开启的本地 Demo Provider)----
48 federation_demo_enabled: bool = Field(48 federation_demo_enabled: bool = Field(
Mapplications/manager/identity_center/tests/unit_tests/test_federation_store.py+349-349文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/components/MatchExprEditor.tsx+1-1文件内容审核中,请稍后刷新重试
@@ -130,7 +130,8 @@
130 "modifiedAt": "Modified At",130 "modifiedAt": "Modified At",
131 "viewBrief": "Card view",131 "viewBrief": "Card view",
132 "viewList": "List view",132 "viewList": "List view",
133- "searchPlaceholder": "Search instance name, ID, or status"133+ "searchPlaceholder": "Search instance name, ID, or status",
134+ "probeFailed": "Health probe failed: {{detail}}"
134 },135 },
135 "instanceForm": {136 "instanceForm": {
136 "name": "Instance Name",137 "name": "Instance Name",
@@ -388,7 +389,7 @@
388 },389 },
389 "embeddingTemplate": {390 "embeddingTemplate": {
390 "title": "Embedding Templates",391 "title": "Embedding Templates",
391- "subtitle": "Maintain reusable embedding model configurations",392+ "subtitle": "Maintain embedding model templates",
392 "new": "New Embedding Template",393 "new": "New Embedding Template",
393 "edit": "Edit Embedding Template",394 "edit": "Edit Embedding Template",
394 "templateName": "Template Name",395 "templateName": "Template Name",
@@ -459,8 +460,8 @@
459 "deleteConfirm": "Delete this skill whitelist template? It may be referenced by instance resources."460 "deleteConfirm": "Delete this skill whitelist template? It may be referenced by instance resources."
460 },461 },
461 "serviceConfigTemplate": {462 "serviceConfigTemplate": {
462- "title": "Service Config Templates",463+ "title": "Service Resource Templates",
463- "subtitle": "Maintain AgentServer container runtime and session parameter templates for Gateway Runtime to start AgentServer and manage sessions",464+ "subtitle": "Maintain AgentServer session parameters and container runtime templates",
464 "new": "New Template",465 "new": "New Template",
465 "edit": "Edit Template",466 "edit": "Edit Template",
466 "editSubtitle": "HLD three-part config: template policy + AgentServer main + Sandbox sidecar",467 "editSubtitle": "HLD three-part config: template policy + AgentServer main + Sandbox sidecar",
@@ -635,15 +636,19 @@
635 },636 },
636 "iam": {637 "iam": {
637 "users": "User Management",638 "users": "User Management",
639+ "usersSubtitle": "Manage users and roles",
640+ "usersSearchPlaceholder": "Search user ID or display name",
638 "orgs": "Org Management",641 "orgs": "Org Management",
642+ "orgsSubtitle": "Manage organizations and membership",
643+ "orgsSearchPlaceholder": "Search organization ID or display name",
639 "bots": "Agent Templates",644 "bots": "Agent Templates",
640 "newUser": "New User",645 "newUser": "New User",
641 "batchNewUser": "Batch Create",646 "batchNewUser": "Batch Create",
642 "batchImport": "Import",647 "batchImport": "Import",
643 "batchDownloadTemplate": "Download Template",648 "batchDownloadTemplate": "Download Template",
644- "batchHint": "Upload .xlsx/.csv. Columns: username, password, display_name, is_admin, orgs. Invalid orgs are ignored (→ no org).",649+ "batchHint": "Upload .xlsx/.csv. Columns: username, password, display_name, is_admin, orgs. Username: letters/digits/underscore/hyphen only. Invalid orgs are ignored (→ no org).",
645 "batchPreview": "Preview ({{n}} rows)",650 "batchPreview": "Preview ({{n}} rows)",
646- "batchInvalid": "{{n}} invalid (missing username/password)",651+ "batchInvalid": "{{n}} invalid (missing username/password, or username has invalid characters)",
647 "batchNoPwd": "no password",652 "batchNoPwd": "no password",
648 "batchSummary": "Total {{total}}, succeeded {{ok}}, failed {{failed}}",653 "batchSummary": "Total {{total}}, succeeded {{ok}}, failed {{failed}}",
649 "editUser": "Edit User",654 "editUser": "Edit User",
@@ -653,6 +658,8 @@
653 "editBot": "Edit Agent Template",658 "editBot": "Edit Agent Template",
654 "displayName": "Display Name",659 "displayName": "Display Name",
655 "username": "Username (for login)",660 "username": "Username (for login)",
661+ "usernameHint": "Letters, digits, underscore and hyphen only; also used as user ID",
662+ "idCharsetInvalid": "{{field}} may only contain letters, digits, underscore and hyphen",
656 "password": "Password",663 "password": "Password",
657 "resetPassword": "Reset password (blank = keep)",664 "resetPassword": "Reset password (blank = keep)",
658 "admin": "Admin",665 "admin": "Admin",
@@ -662,6 +669,7 @@
662 "name": "Name",669 "name": "Name",
663 "description": "Description",670 "description": "Description",
664 "groupId": "Group ID",671 "groupId": "Group ID",
672+ "groupIdHint": "Optional; auto-generated if blank. Letters, digits, underscore and hyphen only",
665 "userId": "User ID",673 "userId": "User ID",
666 "botId": "Template ID",674 "botId": "Template ID",
667 "role": "Role",675 "role": "Role",
@@ -764,8 +772,8 @@
764 "chatLoadingSlow": "Taking longer than usual. Please wait…"772 "chatLoadingSlow": "Taking longer than usual. Please wait…"
765 },773 },
766 "agentTemplate": {774 "agentTemplate": {
767- "title": "Agent Management",775+ "title": "Agent Templates",
768- "subtitle": "Maintain the platform Agent template catalog",776+ "subtitle": "Maintain reusable Agent definition templates for instance binding",
769 "new": "New Agent Template",777 "new": "New Agent Template",
770 "edit": "Edit Agent Template",778 "edit": "Edit Agent Template",
771 "templateName": "Template Name",779 "templateName": "Template Name",
@@ -130,7 +130,8 @@
130 "modifiedAt": "修改时间",130 "modifiedAt": "修改时间",
131 "viewBrief": "卡片视图",131 "viewBrief": "卡片视图",
132 "viewList": "列表视图",132 "viewList": "列表视图",
133- "searchPlaceholder": "搜索实例名称、实例 ID、状态"133+ "searchPlaceholder": "搜索实例名称、实例 ID、状态",
134+ "probeFailed": "探活失败:{{detail}}"
134 },135 },
135 "instanceForm": {136 "instanceForm": {
136 "name": "实例名称",137 "name": "实例名称",
@@ -388,7 +389,7 @@
388 },389 },
389 "embeddingTemplate": {390 "embeddingTemplate": {
390 "title": "Embedding 模板",391 "title": "Embedding 模板",
391- "subtitle": "维护可复用的向量模型配置",392+ "subtitle": "维护向量模型模板",
392 "new": "新建 Embedding 模板",393 "new": "新建 Embedding 模板",
393 "edit": "编辑 Embedding 模板",394 "edit": "编辑 Embedding 模板",
394 "templateName": "模板名称",395 "templateName": "模板名称",
@@ -459,8 +460,8 @@
459 "deleteConfirm": "确认删除该 Skill 白名单模板?该模板可能被多个实例资源引用,请确认无副作用。"460 "deleteConfirm": "确认删除该 Skill 白名单模板?该模板可能被多个实例资源引用,请确认无副作用。"
460 },461 },
461 "serviceConfigTemplate": {462 "serviceConfigTemplate": {
462- "title": "服务配置模板",463+ "title": "服务资源模板",
463- "subtitle": "维护AgentServer容器运行和会话参数模板,供Gateway Runtime启动AgentServer和管理会话使用",464+ "subtitle": "维护AgentServer会话参数和容器运行模板",
464 "new": "新建服务配置模板",465 "new": "新建服务配置模板",
465 "edit": "编辑服务配置模板",466 "edit": "编辑服务配置模板",
466 "editSubtitle": "按 HLD 三段式配置:模板级策略 + AgentServer 主容器 + Sandbox sidecar",467 "editSubtitle": "按 HLD 三段式配置:模板级策略 + AgentServer 主容器 + Sandbox sidecar",
@@ -635,15 +636,19 @@
635 },636 },
636 "iam": {637 "iam": {
637 "users": "用户管理",638 "users": "用户管理",
639+ "usersSubtitle": "管理用户与角色",
640+ "usersSearchPlaceholder": "搜索用户 ID、显示名",
638 "orgs": "组织管理",641 "orgs": "组织管理",
642+ "orgsSubtitle": "管理组织与成员归属",
643+ "orgsSearchPlaceholder": "搜索组织 ID、显示名",
639 "bots": "Agent 模板",644 "bots": "Agent 模板",
640 "newUser": "新建用户",645 "newUser": "新建用户",
641 "batchNewUser": "批量新建",646 "batchNewUser": "批量新建",
642 "batchImport": "确认导入",647 "batchImport": "确认导入",
643 "batchDownloadTemplate": "下载模板",648 "batchDownloadTemplate": "下载模板",
644- "batchHint": "上传 .xlsx/.csv。列:username、password、display_name、is_admin、orgs。无效组织自动忽略(→ 无组织)。",649+ "batchHint": "上传 .xlsx/.csv。列:username、password、display_name、is_admin、orgs。username 仅允许英文字母/数字/下划线/连字符。无效组织自动忽略(→ 无组织)。",
645 "batchPreview": "预览({{n}} 行)",650 "batchPreview": "预览({{n}} 行)",
646- "batchInvalid": "{{n}} 行无效(缺用户名/密码)",651+ "batchInvalid": "{{n}} 行无效(缺用户名/密码,或用户名含非法字符)",
647 "batchNoPwd": "无密码",652 "batchNoPwd": "无密码",
648 "batchSummary": "共 {{total}},成功 {{ok}},失败 {{failed}}",653 "batchSummary": "共 {{total}},成功 {{ok}},失败 {{failed}}",
649 "editUser": "编辑用户",654 "editUser": "编辑用户",
@@ -653,6 +658,8 @@
653 "editBot": "编辑 Agent 模板",658 "editBot": "编辑 Agent 模板",
654 "displayName": "显示名",659 "displayName": "显示名",
655 "username": "用户名(登录用)",660 "username": "用户名(登录用)",
661+ "usernameHint": "仅英文字母、数字、下划线和连字符;同时作为用户 ID",
662+ "idCharsetInvalid": "{{field}}仅允许英文字母、数字、下划线和连字符",
656 "password": "密码",663 "password": "密码",
657 "resetPassword": "重置密码(留空不改)",664 "resetPassword": "重置密码(留空不改)",
658 "admin": "管理员",665 "admin": "管理员",
@@ -662,6 +669,7 @@
662 "name": "名称",669 "name": "名称",
663 "description": "描述",670 "description": "描述",
664 "groupId": "组织 ID",671 "groupId": "组织 ID",
672+ "groupIdHint": "可选;留空自动生成。仅英文字母、数字、下划线和连字符",
665 "userId": "用户 ID",673 "userId": "用户 ID",
666 "botId": "模板 ID",674 "botId": "模板 ID",
667 "role": "角色",675 "role": "角色",
@@ -765,8 +773,8 @@
765 "chatLoadingSlow": "加载较慢,请稍候…"773 "chatLoadingSlow": "加载较慢,请稍候…"
766 },774 },
767 "agentTemplate": {775 "agentTemplate": {
768- "title": "Agent 管理",776+ "title": "Agent模板",
769- "subtitle": "维护平台 Agent 模板目录",777+ "subtitle": "维护可复用的Agent定义模板,供实例绑定使用",
770 "new": "新建 Agent 模板",778 "new": "新建 Agent 模板",
771 "edit": "编辑 Agent 模板",779 "edit": "编辑 Agent 模板",
772 "templateName": "模板名称",780 "templateName": "模板名称",
@@ -1,162 +1,260 @@
1import { useEffect, useMemo, useState } from 'react';1import { useEffect, useMemo, useState } from 'react';
2import { useTranslation } from 'react-i18next';2import { useTranslation } from 'react-i18next';
3+import { ConfirmDialog } from '../../components/ConfirmDialog';
4+import { Empty } from '../../components/Empty';
5+import { ListSearchInput } from '../../components/ListSearchInput';
3import { Modal, ModalCancelButton } from '../../components/Modal';6import { Modal, ModalCancelButton } from '../../components/Modal';
7+import { Pagination } from '../../components/Pagination';
8+import { TableColumnFilter } from '../../components/TableColumnFilter';
9+import {
10+ TableColumnSort,
11+ type ColumnSortValue,
12+} from '../../components/TableColumnSort';
4import { useAsync } from '../../hooks/useAsync';13import { useAsync } from '../../hooks/useAsync';
5import { useFormDirty } from '../../hooks/useFormDirty';14import { useFormDirty } from '../../hooks/useFormDirty';
6-import { ApiError, IamUser, InstanceBindingApi, NO_ORG_GROUP_ID, Org, OrgApi, UserApi } from '../../services/api';15+import { useListSearch } from '../../hooks/useListSearch';
16+import { ApiError, IamUser, NO_ORG_GROUP_ID, Org, OrgApi, UserApi } from '../../services/api';
7import { toast } from '../../stores/uiStore';17import { toast } from '../../stores/uiStore';
8-import { AddToInstanceModal, InstanceChips, InstanceFilter, instanceName, useInstances } from './instanceBinding';18+import { formatTime } from '../../utils/format';
19+ 
20+type OrgSortField = 'group_id' | 'display_name' | 'status' | 'updated_at';
9 21 
10export function OrgsPage() {22export function OrgsPage() {
11 const { t } = useTranslation();23 const { t } = useTranslation();
12- const { data, loading, reload } = useAsync(() => OrgApi.list(), []);24+ const [page, setPage] = useState(1);
13- const instances = useInstances();25+ const [pageSize, setPageSize] = useState(20);
26+ const { searchInput, setSearchInput, searchQuery } = useListSearch();
27+ const [statusFilter, setStatusFilter] = useState('');
28+ const [sortBy, setSortBy] = useState<OrgSortField | ''>('');
29+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
14 const [editing, setEditing] = useState<Org | null | undefined>(undefined); // undefined=关闭, null=新建30 const [editing, setEditing] = useState<Org | null | undefined>(undefined); // undefined=关闭, null=新建
15- const [managing, setManaging] = useState<Org | null>(null); // 正在管理成员的组织31+ const [managing, setManaging] = useState<Org | null>(null);
16- const [showAdd, setShowAdd] = useState(false);32+ const [delTarget, setDelTarget] = useState<Org | null>(null);
17- const [filterJid, setFilterJid] = useState('');33+ 
18- const [roster, setRoster] = useState<Set<string> | null>(null);34+ const sortOptions = useMemo(
19- const [bindings, setBindings] = useState<Record<string, string[]>>({});35+ () => [
20- const [checked, setChecked] = useState<Set<string>>(new Set());36+ { value: 'asc' as const, label: t('common.sortAsc') },
21- const orgs = data?.items ?? [];37+ { value: 'desc' as const, label: t('common.sortDesc') },
22- const orgIdsKey = orgs.map((o) => o.group_id).join(',');38+ { value: '' as const, label: t('common.sortDefault') },
39+ ],
40+ [t],
41+ );
42+ 
43+ const handleSortChange = (field: OrgSortField, value: ColumnSortValue) => {
44+ if (value === '') {
45+ setSortBy('');
46+ setSortOrder('asc');
47+ } else {
48+ setSortBy(field);
49+ setSortOrder(value);
50+ }
51+ setPage(1);
52+ };
23 53 
24 useEffect(() => {54 useEffect(() => {
25- setChecked(new Set());55+ setPage(1);
26- if (filterJid) {56+ }, [searchQuery]);
27- InstanceBindingApi.listOrgs(filterJid).then((r) => setRoster(new Set(r.group_ids))).catch(() => setRoster(new Set()));
28- } else {
29- setRoster(null);
30- if (orgs.length) {
31- InstanceBindingApi.orgGateways(orgs.map((o) => o.group_id)).then((r) => setBindings(r.bindings)).catch(() => setBindings({}));
32- }
33- }
34- }, [filterJid, orgIdsKey]); // eslint-disable-line react-hooks/exhaustive-deps
35 57 
36- // 无组织(__none__)也是合法可见性范围,同普通组织一样可绑定实例58+ const { data, loading, error, reload } = useAsync(
37- const shown = filterJid && roster ? orgs.filter((o) => roster.has(o.group_id)) : orgs;59+ () =>
60+ OrgApi.list({
61+ page,
62+ page_size: pageSize,
63+ search: searchQuery,
64+ status: statusFilter || undefined,
65+ sort_by: sortBy || undefined,
66+ sort_order: sortBy ? sortOrder : undefined,
67+ }),
68+ [page, pageSize, searchQuery, statusFilter, sortBy, sortOrder],
69+ );
38 70 
39- function reloadRoster() {71+ const items = data?.items ?? [];
40- if (filterJid) InstanceBindingApi.listOrgs(filterJid).then((r) => setRoster(new Set(r.group_ids))).catch(() => undefined);
41- }
42- 
43- async function onDelete(o: Org) {
44- if (!window.confirm(t('iam.confirmDeleteOrg', { name: o.name }))) return;
45- try {
46- await OrgApi.remove(o.group_id);
47- toast('success', t('success.deleted'));
48- reload();
49- } catch (e) {
50- toast('danger', e instanceof ApiError ? e.detail : String(e));
51- }
52- }
53- 
54- function toggleCheck(id: string) {
55- setChecked((prev) => {
56- const next = new Set(prev);
57- if (next.has(id)) next.delete(id); else next.add(id);
58- return next;
59- });
60- }
61- function toggleAll() {
62- setChecked((prev) => (prev.size === shown.length ? new Set() : new Set(shown.map((o) => o.group_id))));
63- }
64- 
65- async function onRemoveFromInstance() {
66- const ids = Array.from(checked);
67- if (!ids.length || !filterJid) return;
68- const name = instanceName(instances, filterJid);
69- if (!window.confirm(t('iam.confirmRemoveFromInstance', { defaultValue: '从实例「{{name}}」移除选中的 {{n}} 项?', name, n: ids.length }))) return;
70- try {
71- await InstanceBindingApi.unbindOrgs(filterJid, ids);
72- toast('success', t('success.saved'));
73- setChecked(new Set());
74- reloadRoster();
75- } catch (e) {
76- toast('danger', e instanceof ApiError ? e.detail : String(e));
77- }
78- }
79- 
80- const inInstanceMode = !!filterJid;
81- const cols = inInstanceMode ? 5 : 6;
82- const instName = filterJid ? instanceName(instances, filterJid) : '';
83 72 
84 return (73 return (
85- <div className="page">74+ <>
86- <div className="flex items-center justify-between mb-3">75+ <div className="flex min-w-0 flex-col gap-4">
87- <h2 className="card-title">{t('iam.orgs')}</h2>76+ <div className="page-header w-full min-w-0 flex-wrap items-start gap-y-3">
88- <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>77+ <div className="min-w-[7.5rem] max-w-[12rem] shrink-0 sm:max-w-[16rem]">
89- <InstanceFilter instances={instances} value={filterJid} onChange={setFilterJid} />78+ <div className="page-title truncate" title={t('iam.orgs')}>
90- {inInstanceMode && (79+ {t('iam.orgs')}
91- <>80+ </div>
92- <button className="btn danger" disabled={checked.size === 0} onClick={onRemoveFromInstance}>81+ <div className="page-subtitle truncate" title={t('iam.orgsSubtitle')}>
93- {t('iam.removeFromInstance', { defaultValue: '移除出 {{name}}', name: instName })}{checked.size ? `(${checked.size})` : ''}82+ {t('iam.orgsSubtitle')}
94- </button>83+ </div>
95- <button className="btn" onClick={() => setShowAdd(true)}>84+ </div>
96- {t('iam.addToInstance', { defaultValue: '添加到 {{name}}', name: instName })}85+ <div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2">
97- </button>86+ <ListSearchInput
98- </>87+ value={searchInput}
88+ onChange={setSearchInput}
89+ placeholder={t('iam.orgsSearchPlaceholder')}
90+ className="basis-full sm:basis-auto"
91+ />
92+ <button className="btn sm" onClick={() => void reload()}>
93+ {t('common.refresh')}
94+ </button>
95+ <button className="btn primary sm" onClick={() => setEditing(null)}>
96+ + {t('iam.newOrg')}
97+ </button>
98+ </div>
99+ </div>
100+ 
101+ <div className="flex w-full min-w-0 shrink-0 flex-col gap-4">
102+ <div className="card !p-0">
103+ {loading ? (
104+ <div className="p-4 text-sm text-muted">{t('common.loading')}</div>
105+ ) : error ? (
106+ <div className="p-4 text-sm text-danger">{t('errors.loadFailed', { detail: error })}</div>
107+ ) : (
108+ <div className="overflow-x-auto">
109+ <table className="table w-max min-w-full">
110+ <thead>
111+ <tr>
112+ <th className="w-[25rem] max-w-[25rem]">
113+ <TableColumnSort
114+ label={t('iam.groupId')}
115+ value={sortBy === 'group_id' ? sortOrder : ''}
116+ options={sortOptions}
117+ onChange={(value) => handleSortChange('group_id', value)}
118+ />
119+ </th>
120+ <th>
121+ <TableColumnSort
122+ label={t('iam.displayName')}
123+ value={sortBy === 'display_name' ? sortOrder : ''}
124+ options={sortOptions}
125+ onChange={(value) => handleSortChange('display_name', value)}
126+ />
127+ </th>
128+ <th>
129+ <div className="th-filter">
130+ <span className="th-filter__label">{t('iam.status')}</span>
131+ <TableColumnSort
132+ iconOnly
133+ label={t('iam.status')}
134+ value={sortBy === 'status' ? sortOrder : ''}
135+ options={sortOptions}
136+ onChange={(value) => handleSortChange('status', value)}
137+ />
138+ <TableColumnFilter
139+ iconOnly
140+ label={t('iam.status')}
141+ value={statusFilter}
142+ options={[
143+ { value: '', label: t('common.all') },
144+ { value: 'active', label: t('common.enabled') },
145+ { value: 'disabled', label: t('common.disabled') },
146+ ]}
147+ onChange={(value) => {
148+ setStatusFilter(value);
149+ setPage(1);
150+ }}
151+ />
152+ </div>
153+ </th>
154+ <th>
155+ <TableColumnSort
156+ label={t('common.updatedAt')}
157+ value={sortBy === 'updated_at' ? sortOrder : ''}
158+ options={sortOptions}
159+ onChange={(value) => handleSortChange('updated_at', value)}
160+ />
161+ </th>
162+ <th className="whitespace-nowrap min-w-[9.5rem]">{t('common.actions')}</th>
163+ </tr>
164+ </thead>
165+ <tbody>
166+ {items.length === 0 ? (
167+ <tr>
168+ <td colSpan={5}>
169+ <Empty text={t('common.empty')} />
170+ </td>
171+ </tr>
172+ ) : (
173+ items.map((o) => (
174+ <tr key={o.group_id}>
175+ <td
176+ className="mono text-[11px] text-muted w-[25rem] max-w-[25rem] break-all"
177+ title={o.group_id}
178+ >
179+ {o.group_id}
180+ </td>
181+ <td className="text-text-strong font-medium break-words">{o.display_name}</td>
182+ <td className="whitespace-nowrap">
183+ {o.status === 'active' ? t('common.enabled') : t('common.disabled')}
184+ </td>
185+ <td className="mono text-[11px] text-muted whitespace-nowrap">
186+ {formatTime(o.updated_at)}
187+ </td>
188+ <td className="whitespace-nowrap min-w-[9.5rem]">
189+ <div className="flex items-center gap-1">
190+ <button className="btn sm" onClick={() => setManaging(o)}>
191+ {t('iam.members')}
192+ </button>
193+ <button className="btn sm ghost" onClick={() => setEditing(o)}>
194+ {t('common.edit')}
195+ </button>
196+ <button className="btn sm danger" onClick={() => setDelTarget(o)}>
197+ {t('common.delete')}
198+ </button>
199+ </div>
200+ </td>
201+ </tr>
202+ ))
203+ )}
204+ </tbody>
205+ </table>
206+ </div>
207+ )}
208+ </div>
209+ 
210+ {data && (
211+ <Pagination
212+ page={page}
213+ pageSize={pageSize}
214+ total={data.total ?? data.items.length}
215+ onChange={(p, ps) => {
216+ setPage(p);
217+ setPageSize(ps);
218+ }}
219+ />
99 )}220 )}
100- <button className="btn primary" onClick={() => setEditing(null)}>{t('iam.newOrg')}</button>
101 </div>221 </div>
102 </div>222 </div>
103- <div className="card">223+ 
104- <table className="table" style={{ width: '100%' }}>
105- <thead>
106- <tr>
107- <th style={{ width: 32 }}>
108- <input type="checkbox" checked={shown.length > 0 && checked.size === shown.length} onChange={toggleAll} />
109- </th>
110- <th>{t('iam.groupId')}</th>
111- <th>{t('iam.name')}</th>
112- <th>{t('iam.status')}</th>
113- {!inInstanceMode && <th>{t('iam.belongInstances', { defaultValue: '所属实例' })}</th>}
114- <th>{t('common.actions')}</th>
115- </tr>
116- </thead>
117- <tbody>
118- {shown.map((o) => (
119- <tr key={o.group_id}>
120- <td><input type="checkbox" checked={checked.has(o.group_id)} onChange={() => toggleCheck(o.group_id)} /></td>
121- <td className="mono text-xs">{o.group_id}</td>
122- <td>{o.name}</td>
123- <td>{o.status}</td>
124- {!inInstanceMode && <td><InstanceChips jids={bindings[o.group_id] ?? []} instances={instances} /></td>}
125- <td style={{ textAlign: 'right' }}>
126- <button className="btn sm" onClick={() => setManaging(o)}>{t('iam.members')}</button>
127- <button className="btn sm" style={{ marginLeft: 6 }} onClick={() => setEditing(o)}>{t('common.edit')}</button>
128- <button className="btn sm danger" style={{ marginLeft: 6 }} onClick={() => onDelete(o)}>{t('common.delete')}</button>
129- </td>
130- </tr>
131- ))}
132- {!loading && shown.length === 0 && (
133- <tr><td colSpan={cols} className="text-muted">{t('iam.noOrgs')}</td></tr>
134- )}
135- </tbody>
136- </table>
137- </div>
138 {editing !== undefined && (224 {editing !== undefined && (
139- <OrgModal org={editing} onClose={() => setEditing(undefined)} onSaved={() => { setEditing(undefined); reload(); }} />225+ <OrgModal
140- )}226+ org={editing}
141- {managing && (227+ onClose={() => setEditing(undefined)}
142- <MembersModal org={managing} onClose={() => setManaging(null)} />228+ onSaved={() => {
143- )}229+ setEditing(undefined);
144- {showAdd && filterJid && (230+ void reload();
145- <AddToInstanceModal
146- title={t('iam.addToInstance', { defaultValue: '添加到 {{name}}', name: instName })}
147- candidates={orgs
148- .filter((o) => !roster?.has(o.group_id))
149- .map((o) => ({ id: o.group_id, label: o.name, sub: o.group_id }))}
150- onConfirm={async ({ ids }) => {
151- await InstanceBindingApi.bindOrgs(filterJid, ids);
152- toast('success', t('success.saved'));
153- setShowAdd(false);
154- reloadRoster();
155 }}231 }}
156- onClose={() => setShowAdd(false)}
157 />232 />
158 )}233 )}
159- </div>234+ {managing && <MembersModal org={managing} onClose={() => setManaging(null)} />}
235+ 
236+ <ConfirmDialog
237+ open={!!delTarget}
238+ message={t('iam.confirmDeleteOrg', { name: delTarget?.display_name ?? '' })}
239+ danger
240+ onConfirm={async () => {
241+ if (!delTarget) return;
242+ try {
243+ await OrgApi.remove(delTarget.group_id);
244+ toast('success', t('success.deleted'));
245+ void reload();
246+ } catch (e) {
247+ toast(
248+ 'danger',
249+ t('errors.deleteFailed', {
250+ detail: e instanceof ApiError ? e.detail : (e as Error).message,
251+ }),
252+ );
253+ }
254+ }}
255+ onClose={() => setDelTarget(null)}
256+ />
257+ </>
160 );258 );
161}259}
162 260 
@@ -208,11 +306,10 @@ function MembersModal({ org, onClose }: { org: Org; onClose: () => void }) {
208 return (306 return (
209 <Modal307 <Modal
210 open308 open
211- title={`${t('iam.members')} · ${org.name}`}309+ title={`${t('iam.members')} · ${org.display_name}`}
212 onClose={onClose}310 onClose={onClose}
213 footer={<button className="btn primary" onClick={onClose}>{t('common.close')}</button>}311 footer={<button className="btn primary" onClick={onClose}>{t('common.close')}</button>}
214 >312 >
215- {/* 当前成员 */}
216 <label className="label">{t('iam.currentMembers')} ({members.length})</label>313 <label className="label">{t('iam.currentMembers')} ({members.length})</label>
217 <div style={{ maxHeight: 200, overflow: 'auto', border: '1px solid var(--border, #ddd)', borderRadius: 6, padding: 8 }}>314 <div style={{ maxHeight: 200, overflow: 'auto', border: '1px solid var(--border, #ddd)', borderRadius: 6, padding: 8 }}>
218 {members.map((u) => (315 {members.map((u) => (
@@ -226,7 +323,6 @@ function MembersModal({ org, onClose }: { org: Org; onClose: () => void }) {
226 {!loading && members.length === 0 && <div className="text-xs text-muted">{t('iam.noMembers')}</div>}323 {!loading && members.length === 0 && <div className="text-xs text-muted">{t('iam.noMembers')}</div>}
227 </div>324 </div>
228 325 
229- {/* 添加成员(搜索全部用户) */}
230 {readOnly ? (326 {readOnly ? (
231 <div className="text-xs text-muted" style={{ marginTop: 12 }}>{t('iam.noOrgReadonly')}</div>327 <div className="text-xs text-muted" style={{ marginTop: 12 }}>{t('iam.noOrgReadonly')}</div>
232 ) : (328 ) : (
@@ -251,13 +347,13 @@ function MembersModal({ org, onClose }: { org: Org; onClose: () => void }) {
251function OrgModal({ org, onClose, onSaved }: { org: Org | null; onClose: () => void; onSaved: () => void }) {347function OrgModal({ org, onClose, onSaved }: { org: Org | null; onClose: () => void; onSaved: () => void }) {
252 const { t } = useTranslation();348 const { t } = useTranslation();
253 const { markClean, isDirty } = useFormDirty(true);349 const { markClean, isDirty } = useFormDirty(true);
254- const [name, setName] = useState(org?.name ?? '');350+ const [displayName, setDisplayName] = useState(org?.display_name ?? '');
255 const [status, setStatus] = useState(org?.status ?? 'active');351 const [status, setStatus] = useState(org?.status ?? 'active');
256 const [busy, setBusy] = useState(false);352 const [busy, setBusy] = useState(false);
257 353 
258 useEffect(() => {354 useEffect(() => {
259- const next = { name: org?.name ?? '', status: org?.status ?? 'active' };355+ const next = { displayName: org?.display_name ?? '', status: org?.status ?? 'active' };
260- setName(next.name);356+ setDisplayName(next.displayName);
261 setStatus(next.status);357 setStatus(next.status);
262 markClean(next);358 markClean(next);
263 }, [org, markClean]);359 }, [org, markClean]);
@@ -265,8 +361,8 @@ function OrgModal({ org, onClose, onSaved }: { org: Org | null; onClose: () => v
265 async function save() {361 async function save() {
266 setBusy(true);362 setBusy(true);
267 try {363 try {
268- if (org) await OrgApi.update(org.group_id, { name, status });364+ if (org) await OrgApi.update(org.group_id, { display_name: displayName, status });
269- else await OrgApi.create({ name });365+ else await OrgApi.create({ display_name: displayName });
270 toast('success', t('success.saved'));366 toast('success', t('success.saved'));
271 onSaved();367 onSaved();
272 } catch (e) {368 } catch (e) {
@@ -276,21 +372,24 @@ function OrgModal({ org, onClose, onSaved }: { org: Org | null; onClose: () => v
276 }372 }
277 }373 }
278 374 
375+ const draft = { displayName, status };
376+ const canSave = !!displayName.trim();
377+ 
279 return (378 return (
280 <Modal379 <Modal
281 open380 open
282 title={org ? t('iam.editOrg') : t('iam.newOrg')}381 title={org ? t('iam.editOrg') : t('iam.newOrg')}
283 onClose={onClose}382 onClose={onClose}
284- dirty={isDirty({ name, status })}383+ dirty={isDirty(draft)}
285 footer={384 footer={
286 <>385 <>
287 <ModalCancelButton className="btn" />386 <ModalCancelButton className="btn" />
288- <button className="btn primary" style={{ marginLeft: 8 }} disabled={busy || !name.trim()} onClick={save}>{t('common.save')}</button>387+ <button className="btn primary" style={{ marginLeft: 8 }} disabled={busy || !canSave} onClick={save}>{t('common.save')}</button>
289 </>388 </>
290 }389 }
291 >390 >
292- <label className="label">{t('iam.name')}</label>391+ <label className="label">{t('iam.displayName')}</label>
293- <input className="input" value={name} onChange={(e) => setName(e.target.value)} />392+ <input className="input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
294 {org && (393 {org && (
295 <>394 <>
296 <label className="label" style={{ marginTop: 12 }}>{t('iam.status')}</label>395 <label className="label" style={{ marginTop: 12 }}>{t('iam.status')}</label>
@@ -1,168 +1,311 @@
1-import { ChangeEvent, useEffect, useState } from 'react';1+import { ChangeEvent, useEffect, useMemo, useState } from 'react';
2import { useTranslation } from 'react-i18next';2import { useTranslation } from 'react-i18next';
3import * as XLSX from 'xlsx';3import * as XLSX from 'xlsx';
4+import { ConfirmDialog } from '../../components/ConfirmDialog';
5+import { Empty } from '../../components/Empty';
6+import { ListSearchInput } from '../../components/ListSearchInput';
4import { Modal, ModalCancelButton } from '../../components/Modal';7import { Modal, ModalCancelButton } from '../../components/Modal';
8+import { Pagination } from '../../components/Pagination';
9+import { TableColumnFilter } from '../../components/TableColumnFilter';
10+import {
11+ TableColumnSort,
12+ type ColumnSortValue,
13+} from '../../components/TableColumnSort';
5import { useAsync } from '../../hooks/useAsync';14import { useAsync } from '../../hooks/useAsync';
6import { useFormDirty } from '../../hooks/useFormDirty';15import { useFormDirty } from '../../hooks/useFormDirty';
7-import { ApiError, IamUser, InstanceBindingApi, NO_ORG_GROUP_ID, Org, OrgApi, UserApi } from '../../services/api';16+import { useListSearch } from '../../hooks/useListSearch';
17+import { ApiError, IamUser, NO_ORG_GROUP_ID, Org, OrgApi, UserApi } from '../../services/api';
8import { toast } from '../../stores/uiStore';18import { toast } from '../../stores/uiStore';
9-import { AddToInstanceModal, InstanceChips, InstanceFilter, instanceName, useInstances } from './instanceBinding';19+import { formatTime } from '../../utils/format';
20+import { isValidIdentityId, sanitizeIdentityIdInput } from '../../utils/identityId';
21+ 
22+type UserSortField = 'user_id' | 'display_name' | 'is_admin' | 'status' | 'updated_at';
10 23 
11export function UsersPage() {24export function UsersPage() {
12 const { t } = useTranslation();25 const { t } = useTranslation();
13- const { data, loading, reload } = useAsync(() => UserApi.list(), []);26+ const [page, setPage] = useState(1);
14- const { data: orgsData } = useAsync(() => OrgApi.list(), []);27+ const [pageSize, setPageSize] = useState(20);
15- const instances = useInstances();28+ const { searchInput, setSearchInput, searchQuery } = useListSearch();
29+ const [statusFilter, setStatusFilter] = useState('');
30+ const [roleFilter, setRoleFilter] = useState(''); // '' | 'true' | 'false'
31+ const [sortBy, setSortBy] = useState<UserSortField | ''>('');
32+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
16 const [editing, setEditing] = useState<IamUser | null | undefined>(undefined);33 const [editing, setEditing] = useState<IamUser | null | undefined>(undefined);
17 const [showBatch, setShowBatch] = useState(false);34 const [showBatch, setShowBatch] = useState(false);
18- const [showAdd, setShowAdd] = useState(false);35+ const [delTarget, setDelTarget] = useState<IamUser | null>(null);
19- const [filterJid, setFilterJid] = useState('');36+ 
20- const [roster, setRoster] = useState<Set<string> | null>(null); // 模式二:某实例花名册 user_ids37+ const { data: orgsData } = useAsync(() => OrgApi.list(), []);
21- const [bindings, setBindings] = useState<Record<string, string[]>>({}); // 模式一:所属实例
22- const [checked, setChecked] = useState<Set<string>>(new Set());
23- const users = data?.items ?? [];
24 const orgs = orgsData?.items ?? [];38 const orgs = orgsData?.items ?? [];
25- const userIdsKey = users.map((u) => u.user_id).join(',');
26 39 
27- // 切实例/换用户:载入 roster(模式二)或 所属实例(模式一)40+ const sortOptions = useMemo(
28- useEffect(() => {41+ () => [
29- setChecked(new Set());42+ { value: 'asc' as const, label: t('common.sortAsc') },
30- if (filterJid) {43+ { value: 'desc' as const, label: t('common.sortDesc') },
31- InstanceBindingApi.listUsers(filterJid).then((r) => setRoster(new Set(r.user_ids))).catch(() => setRoster(new Set()));44+ { value: '' as const, label: t('common.sortDefault') },
45+ ],
46+ [t],
47+ );
48+ 
49+ const handleSortChange = (field: UserSortField, value: ColumnSortValue) => {
50+ if (value === '') {
51+ setSortBy('');
52+ setSortOrder('asc');
32 } else {53 } else {
33- setRoster(null);54+ setSortBy(field);
34- if (users.length) {55+ setSortOrder(value);
35- InstanceBindingApi.userGateways(users.map((u) => u.user_id)).then((r) => setBindings(r.bindings)).catch(() => setBindings({}));
36- }
37 }56 }
38- }, [filterJid, userIdsKey]); // eslint-disable-line react-hooks/exhaustive-deps57+ setPage(1);
58+ };
39 59 
40- const shown = filterJid && roster ? users.filter((u) => roster.has(u.user_id)) : users;60+ useEffect(() => {
61+ setPage(1);
62+ }, [searchQuery]);
41 63 
42- function reloadRoster() {64+ const { data, loading, error, reload } = useAsync(
43- if (filterJid) InstanceBindingApi.listUsers(filterJid).then((r) => setRoster(new Set(r.user_ids))).catch(() => undefined);65+ () =>
44- }66+ UserApi.list({
67+ page,
68+ page_size: pageSize,
69+ search: searchQuery,
70+ status: statusFilter || undefined,
71+ is_admin: roleFilter === '' ? undefined : roleFilter === 'true',
72+ sort_by: sortBy || undefined,
73+ sort_order: sortBy ? sortOrder : undefined,
74+ }),
75+ [page, pageSize, searchQuery, statusFilter, roleFilter, sortBy, sortOrder],
76+ );
45 77 
46- async function onDelete(u: IamUser) {78+ const items = data?.items ?? [];
47- if (!window.confirm(t('iam.confirmDeleteUser', { name: u.display_name, id: u.user_id }))) return;
48- try {
49- await UserApi.remove(u.user_id);
50- toast('success', t('success.deleted'));
51- reload();
52- } catch (e) {
53- toast('danger', e instanceof ApiError ? e.detail : String(e));
54- }
55- }
56- 
57- function toggleCheck(id: string) {
58- setChecked((prev) => {
59- const next = new Set(prev);
60- if (next.has(id)) next.delete(id); else next.add(id);
61- return next;
62- });
63- }
64- function toggleAll() {
65- setChecked((prev) => (prev.size === shown.length ? new Set() : new Set(shown.map((u) => u.user_id))));
66- }
67- 
68- async function onRemoveFromInstance() {
69- const ids = Array.from(checked);
70- if (!ids.length || !filterJid) return;
71- const name = instanceName(instances, filterJid);
72- if (!window.confirm(t('iam.confirmRemoveFromInstance', { defaultValue: '从实例「{{name}}」移除选中的 {{n}} 项?', name, n: ids.length }))) return;
73- try {
74- await InstanceBindingApi.unbindUsers(filterJid, ids);
75- toast('success', t('success.saved'));
76- setChecked(new Set());
77- reloadRoster();
78- } catch (e) {
79- toast('danger', e instanceof ApiError ? e.detail : String(e));
80- }
81- }
82- 
83- const inInstanceMode = !!filterJid;
84- const cols = inInstanceMode ? 6 : 7;
85- const instName = filterJid ? instanceName(instances, filterJid) : '';
86 79 
87 return (80 return (
88- <div className="page">81+ <>
89- <div className="flex items-center justify-between mb-3">82+ <div className="flex min-w-0 flex-col gap-4">
90- <h2 className="card-title">{t('iam.users')}</h2>83+ <div className="page-header w-full min-w-0 flex-wrap items-start gap-y-3">
91- <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>84+ <div className="min-w-[7.5rem] max-w-[12rem] shrink-0 sm:max-w-[16rem]">
92- <InstanceFilter instances={instances} value={filterJid} onChange={setFilterJid} />85+ <div className="page-title truncate" title={t('iam.users')}>
93- {inInstanceMode && (86+ {t('iam.users')}
94- <>87+ </div>
95- <button className="btn danger" disabled={checked.size === 0} onClick={onRemoveFromInstance}>88+ <div className="page-subtitle truncate" title={t('iam.usersSubtitle')}>
96- {t('iam.removeFromInstance', { defaultValue: '移除出 {{name}}', name: instName })}{checked.size ? `(${checked.size})` : ''}89+ {t('iam.usersSubtitle')}
97- </button>90+ </div>
98- <button className="btn" onClick={() => setShowAdd(true)}>91+ </div>
99- {t('iam.addToInstance', { defaultValue: '添加到 {{name}}', name: instName })}92+ <div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2">
100- </button>93+ <ListSearchInput
101- </>94+ value={searchInput}
95+ onChange={setSearchInput}
96+ placeholder={t('iam.usersSearchPlaceholder')}
97+ className="basis-full sm:basis-auto"
98+ />
99+ <button className="btn sm" onClick={() => void reload()}>
100+ {t('common.refresh')}
101+ </button>
102+ <button className="btn sm" onClick={() => setShowBatch(true)}>
103+ {t('iam.batchNewUser')}
104+ </button>
105+ <button className="btn primary sm" onClick={() => setEditing(null)}>
106+ + {t('iam.newUser')}
107+ </button>
108+ </div>
109+ </div>
110+ 
111+ <div className="flex w-full min-w-0 shrink-0 flex-col gap-4">
112+ <div className="card !p-0">
113+ {loading ? (
114+ <div className="p-4 text-sm text-muted">{t('common.loading')}</div>
115+ ) : error ? (
116+ <div className="p-4 text-sm text-danger">{t('errors.loadFailed', { detail: error })}</div>
117+ ) : (
118+ <div className="overflow-x-auto">
119+ <table className="table w-max min-w-full">
120+ <thead>
121+ <tr>
122+ <th className="w-[25rem] max-w-[25rem]">
123+ <TableColumnSort
124+ label={t('iam.userId')}
125+ value={sortBy === 'user_id' ? sortOrder : ''}
126+ options={sortOptions}
127+ onChange={(value) => handleSortChange('user_id', value)}
128+ />
129+ </th>
130+ <th>
131+ <TableColumnSort
132+ label={t('iam.displayName')}
133+ value={sortBy === 'display_name' ? sortOrder : ''}
134+ options={sortOptions}
135+ onChange={(value) => handleSortChange('display_name', value)}
136+ />
137+ </th>
138+ <th>
139+ <div className="th-filter">
140+ <span className="th-filter__label">{t('iam.role')}</span>
141+ <TableColumnSort
142+ iconOnly
143+ label={t('iam.role')}
144+ value={sortBy === 'is_admin' ? sortOrder : ''}
145+ options={sortOptions}
146+ onChange={(value) => handleSortChange('is_admin', value)}
147+ />
148+ <TableColumnFilter
149+ iconOnly
150+ label={t('iam.role')}
151+ value={roleFilter}
152+ options={[
153+ { value: '', label: t('common.all') },
154+ { value: 'true', label: t('iam.roleAdmin') },
155+ { value: 'false', label: t('iam.roleUser') },
156+ ]}
157+ onChange={(value) => {
158+ setRoleFilter(value);
159+ setPage(1);
160+ }}
161+ />
162+ </div>
163+ </th>
164+ <th>
165+ <div className="th-filter">
166+ <span className="th-filter__label">{t('iam.status')}</span>
167+ <TableColumnSort
168+ iconOnly
169+ label={t('iam.status')}
170+ value={sortBy === 'status' ? sortOrder : ''}
171+ options={sortOptions}
172+ onChange={(value) => handleSortChange('status', value)}
173+ />
174+ <TableColumnFilter
175+ iconOnly
176+ label={t('iam.status')}
177+ value={statusFilter}
178+ options={[
179+ { value: '', label: t('common.all') },
180+ { value: 'active', label: t('common.enabled') },
181+ { value: 'disabled', label: t('common.disabled') },
182+ ]}
183+ onChange={(value) => {
184+ setStatusFilter(value);
185+ setPage(1);
186+ }}
187+ />
188+ </div>
189+ </th>
190+ <th>
191+ <TableColumnSort
192+ label={t('common.updatedAt')}
193+ value={sortBy === 'updated_at' ? sortOrder : ''}
194+ options={sortOptions}
195+ onChange={(value) => handleSortChange('updated_at', value)}
196+ />
197+ </th>
198+ <th className="whitespace-nowrap min-w-[9.5rem]">{t('common.actions')}</th>
199+ </tr>
200+ </thead>
201+ <tbody>
202+ {items.length === 0 ? (
203+ <tr>
204+ <td colSpan={6}>
205+ <Empty text={t('common.empty')} />
206+ </td>
207+ </tr>
208+ ) : (
209+ items.map((u) => (
210+ <tr key={u.user_id}>
211+ <td
212+ className="mono text-[11px] text-muted w-[25rem] max-w-[25rem] break-all"
213+ title={u.user_id}
214+ >
215+ {u.user_id}
216+ </td>
217+ <td className="text-text-strong font-medium break-words">{u.display_name}</td>
218+ <td className="whitespace-nowrap">
219+ {u.is_admin ? (
220+ <span className="badge">{t('iam.roleAdmin')}</span>
221+ ) : (
222+ t('iam.roleUser')
223+ )}
224+ </td>
225+ <td className="whitespace-nowrap">
226+ {u.status === 'active' ? t('common.enabled') : t('common.disabled')}
227+ </td>
228+ <td className="mono text-[11px] text-muted whitespace-nowrap">
229+ {formatTime(u.updated_at)}
230+ </td>
231+ <td className="whitespace-nowrap min-w-[9.5rem]">
232+ <div className="flex items-center gap-1">
233+ <button className="btn sm ghost" onClick={() => setEditing(u)}>
234+ {t('common.edit')}
235+ </button>
236+ <button className="btn sm danger" onClick={() => setDelTarget(u)}>
237+ {t('common.delete')}
238+ </button>
239+ </div>
240+ </td>
241+ </tr>
242+ ))
243+ )}
244+ </tbody>
245+ </table>
246+ </div>
247+ )}
248+ </div>
249+ 
250+ {data && (
251+ <Pagination
252+ page={page}
253+ pageSize={pageSize}
254+ total={data.total ?? data.items.length}
255+ onChange={(p, ps) => {
256+ setPage(p);
257+ setPageSize(ps);
258+ }}
259+ />
102 )}260 )}
103- <button className="btn" onClick={() => setShowBatch(true)}>{t('iam.batchNewUser')}</button>
104- <button className="btn primary" onClick={() => setEditing(null)}>{t('iam.newUser')}</button>
105 </div>261 </div>
106 </div>262 </div>
107- <div className="card">263+ 
108- <table className="table" style={{ width: '100%' }}>
109- <thead>
110- <tr>
111- <th style={{ width: 32 }}>
112- <input type="checkbox" checked={shown.length > 0 && checked.size === shown.length} onChange={toggleAll} />
113- </th>
114- <th>{t('iam.userId')}</th>
115- <th>{t('iam.displayName')}</th>
116- <th>{t('iam.role')}</th>
117- <th>{t('iam.status')}</th>
118- {!inInstanceMode && <th>{t('iam.belongInstances', { defaultValue: '所属实例' })}</th>}
119- <th>{t('common.actions')}</th>
120- </tr>
121- </thead>
122- <tbody>
123- {shown.map((u) => (
124- <tr key={u.user_id}>
125- <td><input type="checkbox" checked={checked.has(u.user_id)} onChange={() => toggleCheck(u.user_id)} /></td>
126- <td className="mono text-xs">{u.user_id}</td>
127- <td>{u.display_name}</td>
128- <td>{u.is_admin ? <span className="badge">{t('iam.roleAdmin')}</span> : t('iam.roleUser')}</td>
129- <td>{u.status}</td>
130- {!inInstanceMode && <td><InstanceChips jids={bindings[u.user_id] ?? []} instances={instances} /></td>}
131- <td style={{ textAlign: 'right' }}>
132- <button className="btn sm" onClick={() => setEditing(u)}>{t('common.edit')}</button>
133- <button className="btn sm danger" style={{ marginLeft: 6 }} onClick={() => onDelete(u)}>{t('common.delete')}</button>
134- </td>
135- </tr>
136- ))}
137- {!loading && shown.length === 0 && <tr><td colSpan={cols} className="text-muted">{t('iam.noUsers')}</td></tr>}
138- </tbody>
139- </table>
140- </div>
141 {editing !== undefined && (264 {editing !== undefined && (
142- <UserModal user={editing} orgs={orgs} onClose={() => setEditing(undefined)} onSaved={() => { setEditing(undefined); reload(); }} />265+ <UserModal
266+ user={editing}
267+ orgs={orgs}
268+ onClose={() => setEditing(undefined)}
269+ onSaved={() => {
270+ setEditing(undefined);
271+ void reload();
272+ }}
273+ />
143 )}274 )}
144 {showBatch && (275 {showBatch && (
145 <BatchImportModal276 <BatchImportModal
146- targetJid={filterJid}
147- targetName={instName}
148 onClose={() => setShowBatch(false)}277 onClose={() => setShowBatch(false)}
149- onDone={() => { reload(); reloadRoster(); }}278+ onDone={() => {
150- />279+ void reload();
151- )}
152- {showAdd && filterJid && (
153- <AddToInstanceModal
154- title={t('iam.addToInstance', { defaultValue: '添加到 {{name}}', name: instName })}
155- candidates={users.filter((u) => !roster?.has(u.user_id)).map((u) => ({ id: u.user_id, label: u.display_name, sub: u.user_id }))}
156- onConfirm={async ({ ids }) => {
157- await InstanceBindingApi.bindUsers(filterJid, ids);
158- toast('success', t('success.saved'));
159- setShowAdd(false);
160- reloadRoster();
161 }}280 }}
162- onClose={() => setShowAdd(false)}
163 />281 />
164 )}282 )}
165- </div>283+ 
284+ <ConfirmDialog
285+ open={!!delTarget}
286+ message={t('iam.confirmDeleteUser', {
287+ name: delTarget?.display_name ?? '',
288+ id: delTarget?.user_id ?? '',
289+ })}
290+ danger
291+ onConfirm={async () => {
292+ if (!delTarget) return;
293+ try {
294+ await UserApi.remove(delTarget.user_id);
295+ toast('success', t('success.deleted'));
296+ void reload();
297+ } catch (e) {
298+ toast(
299+ 'danger',
300+ t('errors.deleteFailed', {
301+ detail: e instanceof ApiError ? e.detail : (e as Error).message,
302+ }),
303+ );
304+ }
305+ }}
306+ onClose={() => setDelTarget(null)}
307+ />
308+ </>
166 );309 );
167}310}
168 311 
@@ -178,8 +321,8 @@ function parseBool(v: unknown): boolean {
178}321}
179 322 
180function BatchImportModal({323function BatchImportModal({
181- targetJid, targetName, onClose, onDone,324+ onClose, onDone,
182-}: { targetJid: string; targetName: string; onClose: () => void; onDone: () => void }) {325+}: { onClose: () => void; onDone: () => void }) {
183 const { t } = useTranslation();326 const { t } = useTranslation();
184 const [rows, setRows] = useState<BatchRow[]>([]);327 const [rows, setRows] = useState<BatchRow[]>([]);
185 const [fileName, setFileName] = useState('');328 const [fileName, setFileName] = useState('');
@@ -226,24 +369,16 @@ function BatchImportModal({
226 else reader.readAsArrayBuffer(f);369 else reader.readAsArrayBuffer(f);
227 }370 }
228 371 
229- const invalidCount = rows.filter((r) => !r.username || !r.password).length;372+ const invalidCount = rows.filter(
373+ (r) => !r.username || !r.password || !isValidIdentityId(r.username),
374+ ).length;
230 375 
231 async function submit() {376 async function submit() {
232 setBusy(true);377 setBusy(true);
233 try {378 try {
234 const res = await UserApi.batchCreate(rows);379 const res = await UserApi.batchCreate(rows);
235 setResult(res);380 setResult(res);
236- if (res.summary.ok > 0) {381+ if (res.summary.ok > 0) onDone();
237- // 当前选中了某实例:把成功创建的用户补绑到该实例(跨服务两步:identity 建 → manager 绑)。
238- if (targetJid) {
239- const ids = res.results.filter((r) => r.ok && r.user_id).map((r) => r.user_id as string);
240- if (ids.length) {
241- try { await InstanceBindingApi.bindUsers(targetJid, ids); }
242- catch (e) { toast('danger', `${t('iam.bindInstanceFailed', { defaultValue: '加入实例失败' })}: ${e instanceof ApiError ? e.detail : String(e)}`); }
243- }
244- }
245- onDone();
246- }
247 } catch (e) {382 } catch (e) {
248 toast('danger', e instanceof ApiError ? e.detail : String(e));383 toast('danger', e instanceof ApiError ? e.detail : String(e));
249 } finally {384 } finally {
@@ -276,13 +411,6 @@ function BatchImportModal({
276 <input type="file" accept=".xlsx,.csv" onChange={onFile} />411 <input type="file" accept=".xlsx,.csv" onChange={onFile} />
277 </div>412 </div>
278 <div className="text-xs text-muted" style={{ marginBottom: 8 }}>{t('iam.batchHint')}</div>413 <div className="text-xs text-muted" style={{ marginBottom: 8 }}>{t('iam.batchHint')}</div>
279- {targetJid
280- ? <div className="text-xs" style={{ marginBottom: 8, color: '#2d7d46' }}>
281- {t('iam.batchWillJoin', { defaultValue: '将同时加入实例:{{name}}', name: targetName })}
282- </div>
283- : <div className="text-xs text-muted" style={{ marginBottom: 8 }}>
284- {t('iam.batchNoInstance', { defaultValue: '未选实例:仅创建用户,暂不加入任何实例(可选实例后再添加)' })}
285- </div>}
286 {fileName && <div className="text-xs" style={{ marginBottom: 6 }}>{fileName}</div>}414 {fileName && <div className="text-xs" style={{ marginBottom: 6 }}>{fileName}</div>}
287 415 
288 {rows.length > 0 && !result && (416 {rows.length > 0 && !result && (
@@ -296,8 +424,8 @@ function BatchImportModal({
296 <thead><tr><th>{t('iam.username')}</th><th>{t('iam.displayName')}</th><th>{t('iam.admin')}</th><th>{t('iam.belongOrgs')}</th></tr></thead>424 <thead><tr><th>{t('iam.username')}</th><th>{t('iam.displayName')}</th><th>{t('iam.admin')}</th><th>{t('iam.belongOrgs')}</th></tr></thead>
297 <tbody>425 <tbody>
298 {rows.map((r, i) => (426 {rows.map((r, i) => (
299- <tr key={i} style={!r.username || !r.password ? { background: 'rgba(192,57,43,0.08)' } : undefined}>427+ <tr key={i} style={!r.username || !r.password || !isValidIdentityId(r.username) ? { background: 'rgba(192,57,43,0.08)' } : undefined}>
300- <td>{r.username || '—'}{!r.password && <span style={{ color: '#c0392b' }}> ·{t('iam.batchNoPwd')}</span>}</td>428+ <td>{r.username || '—'}{!r.password && <span style={{ color: '#c0392b' }}> ·{t('iam.batchNoPwd')}</span>}{!!r.username && !isValidIdentityId(r.username) && <span style={{ color: '#c0392b' }}> ·{t('iam.idCharsetInvalid', { field: t('iam.username') })}</span>}</td>
301 <td>{r.display_name || r.username}</td>429 <td>{r.display_name || r.username}</td>
302 <td>{r.is_admin ? '✓' : ''}</td>430 <td>{r.is_admin ? '✓' : ''}</td>
303 <td className="mono text-xs">{(r.orgs || []).join(', ')}</td>431 <td className="mono text-xs">{(r.orgs || []).join(', ')}</td>
@@ -372,6 +500,12 @@ function UserModal({ user, orgs, onClose, onSaved }: { user: IamUser | null; org
372 }500 }
373 501 
374 async function save() {502 async function save() {
503+ if (!isEdit) {
504+ if (!isValidIdentityId(username)) {
505+ toast('warn', t('iam.idCharsetInvalid', { field: t('iam.username') }));
506+ return;
507+ }
508+ }
375 setBusy(true);509 setBusy(true);
376 try {510 try {
377 let uid = user?.user_id;511 let uid = user?.user_id;
@@ -381,7 +515,7 @@ function UserModal({ user, orgs, onClose, onSaved }: { user: IamUser | null; org
381 ...(password ? { password } : {}),515 ...(password ? { password } : {}),
382 });516 });
383 } else {517 } else {
384- const created = await UserApi.create({ display_name: displayName, username, password, is_admin: isAdmin });518+ const created = await UserApi.create({ display_name: displayName, username: username.trim(), password, is_admin: isAdmin });
385 uid = created.user_id;519 uid = created.user_id;
386 }520 }
387 if (uid) await UserApi.setOrgs(uid, Array.from(selectedOrgs));521 if (uid) await UserApi.setOrgs(uid, Array.from(selectedOrgs));
@@ -394,7 +528,7 @@ function UserModal({ user, orgs, onClose, onSaved }: { user: IamUser | null; org
394 }528 }
395 }529 }
396 530 
397- const canSave = displayName.trim() && (isEdit || (username.trim() && password));531+ const canSave = displayName.trim() && (isEdit || (isValidIdentityId(username) && password));
398 const draft = {532 const draft = {
399 displayName,533 displayName,
400 username,534 username,
@@ -423,7 +557,13 @@ function UserModal({ user, orgs, onClose, onSaved }: { user: IamUser | null; org
423 {!isEdit && (557 {!isEdit && (
424 <>558 <>
425 <label className="label" style={{ marginTop: 12 }}>{t('iam.username')}</label>559 <label className="label" style={{ marginTop: 12 }}>{t('iam.username')}</label>
426- <input className="input" value={username} onChange={(e) => setUsername(e.target.value)} />560+ <input
561+ className="input mono"
562+ value={username}
563+ maxLength={64}
564+ onChange={(e) => setUsername(sanitizeIdentityIdInput(e.target.value))}
565+ />
566+ <div className="text-xs text-muted" style={{ marginTop: 4 }}>{t('iam.usernameHint')}</div>
427 </>567 </>
428 )}568 )}
429 569 
@@ -450,7 +590,7 @@ function UserModal({ user, orgs, onClose, onSaved }: { user: IamUser | null; org
450 {realOrgs.map((o) => (590 {realOrgs.map((o) => (
451 <label key={o.group_id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '2px 0' }}>591 <label key={o.group_id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '2px 0' }}>
452 <input type="checkbox" checked={selectedOrgs.has(o.group_id)} onChange={() => toggleOrg(o.group_id)} />592 <input type="checkbox" checked={selectedOrgs.has(o.group_id)} onChange={() => toggleOrg(o.group_id)} />
453- {o.name} <span className="text-xs text-muted mono">{o.group_id}</span>593+ {o.display_name} <span className="text-xs text-muted mono">{o.group_id}</span>
454 </label>594 </label>
455 ))}595 ))}
456 {realOrgs.length === 0 && <div className="text-xs text-muted">{t('iam.noOrgs')}</div>}596 {realOrgs.length === 0 && <div className="text-xs text-muted">{t('iam.noOrgs')}</div>}
Mapplications/manager/manager_web/src/pages/instance/InstanceListPage.tsx+79-21文件内容审核中,请稍后刷新重试
Rapplications/manager/manager_web/src/pages/iam/instanceBinding.tsxapplications/manager/manager_web/src/pages/instance/instanceAccessPanel/instanceBinding.tsx+6-53文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/pages/templates/AgentTemplatesPage.tsx+1-1文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/pages/templates/ModelTemplatesPage.tsx+1-1文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/services/api.ts+32-5文件内容审核中,请稍后刷新重试
Aapplications/manager/manager_web/src/utils/identityId.ts+17-0文件内容审核中,请稍后刷新重试