已合并
fix:优化身份中心前端页面 #492
王欣创建于 3 天前
fix:优化身份中心前端页面 #492
已合并
共 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`、前端角色分流及已挂载的 |
| 167 | Manager 权限守卫。 | 167 | Manager 权限守卫。 |
| 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/federation/store.py+16-8文件内容审核中,请稍后刷新重试
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/src/identity_center/infrastructure/utils.py+20-0文件内容审核中,请稍后刷新重试
Mapplications/manager/identity_center/src/identity_center/models/identity_models.py+67-49文件内容审核中,请稍后刷新重试
Mapplications/manager/identity_center/src/identity_center/routers/iam_routers.py+32-7文件内容审核中,请稍后刷新重试
Mapplications/manager/identity_center/src/identity_center/schemas/iam_schemas.py+63-7文件内容审核中,请稍后刷新重试
Mapplications/manager/identity_center/tests/unit_tests/test_federation_store.py+349-349文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_server/src/manager_server/routers/instance_routers.py+10-0文件内容审核中,请稍后刷新重试
| @@ -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 @@ | |||
| 1 | import { useEffect, useMemo, useState } from 'react'; | 1 | import { useEffect, useMemo, useState } from 'react'; |
| 2 | import { useTranslation } from 'react-i18next'; | 2 | import { useTranslation } from 'react-i18next'; |
| 3 | +import { ConfirmDialog } from '../../components/ConfirmDialog'; | ||
| 4 | +import { Empty } from '../../components/Empty'; | ||
| 5 | +import { ListSearchInput } from '../../components/ListSearchInput'; | ||
| 3 | import { Modal, ModalCancelButton } from '../../components/Modal'; | 6 | import { 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'; | ||
| 4 | import { useAsync } from '../../hooks/useAsync'; | 13 | import { useAsync } from '../../hooks/useAsync'; |
| 5 | import { useFormDirty } from '../../hooks/useFormDirty'; | 14 | import { 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'; | ||
| 7 | import { toast } from '../../stores/uiStore'; | 17 | import { 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 | ||
| 10 | export function OrgsPage() { | 22 | export 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 | <Modal | 307 | <Modal |
| 210 | open | 308 | 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 }) { | |||
| 251 | function OrgModal({ org, onClose, onSaved }: { org: Org | null; onClose: () => void; onSaved: () => void }) { | 347 | function 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 | <Modal | 379 | <Modal |
| 281 | open | 380 | 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'; |
| 2 | import { useTranslation } from 'react-i18next'; | 2 | import { useTranslation } from 'react-i18next'; |
| 3 | import * as XLSX from 'xlsx'; | 3 | import * as XLSX from 'xlsx'; |
| 4 | +import { ConfirmDialog } from '../../components/ConfirmDialog'; | ||
| 5 | +import { Empty } from '../../components/Empty'; | ||
| 6 | +import { ListSearchInput } from '../../components/ListSearchInput'; | ||
| 4 | import { Modal, ModalCancelButton } from '../../components/Modal'; | 7 | import { 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'; | ||
| 5 | import { useAsync } from '../../hooks/useAsync'; | 14 | import { useAsync } from '../../hooks/useAsync'; |
| 6 | import { useFormDirty } from '../../hooks/useFormDirty'; | 15 | import { 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'; | ||
| 8 | import { toast } from '../../stores/uiStore'; | 18 | import { 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 | ||
| 11 | export function UsersPage() { | 24 | export 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_ids | 37 | + 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-deps | 57 | + 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 | <BatchImportModal | 276 | <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 | ||
| 180 | function BatchImportModal({ | 323 | function 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>} |
Rapplications/manager/manager_web/src/pages/iam/instanceBinding.tsx→applications/manager/manager_web/src/pages/instance/instanceAccessPanel/instanceBinding.tsx+6-53文件内容审核中,请稍后刷新重试