Compare commits
No commits in common. "main" and "feat/dataviz-users" have entirely different histories.
main
...
feat/datav
10
.gitignore
vendored
10
.gitignore
vendored
@ -23,14 +23,4 @@ ENV/
|
||||
.env
|
||||
.venv
|
||||
wwwroot/wwwroot
|
||||
# CRUD auto-generated directories
|
||||
wwwroot/organization/
|
||||
wwwroot/orgtypes/
|
||||
wwwroot/permission/
|
||||
wwwroot/provider/
|
||||
wwwroot/reseller/
|
||||
wwwroot/role/
|
||||
wwwroot/rolepermission/
|
||||
wwwroot/userrole/
|
||||
wwwroot/users/
|
||||
mysql.ddl.sql
|
||||
107
README.md
107
README.md
@ -1,107 +1,2 @@
|
||||
# rbac — 角色权限模块
|
||||
# rbac
|
||||
|
||||
产线平台/Sage 系应用的基础模块,提供**用户、机构、角色、权限(RBAC)**的完整管理体系,以及登录认证(密码/短信/扫码/Bearer API key 等多种认证方式注册点)、权限校验拦截、审计日志和双层权限缓存。所有宿主应用的鉴权都收敛到本模块的 `objcheckperm`(挂到 `AuthAPI.checkUserPermission`)。
|
||||
|
||||
## 模块定位
|
||||
|
||||
- 数据模型层:用户/机构/角色/权限四套表(见下)。
|
||||
- 认证层:`check_perm.py` 实现密码登录(含 3 次失败锁定 5 分钟)、`getAuthenticationUserid` 按 `Authorization` 头前缀分发到已注册的认证方法(内置 `Basic `,其他模块可通过 `register_auth_method` 注册,如 dapi 注册 `Bearer `/`Deerer `)。
|
||||
- 授权层:`userperm.py` 的 `UserPermissions`(单例)负责"用户→角色→路径权限"的判定,含 LRU+Redis 双层缓存。
|
||||
- 事件层:`init.py` 通过 DBPools 绑定 role/userrole/permission/rolepermission 四表的 C/U/D after 事件,变更即失效缓存。
|
||||
|
||||
## 表清单(models/*.json)
|
||||
|
||||
| 表 | 标题 | 关键字段 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `users` | 用户 | id, username, password, email, orgid, nick_name, mobile, login_fail_count, last_login_fail, last_login | 密码 RC4(appPublic.rc4)加密存储 |
|
||||
| `organization` | 机构 | id, orgname, orgabbr, alias_name, contactor, contactor_phone, province_id, city_id | 支持层级(parentid) |
|
||||
| `orgtypes` | 机构拥有角色 | id, orgid, orgtypeid | orgtypeid 取自 appbase 的 `appcodes_kv`(parentid='org_type') |
|
||||
| `role` | 角色 | id, orgtypeid(默认'0'), name | 角色全名 = `{orgtypeid}.{name}` |
|
||||
| `userrole` | 用户角色 | id, userid, roleid | 用户↔角色多对多 |
|
||||
| `permission` | 权限 | id, name, parentid, path, icon, permtype, need_audit | path 即受保护的 URL 路径 |
|
||||
| `rolepermission` | 角色权限表 | id, roleid, permid | 角色↔权限多对多 |
|
||||
| `audit_log` | 审计日志 | id, permid, userid, params_kw, exe_date, exe_timestamp, remote_ip | need_audit 权限调用时由 `audit_log.write_audit_log` 落表 |
|
||||
|
||||
`models/` 为 sqlor DDL 源,`json/` 为 bricks CRUD 页面定义(另含 reseller.json、provider.json 等展示用定义)。
|
||||
|
||||
## 角色模型
|
||||
|
||||
- 判定键为 `{orgtypeid}.{name}`,加载用户角色时自动派生通配角色:`{orgtypeid}.*`、`*.{name}`;每个登录用户固定拥有 `any`、`logined` 两个隐式角色,未登录请求按 `anonymous`/`any` 处理。
|
||||
- 常用内置角色(由部署脚本/SQL 种子创建):
|
||||
- `owner.superuser` — 平台超级管理员(rbac 管理页面、`register_rbac_tools_perm.py` 注册的工具权限都挂在此角色)。
|
||||
- `owner.admin` — 平台管理员。
|
||||
- `owner.audit` — 审计只读角色(配合 audit_log 模块,审计独立性)。
|
||||
- `downapp.logined` — dapi 模块在 API key 认证成功后自动创建并授予(见 `ensure_downappuser_role_and_assign`)。
|
||||
- 路径匹配(`check_roles_path`):精确匹配、`**`/`%` 前缀通配、`/main/xxx` → `/xxx` 归一化。
|
||||
- 超管创建参考 `pipeline-app/scripts/create_superuser.py`(orgtypeid=owner, roles=[superuser, admin])。
|
||||
|
||||
## 缓存机制(重点)
|
||||
|
||||
`UserPermissions` 使用**双层缓存**:
|
||||
|
||||
- **L1 进程内**:`ur_caches`(用户→角色,LRU maxsize=10000,TTL **300s**);`rp_caches`(角色→路径集合,全量,TTL **600s**,双检锁+原子换入防止加载期间 403)。
|
||||
- **L2 Redis 共享**:经 `appPublic.share_cache`(`cache_set/cache_get/cache_invalidate`)写入,key 为 `rbac:role_perms`、`rbac:user_roles:{userid}`,TTL 与 L1 相同(600s/300s),供多 worker 进程复用。
|
||||
- 开关:`config.json` 的 `module_cache.rbac=false` 可整体禁用缓存(调试用)。
|
||||
- 失效途径:
|
||||
1. role/userrole/permission/rolepermission 表 C/U/D 事件(`register_rbac_event_listeners`,userrole 变更精确失效单个用户,其余全量失效 rp_caches);
|
||||
2. `hot_reload` 事件(`on_hot_reload` 清空两层);
|
||||
3. 手动调用 `ServerEnv().invalidate_user_perm_cache(userid)` / `invalidate_all_perm_caches()` / `invalidate_role_perm_cache()`,或访问 `/rbac/refresh_userperm.dspy`。
|
||||
|
||||
> **部署纪律:直接改数据库里的角色/权限数据(绕过事件绑定)后,缓存不会自动失效。必须 `redis-cli FLUSHDB` 清掉 L2 共享缓存并重启应用进程清掉各 worker 的 L1 缓存,否则最长 600 秒内旧权限继续生效(可能放行或误拦)。**
|
||||
|
||||
## 对外 API / dspy 端点(wwwroot/)
|
||||
|
||||
- 登录认证:`userpassword_login.dspy`、`phone_login.dspy`、`gen_sms_code.dspy`、`user/up_login.dspy`、`user/code_login.dspy`、`user/logout.dspy`、`user/register.dspy`、`user/sms_register.dspy`、`user/reset_password/reset_password.dspy`
|
||||
- 用户资料:`user/edit_profile.dspy`、`user/save_profile.dspy`
|
||||
- 管理端:`add_adminuser.dspy`、`add_provider.dspy`、`get_provider.dspy`、`add_reseller.dspy`、`get_reseller.dspy`
|
||||
- 角色权限查询:`get_all_roles.dspy`、`get_normal_roles.dspy`、`list_path_roles.dspy`、`find_unauth_files.dspy`(扫描 wwwroot 中未注册权限的文件)、`api/get_search_roleid.dspy`
|
||||
- 用户 CRUD:`api/add_user.dspy`、`api/update_user.dspy`
|
||||
- 管理员对他人的敏感操作(仅 `* admin` 角色,见 load_path.py 的 PATHS_ADMIN):`api/admin_reset_password.dspy`(重置他人密码,禁止对自己操作,自己改密走自助入口)、`api/clear_login_fail.dspy`(清除连续登录失败次数)、`admin_reset_password.ui`(PopupWindow 表单,隐藏字段 userid 由 `params_kw.get('id')` 服务端注入)
|
||||
- 缓存刷新:`refresh_userperm.dspy`
|
||||
- 用户同步:`usersync/index.dspy`(把 rbac 用户同步到 dapi 并生成 API key,接口文档见 `docs/userasync-api.md`)
|
||||
- UI:`admin_menu.ui`、`list_path_roles.ui`、`user/login.ui`、`user/user_panel.ui`、`stat_*.ui`(用户/机构统计卡片)等
|
||||
|
||||
权限注册分三档:`any`(免登录,见 `scripts/load_path.py` 的 PATHS_ANY)、`logined`(登录即可)、具体角色(如 `owner.superuser`,见 `script/register_rbac_tools_perm.py`;全局管理员档 `* admin` 见 PATHS_ADMIN)。`set_role_perm.py`(pipeline-app 根目录)按 `[orgtypeid.]name` 查 role 表取真实 roleid,特殊角色 any/anonymous/logined 用字面 id。
|
||||
|
||||
## load 注册函数
|
||||
|
||||
入口 `rbac/init.py` 的 **`load_rbac()`**,向 `ServerEnv()` 单例挂载:
|
||||
|
||||
```
|
||||
env.userpermissions # UserPermissions 单例
|
||||
env.create_org / create_user / register_user / register_auth_method
|
||||
env.check_user_password / get_user_roles / get_org_users / sor_get_org_users
|
||||
env.set_role_perm / set_role_perms / sor_add_user_roles
|
||||
env.get_platform_providers / get_owner_orgid
|
||||
env.invalidate_user_perm_cache / invalidate_all_perm_caches / invalidate_role_perm_cache
|
||||
AuthAPI.checkUserPermission = objcheckperm # ahserver 鉴权拦截点
|
||||
```
|
||||
|
||||
并绑定 hot_reload 事件与四张权限表的 DB 事件监听。
|
||||
|
||||
辅助模块:`rbac_tools.py`(query_path_roles/scan_unauth_files)、`audit_log.py`(write_audit_log)、`orgs.py`(get_platform_providers)、`user_stats.py`、`set_role_perms.py`、`version.py`。
|
||||
|
||||
## 宿主集成
|
||||
|
||||
宿主应用在启动入口(如 `sage/app/sage.py`、`pipeline-app/app/pipeline_app.py`、`cpcc/app/cpcc.py`、`filemgr/app/fileMGR.py`)中:
|
||||
|
||||
```python
|
||||
from rbac.init import load_rbac
|
||||
...
|
||||
load_appbase() # 先加载 appbase(rbac 注册开关读 params 表)
|
||||
load_rbac()
|
||||
load_dapi() # 依赖 rbac 的 register_auth_method,必须在其后
|
||||
```
|
||||
|
||||
依赖:ahserver(AuthAPI/ServerEnv)、sqlor(DBPools/get_sor_context)、appPublic(rc4/share_cache/jsonConfig 等)、appbase(params 表的 `register_open` 控制注册开关)。
|
||||
|
||||
数据库名解析:`ServerEnv().get_module_dbname('rbac')`,宿主需在 config.json 中配置模块→库映射(或默认同库)。
|
||||
|
||||
## 部署注意
|
||||
|
||||
1. **加载顺序**:appbase → rbac → dapi/业务模块;rbac 必须最先完成 `AuthAPI.checkUserPermission` 挂载,否则所有请求无鉴权。
|
||||
2. **权限注册**:部署新页面/dspy 后运行 `scripts/load_path.py`(any/logined 分档)或 `set_role_perm.py` 注册路径权限,否则会被默认拦截;可用 `find_unauth_files.dspy` 扫描遗漏。
|
||||
3. **改权限数据后必须 FLUSHDB + 重启**(见上文缓存机制),这是双层缓存(进程内 LRU + Redis,rp TTL 600s)下的硬性运维动作。
|
||||
4. `script/init.py` 内含示例数据库口令,仅供本地初始化参考,生产以宿主 config.json 为准;密码字段一律 RC4(`appPublic.rc4.password`)加密。
|
||||
5. 登录防爆破:3 次失败锁定 5 分钟(`LOGIN_LOCKOUT_DURATION`),审计写 `audit_log` 表;`need_audit='1'` 的权限调用也会落审计。
|
||||
6. usersync 接口把用户下发给 dapi(下位系统 API key 体系),文档见 `docs/userasync-api.md`。
|
||||
|
||||
137
i18n/en/msg.txt
137
i18n/en/msg.txt
@ -1,137 +0,0 @@
|
||||
用户管理: User Management
|
||||
用户名: Username
|
||||
密码: Password
|
||||
确认密码: Confirm Password
|
||||
姓名: Full Name
|
||||
邮箱: Email
|
||||
手机号: Phone Number
|
||||
所属机构: Organization
|
||||
角色: Role
|
||||
状态: Status
|
||||
启用: Enable
|
||||
停用: Disable
|
||||
新增用户: Add User
|
||||
编辑用户: Edit User
|
||||
删除用户: Delete User
|
||||
重置密码: Reset Password
|
||||
角色管理: Role Management
|
||||
角色名称: Role Name
|
||||
角色编码: Role Code
|
||||
角色描述: Role Description
|
||||
权限: Permission
|
||||
新增角色: Add Role
|
||||
编辑角色: Edit Role
|
||||
删除角色: Delete Role
|
||||
分配权限: Assign Permission
|
||||
机构管理: Organization Management
|
||||
机构名称: Organization Name
|
||||
机构编码: Organization Code
|
||||
机构类型: Organization Type
|
||||
上级机构: Parent Organization
|
||||
新增机构: Add Organization
|
||||
编辑机构: Edit Organization
|
||||
删除机构: Delete Organization
|
||||
菜单管理: Menu Management
|
||||
菜单名称: Menu Name
|
||||
菜单路径: Menu Path
|
||||
菜单图标: Menu Icon
|
||||
菜单排序: Menu Sort Order
|
||||
上级菜单: Parent Menu
|
||||
新增菜单: Add Menu
|
||||
编辑菜单: Edit Menu
|
||||
删除菜单: Delete Menu
|
||||
权限管理: Permission Management
|
||||
权限名称: Permission Name
|
||||
权限编码: Permission Code
|
||||
权限类型: Permission Type
|
||||
资源权限: Resource Permission
|
||||
操作权限: Operation Permission
|
||||
数据权限: Data Permission
|
||||
登录日志: Login Log
|
||||
登录时间: Login Time
|
||||
登录IP: Login IP
|
||||
登录状态: Login Status
|
||||
成功: Success
|
||||
失败: Failed
|
||||
操作日志: Operation Log
|
||||
操作类型: Operation Type
|
||||
操作时间: Operation Time
|
||||
操作人: Operator
|
||||
操作结果: Operation Result
|
||||
API密钥管理: API Key Management
|
||||
密钥名称: Key Name
|
||||
密钥值: Key Value
|
||||
密钥状态: Key Status
|
||||
新增密钥: Add Key
|
||||
编辑密钥: Edit Key
|
||||
删除密钥: Delete Key
|
||||
在线用户: Online Users
|
||||
会话ID: Session ID
|
||||
最后活动时间: Last Activity Time
|
||||
强制下线: Force Logout
|
||||
安全设置: Security Settings
|
||||
密码策略: Password Policy
|
||||
密码长度: Password Length
|
||||
密码复杂度: Password Complexity
|
||||
密码有效期: Password Expiry
|
||||
登录失败锁定: Login Failure Lock
|
||||
最大失败次数: Max Failed Attempts
|
||||
锁定时长: Lock Duration
|
||||
双因素认证: Two-Factor Authentication
|
||||
启用双因素: Enable 2FA
|
||||
验证码: Verification Code
|
||||
Token管理: Token Management
|
||||
Token名称: Token Name
|
||||
Token过期时间: Token Expiry Time
|
||||
新增Token: Add Token
|
||||
编辑Token: Edit Token
|
||||
删除Token: Delete Token
|
||||
刷新Token: Refresh Token
|
||||
用户组: User Group
|
||||
组名称: Group Name
|
||||
组描述: Group Description
|
||||
新增用户组: Add User Group
|
||||
编辑用户组: Edit User Group
|
||||
删除用户组: Delete User Group
|
||||
成员管理: Member Management
|
||||
添加成员: Add Member
|
||||
移除成员: Remove Member
|
||||
搜索: Search
|
||||
名称或编码: Name or Code
|
||||
操作: Action
|
||||
描述: Description
|
||||
类型: Type
|
||||
名称: Name
|
||||
编码: Code
|
||||
备注: Remarks
|
||||
创建时间: Created Time
|
||||
更新时间: Updated Time
|
||||
新增: Add
|
||||
保存: Save
|
||||
取消: Cancel
|
||||
确认: Confirm
|
||||
删除: Delete
|
||||
编辑: Edit
|
||||
查看: View
|
||||
导出: Export
|
||||
打印: Print
|
||||
刷新: Refresh
|
||||
返回: Back
|
||||
提交: Submit
|
||||
重置: Reset
|
||||
Conform: Conform
|
||||
Discard: Discard
|
||||
Submit: Submit
|
||||
Reset: Reset
|
||||
Cancel: Cancel
|
||||
全部: All
|
||||
日期: Date
|
||||
管理员: Administrator
|
||||
普通用户: Normal User
|
||||
只读用户: Read-Only User
|
||||
访问控制: Access Control
|
||||
最后登录: Last Login
|
||||
清除登录次数: Clear Login Fail Count
|
||||
新密码: New Password
|
||||
确认清除该用户的连续登录失败次数?: Clear the consecutive login failure count of this user?
|
||||
为选中用户设置新密码(至少8位): Set a new password (at least 8 characters) for the selected user
|
||||
137
i18n/jp/msg.txt
137
i18n/jp/msg.txt
@ -1,137 +0,0 @@
|
||||
用户管理: ユーザー管理
|
||||
用户名: ユーザー名
|
||||
密码: パスワード
|
||||
确认密码: パスワード確認
|
||||
姓名: 氏名
|
||||
邮箱: メールアドレス
|
||||
手机号: 電話番号
|
||||
所属机构: 所属組織
|
||||
角色: ロール
|
||||
状态: ステータス
|
||||
启用: 有効
|
||||
停用: 無効
|
||||
新增用户: ユーザー追加
|
||||
编辑用户: ユーザー編集
|
||||
删除用户: ユーザー削除
|
||||
重置密码: パスワードリセット
|
||||
角色管理: ロール管理
|
||||
角色名称: ロール名
|
||||
角色编码: ロールコード
|
||||
角色描述: ロール説明
|
||||
权限: 権限
|
||||
新增角色: ロール追加
|
||||
编辑角色: ロール編集
|
||||
删除角色: ロール削除
|
||||
分配权限: 権限割り当て
|
||||
机构管理: 組織管理
|
||||
机构名称: 組織名
|
||||
机构编码: 組織コード
|
||||
机构类型: 組織タイプ
|
||||
上级机构: 上位組織
|
||||
新增机构: 組織追加
|
||||
编辑机构: 組織編集
|
||||
删除机构: 組織削除
|
||||
菜单管理: メニュー管理
|
||||
菜单名称: メニュー名
|
||||
菜单路径: メニューパス
|
||||
菜单图标: メニューアイコン
|
||||
菜单排序: メニュー並び順
|
||||
上级菜单: 上位メニュー
|
||||
新增菜单: メニュー追加
|
||||
编辑菜单: メニュー編集
|
||||
删除菜单: メニュー削除
|
||||
权限管理: 権限管理
|
||||
权限名称: 権限名
|
||||
权限编码: 権限コード
|
||||
权限类型: 権限タイプ
|
||||
资源权限: リソース権限
|
||||
操作权限: 操作権限
|
||||
数据权限: データ権限
|
||||
登录日志: ログインログ
|
||||
登录时间: ログイン時間
|
||||
登录IP: ログインIP
|
||||
登录状态: ログインステータス
|
||||
成功: 成功
|
||||
失败: 失敗
|
||||
操作日志: 操作ログ
|
||||
操作类型: 操作タイプ
|
||||
操作时间: 操作時間
|
||||
操作人: 操作者
|
||||
操作结果: 操作結果
|
||||
API密钥管理: APIキー管理
|
||||
密钥名称: キー名
|
||||
密钥值: キー値
|
||||
密钥状态: キーステータス
|
||||
新增密钥: キー追加
|
||||
编辑密钥: キー編集
|
||||
删除密钥: キー削除
|
||||
在线用户: オンラインユーザー
|
||||
会话ID: セッションID
|
||||
最后活动时间: 最終アクティビティ時間
|
||||
强制下线: 強制ログアウト
|
||||
安全设置: セキュリティ設定
|
||||
密码策略: パスワードポリシー
|
||||
密码长度: パスワード長さ
|
||||
密码复杂度: パスワード複雑度
|
||||
密码有效期: パスワード有効期限
|
||||
登录失败锁定: ログイン失敗ロック
|
||||
最大失败次数: 最大失敗回数
|
||||
锁定时长: ロック期間
|
||||
双因素认证: 二要素認証
|
||||
启用双因素: 二要素認証を有効化
|
||||
验证码: 認証コード
|
||||
Token管理: トークン管理
|
||||
Token名称: トークン名
|
||||
Token过期时间: トークン有効期限
|
||||
新增Token: トークン追加
|
||||
编辑Token: トークン編集
|
||||
删除Token: トークン削除
|
||||
刷新Token: トークン更新
|
||||
用户组: ユーザーグループ
|
||||
组名称: グループ名
|
||||
组描述: グループ説明
|
||||
新增用户组: ユーザーグループ追加
|
||||
编辑用户组: ユーザーグループ編集
|
||||
删除用户组: ユーザーグループ削除
|
||||
成员管理: メンバー管理
|
||||
添加成员: メンバー追加
|
||||
移除成员: メンバー削除
|
||||
搜索: 検索
|
||||
名称或编码: 名前またはコード
|
||||
操作: 操作
|
||||
描述: 説明
|
||||
类型: タイプ
|
||||
名称: 名前
|
||||
编码: コード
|
||||
备注: 備考
|
||||
创建时间: 作成日時
|
||||
更新时间: 更新日時
|
||||
新增: 追加
|
||||
保存: 保存
|
||||
取消: キャンセル
|
||||
确认: 確認
|
||||
删除: 削除
|
||||
编辑: 編集
|
||||
查看: 表示
|
||||
导出: エクスポート
|
||||
打印: 印刷
|
||||
刷新: 更新
|
||||
返回: 戻る
|
||||
提交: 送信
|
||||
重置: リセット
|
||||
Conform: 確認
|
||||
Discard: 破棄
|
||||
Submit: 送信
|
||||
Reset: リセット
|
||||
Cancel: キャンセル
|
||||
全部: すべて
|
||||
日期: 日付
|
||||
管理员: 管理者
|
||||
普通用户: 一般ユーザー
|
||||
只读用户: 読み取り専用ユーザー
|
||||
访问控制: アクセス制御
|
||||
最后登录: 最終ログイン
|
||||
清除登录次数: ログイン失敗回数をクリア
|
||||
新密码: 新しいパスワード
|
||||
确认清除该用户的连续登录失败次数?: このユーザーの連続ログイン失敗回数をクリアしますか?
|
||||
为选中用户设置新密码(至少8位): 選択したユーザーに新しいパスワード(8文字以上)を設定
|
||||
137
i18n/ko/msg.txt
137
i18n/ko/msg.txt
@ -1,137 +0,0 @@
|
||||
用户管理: 사용자 관리
|
||||
用户名: 사용자명
|
||||
密码: 비밀번호
|
||||
确认密码: 비밀번호 확인
|
||||
姓名: 이름
|
||||
邮箱: 이메일
|
||||
手机号: 전화번호
|
||||
所属机构: 소속 기관
|
||||
角色: 역할
|
||||
状态: 상태
|
||||
启用: 활성화
|
||||
停用: 비활성화
|
||||
新增用户: 사용자 추가
|
||||
编辑用户: 사용자 편집
|
||||
删除用户: 사용자 삭제
|
||||
重置密码: 비밀번호 재설정
|
||||
角色管理: 역할 관리
|
||||
角色名称: 역할명
|
||||
角色编码: 역할 코드
|
||||
角色描述: 역할 설명
|
||||
权限: 권한
|
||||
新增角色: 역할 추가
|
||||
编辑角色: 역할 편집
|
||||
删除角色: 역할 삭제
|
||||
分配权限: 권한 할당
|
||||
机构管理: 기관 관리
|
||||
机构名称: 기관명
|
||||
机构编码: 기관 코드
|
||||
机构类型: 기관 유형
|
||||
上级机构: 상위 기관
|
||||
新增机构: 기관 추가
|
||||
编辑机构: 기관 편집
|
||||
删除机构: 기관 삭제
|
||||
菜单管理: 메뉴 관리
|
||||
菜单名称: 메뉴명
|
||||
菜单路径: 메뉴 경로
|
||||
菜单图标: 메뉴 아이콘
|
||||
菜单排序: 메뉴 정렬
|
||||
上级菜单: 상위 메뉴
|
||||
新增菜单: 메뉴 추가
|
||||
编辑菜单: 메뉴 편집
|
||||
删除菜单: 메뉴 삭제
|
||||
权限管理: 권한 관리
|
||||
权限名称: 권한명
|
||||
权限编码: 권한 코드
|
||||
权限类型: 권한 유형
|
||||
资源权限: 리소스 권한
|
||||
操作权限: 작업 권한
|
||||
数据权限: 데이터 권한
|
||||
登录日志: 로그인 로그
|
||||
登录时间: 로그인 시간
|
||||
登录IP: 로그인 IP
|
||||
登录状态: 로그인 상태
|
||||
成功: 성공
|
||||
失败: 실패
|
||||
操作日志: 작업 로그
|
||||
操作类型: 작업 유형
|
||||
操作时间: 작업 시간
|
||||
操作人: 작업자
|
||||
操作结果: 작업 결과
|
||||
API密钥管理: API 키 관리
|
||||
密钥名称: 키 이름
|
||||
密钥值: 키 값
|
||||
密钥状态: 키 상태
|
||||
新增密钥: 키 추가
|
||||
编辑密钥: 키 편집
|
||||
删除密钥: 키 삭제
|
||||
在线用户: 온라인 사용자
|
||||
会话ID: 세션 ID
|
||||
最后活动时间: 마지막 활동 시간
|
||||
强制下线: 강제 로그아웃
|
||||
安全设置: 보안 설정
|
||||
密码策略: 비밀번호 정책
|
||||
密码长度: 비밀번호 길이
|
||||
密码复杂度: 비밀번호 복잡도
|
||||
密码有效期: 비밀번호 유효기간
|
||||
登录失败锁定: 로그인 실패 잠금
|
||||
最大失败次数: 최대 실패 횟수
|
||||
锁定时长: 잠금 시간
|
||||
双因素认证: 이중 인증
|
||||
启用双因素: 이중 인증 활성화
|
||||
验证码: 인증 코드
|
||||
Token管理: 토큰 관리
|
||||
Token名称: 토큰 이름
|
||||
Token过期时间: 토큰 만료 시간
|
||||
新增Token: 토큰 추가
|
||||
编辑Token: 토큰 편집
|
||||
删除Token: 토큰 삭제
|
||||
刷新Token: 토큰 갱신
|
||||
用户组: 사용자 그룹
|
||||
组名称: 그룹 이름
|
||||
组描述: 그룹 설명
|
||||
新增用户组: 사용자 그룹 추가
|
||||
编辑用户组: 사용자 그룹 편집
|
||||
删除用户组: 사용자 그룹 삭제
|
||||
成员管理: 멤버 관리
|
||||
添加成员: 멤버 추가
|
||||
移除成员: 멤버 제거
|
||||
搜索: 검색
|
||||
名称或编码: 이름 또는 코드
|
||||
操作: 작업
|
||||
描述: 설명
|
||||
类型: 유형
|
||||
名称: 이름
|
||||
编码: 코드
|
||||
备注: 비고
|
||||
创建时间: 생성 시간
|
||||
更新时间: 업데이트 시간
|
||||
新增: 추가
|
||||
保存: 저장
|
||||
取消: 취소
|
||||
确认: 확인
|
||||
删除: 삭제
|
||||
编辑: 편집
|
||||
查看: 보기
|
||||
导出: 내보내기
|
||||
打印: 인쇄
|
||||
刷新: 새로고침
|
||||
返回: 뒤로
|
||||
提交: 제출
|
||||
重置: 초기화
|
||||
Conform: 확인
|
||||
Discard: 폐기
|
||||
Submit: 제출
|
||||
Reset: 초기화
|
||||
Cancel: 취소
|
||||
全部: 전체
|
||||
日期: 날짜
|
||||
管理员: 관리자
|
||||
普通用户: 일반 사용자
|
||||
只读用户: 읽기 전용 사용자
|
||||
访问控制: 접근 제어
|
||||
最后登录: 마지막 로그인
|
||||
清除登录次数: 로그인 실패 횟수 초기화
|
||||
新密码: 새 비밀번호
|
||||
确认清除该用户的连续登录失败次数?: 이 사용자의 연속 로그인 실패 횟수를 초기화하시겠습니까?
|
||||
为选中用户设置新密码(至少8位): 선택한 사용자에게 새 비밀번호(8자 이상) 설정
|
||||
143
i18n/zh/msg.txt
143
i18n/zh/msg.txt
@ -1,143 +0,0 @@
|
||||
Account Locked: Account Locked
|
||||
Account locked due to too many failed login attempts. Please try again in 5 minutes.: Account locked due to too many failed login attempts. Please try again in 5 minutes.
|
||||
Add Error: Add Error
|
||||
Add Success: Add Success
|
||||
Add reseller Error: Add reseller Error
|
||||
Cancel: Cancel
|
||||
Conform: Conform
|
||||
Discard: Discard
|
||||
Error: Error
|
||||
Login: Login
|
||||
Login Error: Login Error
|
||||
Reset: Reset
|
||||
Reset Password: Reset Password
|
||||
Submit: Submit
|
||||
Success: Success
|
||||
apikey: apikey
|
||||
dappid参数必填: dappid参数必填
|
||||
failed: failed
|
||||
id: id
|
||||
id/orgid必填: id/orgid必填
|
||||
logout success: logout success
|
||||
no user selected: no user selected
|
||||
ok: ok
|
||||
system error: system error
|
||||
user disabled: user disabled
|
||||
user enabled: user enabled
|
||||
user name or password error: user name or password error
|
||||
user register: user register
|
||||
user.id和user.orgid必填: user.id和user.orgid必填
|
||||
users必填: users必填
|
||||
you are not owner user: you are not owner user
|
||||
角色: 角色
|
||||
两次输入的密码不一致: 两次输入的密码不一致
|
||||
个人信息已更新: 个人信息已更新
|
||||
主营业务描述: 主营业务描述
|
||||
保存成功: 保存成功
|
||||
允许IP集: 允许IP集
|
||||
刷新页面: 刷新页面
|
||||
发送验证码: 发送验证码
|
||||
发送验证码出错,请检查短信模板配置和百度API连接: 发送验证码出错,请检查短信模板配置和百度API连接
|
||||
同步应用id: 同步应用id
|
||||
名称: 名称
|
||||
启用日期: 启用日期
|
||||
图标: 图标
|
||||
地址: 地址
|
||||
增加管理员: 增加管理员
|
||||
失效日期: 失效日期
|
||||
完善个人信息: 完善个人信息
|
||||
完善信息: 完善信息
|
||||
审计日志: 审计日志
|
||||
密码: 密码
|
||||
密码登录: 密码登录
|
||||
应用名称: 应用名称
|
||||
我的角色: 我的角色
|
||||
所在地区id: 所在地区id
|
||||
所在城市id: 所在城市id
|
||||
所在省id: 所在省id
|
||||
所属机构: 所属机构
|
||||
手机: 手机
|
||||
手机号: 手机号
|
||||
手机号需短信验证后方可注册: 手机号需短信验证后方可注册
|
||||
手机登录: 手机登录
|
||||
手机短信验证码出错: 手机短信验证码出错
|
||||
扫描未授权文件: 扫描未授权文件
|
||||
扫码: 扫码
|
||||
描述: 描述
|
||||
是否审计: 是否审计
|
||||
显示名: 显示名
|
||||
最后登录: 最后登录
|
||||
最后登录失败时间: 最后登录失败时间
|
||||
未注册的手机号将自动创建账号: 未注册的手机号将自动创建账号
|
||||
机构: 机构
|
||||
机构id: 机构id
|
||||
机构别名: 机构别名
|
||||
机构名称: 机构名称
|
||||
机构拥有角色: 机构拥有角色
|
||||
机构简称: 机构简称
|
||||
机构类型: 机构类型
|
||||
机构编码: 机构编码
|
||||
权限: 权限
|
||||
权限id: 权限id
|
||||
查询路径权限角色: 查询路径权限角色
|
||||
欢迎登录: 欢迎登录
|
||||
没有收到手机号: 没有收到手机号
|
||||
注册失败: 注册失败
|
||||
注册成功: 注册成功
|
||||
注册日期: 注册日期
|
||||
注册账号: 注册账号
|
||||
父机构id: 父机构id
|
||||
父权限id: 父权限id
|
||||
用户: 用户
|
||||
用户id: 用户id
|
||||
用户名: 用户名
|
||||
用户名已被占用: 用户名已被占用
|
||||
用户应用: 用户应用
|
||||
用户状态: 用户状态
|
||||
用户管理: 用户管理
|
||||
用户角色: 用户角色
|
||||
用户部门表: 用户部门表
|
||||
登录成功: 登录成功
|
||||
短信码已生成: 短信码已生成
|
||||
短信验证码: 短信验证码
|
||||
短信验证码错误或已过期,请重新获取: 短信验证码错误或已过期,请重新获取
|
||||
确认密码: 确认密码
|
||||
签退: 签退
|
||||
类型: 类型
|
||||
系统错误: 系统错误
|
||||
系统错误,请稍后重试: 系统错误,请稍后重试
|
||||
组织结构代码: 组织结构代码
|
||||
网站域名: 网站域名
|
||||
联系人: 联系人
|
||||
联系人电话: 联系人电话
|
||||
营业执照: 营业执照
|
||||
角色id: 角色id
|
||||
角色名称: 角色名称
|
||||
角色权限表: 角色权限表
|
||||
该手机号已注册,请直接登录: 该手机号已注册,请直接登录
|
||||
请先发送并输入短信验证码: 请先发送并输入短信验证码
|
||||
请输入密码: 请输入密码
|
||||
请输入手机号: 请输入手机号
|
||||
请输入用户名: 请输入用户名
|
||||
调用参数: 调用参数
|
||||
调用日期: 调用日期
|
||||
调用时间戳: 调用时间戳
|
||||
账号: 账号
|
||||
路径: 路径
|
||||
身份证: 身份证
|
||||
远程IP: 远程IP
|
||||
连续失败次数: 连续失败次数
|
||||
邮件地址: 邮件地址
|
||||
邮箱: 邮箱
|
||||
部门id: 部门id
|
||||
重置密码: 重置密码
|
||||
错误: 错误
|
||||
需要短信验证key: 需要短信验证key
|
||||
需输入手机号: 需输入手机号
|
||||
需输入验证码: 需输入验证码
|
||||
验证失败: 验证失败
|
||||
验证码: 验证码
|
||||
清除登录次数: 清除登录次数
|
||||
新密码: 新密码
|
||||
确认清除该用户的连续登录失败次数?: 确认清除该用户的连续登录失败次数?
|
||||
为选中用户设置新密码(至少8位): 为选中用户设置新密码(至少8位)
|
||||
@ -1,23 +0,0 @@
|
||||
{
|
||||
"appcodes": [
|
||||
{
|
||||
"id": "user_status",
|
||||
"name": "用户状态",
|
||||
"hierarchy_flg": "0"
|
||||
}
|
||||
],
|
||||
"appcodes_kv": [
|
||||
{
|
||||
"id": "user_status_0",
|
||||
"parentid": "user_status",
|
||||
"k": "0",
|
||||
"v": "可用"
|
||||
},
|
||||
{
|
||||
"id": "user_status_1",
|
||||
"parentid": "user_status",
|
||||
"k": "1",
|
||||
"v": "禁用"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -11,15 +11,11 @@
|
||||
"alters":{}
|
||||
},
|
||||
"edit_exclouded_fields":[],
|
||||
"parentField":"parentid"
|
||||
},
|
||||
"subtables":[
|
||||
{
|
||||
"field":"permid",
|
||||
"title":"权限角色",
|
||||
"subtable":"rolepermission"
|
||||
}
|
||||
]
|
||||
|
||||
"parentField":"parentid",
|
||||
"toolbar":{
|
||||
},
|
||||
"binds":[
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
16
json/userapp.json
Normal file
16
json/userapp.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"tblname": "userapp",
|
||||
"title":"用户",
|
||||
"params": {
|
||||
"confidential_fields":["apikey"],
|
||||
"sortby":"appname",
|
||||
"browserfields": {
|
||||
"exclouded": ["id", "userid"],
|
||||
"alters": {}
|
||||
},
|
||||
"editexclouded": [
|
||||
"id", "userid"
|
||||
],
|
||||
"record_toolbar": null
|
||||
}
|
||||
}
|
||||
14
json/userdepartment.json
Normal file
14
json/userdepartment.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"tblname": "userdepartment",
|
||||
"title":"用户",
|
||||
"params": {
|
||||
"browserfields": {
|
||||
"exclouded": ["id", "userid"],
|
||||
"cwidth": {}
|
||||
},
|
||||
"editexclouded": [
|
||||
"id", "userid"
|
||||
],
|
||||
"record_toolbar": null
|
||||
}
|
||||
}
|
||||
@ -4,14 +4,7 @@
|
||||
"params": {
|
||||
"browserfields": {
|
||||
"exclouded": ["id", "orgid"],
|
||||
"alters": {
|
||||
"roleid": {
|
||||
"uitype": "code",
|
||||
"dataurl": "{{entire_url('/rbac/api/get_search_roleid.dspy')}}",
|
||||
"valueField": "roleid",
|
||||
"textField": "roleid_text"
|
||||
}
|
||||
}
|
||||
"alters": {}
|
||||
},
|
||||
"editexclouded": [
|
||||
"id",
|
||||
|
||||
103
json/users.json
103
json/users.json
@ -2,8 +2,6 @@
|
||||
"tblname": "users",
|
||||
"title":"用户",
|
||||
"params": {
|
||||
"new_data_url": "{{entire_url('/rbac/api/add_user.dspy')}}",
|
||||
"update_data_url": "{{entire_url('/rbac/api/update_user.dspy')}}",
|
||||
"sortby":"username",
|
||||
"confidential_fields":["password"],
|
||||
"logined_userorgid":"orgid",
|
||||
@ -11,106 +9,19 @@
|
||||
"exclouded": ["id", "password", "orgid", "nick_name" ],
|
||||
"cwidth": {}
|
||||
},
|
||||
"editexclouded": ["id", "nick_name", "orgid", "last_login_fail", "last_login", "sync_from", "login_fail_count", "created_at"],
|
||||
"toolbar": {
|
||||
"tools": [
|
||||
{
|
||||
"selected_row": true,
|
||||
"name": "reset_password",
|
||||
"icon": "{{entire_url('/rbac/imgs/users.svg')}}",
|
||||
"label": "重置密码"
|
||||
},
|
||||
{
|
||||
"selected_row": true,
|
||||
"name": "clear_login_fail",
|
||||
"icon": "{{entire_url('/rbac/imgs/users.svg')}}",
|
||||
"label": "清除登录次数"
|
||||
}
|
||||
]
|
||||
},
|
||||
"record_toolbar": [
|
||||
{
|
||||
"name": "enable_user",
|
||||
"label": "启用",
|
||||
"actiontype": "dspy",
|
||||
"url": "/rbac/users/enable_user.dspy",
|
||||
"options": {
|
||||
"icon": "check",
|
||||
"cwidth": 16,
|
||||
"cheight": 9
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disable_user",
|
||||
"label": "禁用",
|
||||
"actiontype": "dspy",
|
||||
"url": "/rbac/users/disable_user.dspy",
|
||||
"options": {
|
||||
"icon": "block",
|
||||
"cwidth": 16,
|
||||
"cheight": 9
|
||||
}
|
||||
}
|
||||
],
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "enable_user",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "var tid=bricks.getWidgetById('users_tbl');var d=tid.select_row.user_data;fetch('/rbac/users/enable_user.dspy?id='+d.id,{method:'POST'}).then(function(r){return r.json()}).then(function(r){if(r.widgettype==='Message'){tid.render({})}else{bricks.show_error(r.options)}})"
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "disable_user",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "var tid=bricks.getWidgetById('users_tbl');var d=tid.select_row.user_data;fetch('/rbac/users/disable_user.dspy?id='+d.id,{method:'POST'}).then(function(r){return r.json()}).then(function(r){if(r.widgettype==='Message'){tid.render({})}else{bricks.show_error(r.options)}})"
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "reset_password",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "PopupWindow",
|
||||
"popup_options": {
|
||||
"title": "重置密码",
|
||||
"resizable": true,
|
||||
"height": "60%",
|
||||
"width": "40%"
|
||||
},
|
||||
"options": {
|
||||
"method": "POST",
|
||||
"params": {},
|
||||
"url": "{{entire_url('/rbac/admin_reset_password.ui')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "clear_login_fail",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "self",
|
||||
"conform": {
|
||||
"title": "清除登录次数",
|
||||
"message": "确认清除该用户的连续登录失败次数?"
|
||||
},
|
||||
"options": {
|
||||
"method": "POST",
|
||||
"params": {},
|
||||
"url": "{{entire_url('/rbac/api/clear_login_fail.dspy')}}"
|
||||
},
|
||||
"params_mapping": {
|
||||
"mapping": {
|
||||
"id": "id"
|
||||
},
|
||||
"need_others": false
|
||||
}
|
||||
}
|
||||
"editexclouded": [
|
||||
"id", "nick_name", "orgid"
|
||||
],
|
||||
"subtables": [
|
||||
{
|
||||
"field":"userid",
|
||||
"title":"用户角色",
|
||||
"subtable":"userrole"
|
||||
},
|
||||
{
|
||||
"field":"userid",
|
||||
"title":"APIKEY",
|
||||
"subtable":"userapp"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
-- RBAC users 表结构修正:对齐 models/users.json 定义
|
||||
-- 修复早期手工建表导致的类型/约束错误。
|
||||
-- 实测 pipeline 库 users 表 last_login_fail 为 NOT NULL DEFAULT current_timestamp(),
|
||||
-- 导致 up_login.dspy 成功登录时 SET last_login_fail=NULL 被 MySQL 转成当前时间戳。
|
||||
-- 对齐后:last_login_fail/last_login 可空,NULL=从未失败。
|
||||
|
||||
ALTER TABLE users MODIFY created_at date DEFAULT NULL COMMENT '注册日期';
|
||||
ALTER TABLE users MODIFY last_login timestamp NULL DEFAULT NULL COMMENT '最后登录时间';
|
||||
ALTER TABLE users MODIFY login_fail_count smallint NOT NULL DEFAULT 0 COMMENT '连续登录失败次数';
|
||||
ALTER TABLE users MODIFY last_login_fail timestamp NULL DEFAULT NULL COMMENT '最后登录失败时间';
|
||||
|
||||
-- sync_from 列幂等补加(MySQL 8.0 无 ADD COLUMN IF NOT EXISTS)
|
||||
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'sync_from');
|
||||
SET @sql = IF(@col = 0,
|
||||
'ALTER TABLE users ADD COLUMN sync_from varchar(32) DEFAULT NULL COMMENT ''同步应用id''',
|
||||
'SELECT ''sync_from already exists''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -1,52 +0,0 @@
|
||||
-- ================================================================
|
||||
-- RBAC users 表结构对齐(生产执行,幂等可重复跑)
|
||||
-- 依赖功能:登录审计(login/login_fail) + 账户锁定(5分钟失败窗口计数)
|
||||
-- 对齐 models/users.json 定义
|
||||
-- 用法:先 USE 到 users 表所在库(Sage 生产为 sage 库)再执行
|
||||
-- ================================================================
|
||||
|
||||
-- 1) created_at 注册日期(date 可空)
|
||||
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='created_at');
|
||||
SET @sql = IF(@col=0,
|
||||
'ALTER TABLE users ADD COLUMN created_at date DEFAULT NULL COMMENT ''注册日期''',
|
||||
'ALTER TABLE users MODIFY created_at date DEFAULT NULL COMMENT ''注册日期''');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- 2) last_login 最后登录时间(timestamp 可空)
|
||||
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='last_login');
|
||||
SET @sql = IF(@col=0,
|
||||
'ALTER TABLE users ADD COLUMN last_login timestamp NULL DEFAULT NULL COMMENT ''最后登录时间''',
|
||||
'ALTER TABLE users MODIFY last_login timestamp NULL DEFAULT NULL COMMENT ''最后登录时间''');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- 3) login_fail_count 连续登录失败次数(smallint NOT NULL DEFAULT 0)
|
||||
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='login_fail_count');
|
||||
SET @sql = IF(@col=0,
|
||||
'ALTER TABLE users ADD COLUMN login_fail_count smallint NOT NULL DEFAULT 0 COMMENT ''连续登录失败次数''',
|
||||
'ALTER TABLE users MODIFY login_fail_count smallint NOT NULL DEFAULT 0 COMMENT ''连续登录失败次数''');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- 4) last_login_fail 最后登录失败时间(关键修复:必须 NULL 可空)
|
||||
-- 早期手工建表为 NOT NULL DEFAULT current_timestamp(),
|
||||
-- 成功登录时 SET last_login_fail=NULL 会被 MySQL 静默转成当前时间戳,
|
||||
-- 导致 5 分钟失败窗口的 IS NULL 判定永远失效,账户锁定逻辑错误。
|
||||
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='last_login_fail');
|
||||
SET @sql = IF(@col=0,
|
||||
'ALTER TABLE users ADD COLUMN last_login_fail timestamp NULL DEFAULT NULL COMMENT ''最后登录失败时间''',
|
||||
'ALTER TABLE users MODIFY last_login_fail timestamp NULL DEFAULT NULL COMMENT ''最后登录失败时间''');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- 5) sync_from 同步应用id(仅补加,已存在则跳过)
|
||||
SET @col = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='sync_from');
|
||||
SET @sql = IF(@col=0,
|
||||
'ALTER TABLE users ADD COLUMN sync_from varchar(32) DEFAULT NULL COMMENT ''同步应用id''',
|
||||
'SELECT ''sync_from already exists''');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
-- 验证结果(应显示 5 行,last_login_fail 的 Null 列为 YES)
|
||||
SHOW COLUMNS FROM users WHERE Field IN ('created_at','last_login','login_fail_count','last_login_fail','sync_from');
|
||||
55
models/userapp.json
Normal file
55
models/userapp.json
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "userapp",
|
||||
"title": "用户应用",
|
||||
"primary": [
|
||||
"id"
|
||||
],
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "id",
|
||||
"title": "id",
|
||||
"type": "str",
|
||||
"length": 32
|
||||
},
|
||||
{
|
||||
"name": "userid",
|
||||
"title": "用户id",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "appname",
|
||||
"title": "应用名称",
|
||||
"type": "str",
|
||||
"length": 99
|
||||
},
|
||||
{
|
||||
"name": "apikey",
|
||||
"title": "apikey",
|
||||
"type": "str",
|
||||
"length": 400
|
||||
},
|
||||
{
|
||||
"name": "enabled_date",
|
||||
"title": "启用日期",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"name": "expired_date",
|
||||
"title": "失效日期",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"name": "allowed_ips",
|
||||
"title": "允许IP集",
|
||||
"type": "str",
|
||||
"length": 200
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
models/userapp.xlsx
Normal file
BIN
models/userapp.xlsx
Normal file
Binary file not shown.
31
models/userdepartment.json
Normal file
31
models/userdepartment.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"name": "userdepartment",
|
||||
"title": "用户部门表",
|
||||
"primary": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "id",
|
||||
"title": "id",
|
||||
"type": "str",
|
||||
"length": 32
|
||||
},
|
||||
{
|
||||
"name": "userid",
|
||||
"title": "用户id",
|
||||
"type": "str",
|
||||
"length": 32
|
||||
},
|
||||
{
|
||||
"name": "depid",
|
||||
"title": "部门id",
|
||||
"type": "str",
|
||||
"length": 32
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
models/userdepartment.xlsx
Normal file
BIN
models/userdepartment.xlsx
Normal file
Binary file not shown.
@ -67,7 +67,7 @@
|
||||
{
|
||||
"name": "created_at",
|
||||
"title": "注册日期",
|
||||
"type": "date"
|
||||
"type": "timestamp"
|
||||
},
|
||||
{
|
||||
"name": "last_login",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import time
|
||||
|
||||
from traceback import format_exc
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from aiohttp import BasicAuth
|
||||
from sqlor.dbpools import DBPools, get_sor_context
|
||||
from appPublic.registerfunction import RegisterFunction
|
||||
@ -55,8 +55,6 @@ async def sor_get_org_users(sor, orgid):
|
||||
return []
|
||||
|
||||
async def create_org(sor, ns, orgtypes=[]):
|
||||
if not ns.get('orgname'):
|
||||
ns.orgname = ns.id # fallback: use id as orgname
|
||||
await sor.C('organization', ns)
|
||||
if orgtypes == []:
|
||||
orgtypes = ['customer']
|
||||
@ -100,20 +98,6 @@ async def create_user(sor, ns, roles=[]):
|
||||
})
|
||||
|
||||
async def register_user(sor, ns):
|
||||
# 注册开关检查:appbase params 表 register_open(1=开放,0=关闭),superuser 可在参数管理界面改
|
||||
try:
|
||||
pro = await sor.R('params', {'params_name': 'register_open'})
|
||||
if pro and str(pro[0].params_value).strip() == '0':
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": "注册暂未开放,请联系管理员",
|
||||
"user": None
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
debug(f'register_user: register_open check skipped: {e}')
|
||||
|
||||
if ns.password != ns.cfm_password:
|
||||
debug('password not match')
|
||||
return False
|
||||
@ -134,16 +118,8 @@ async def register_user(sor, ns):
|
||||
ns.created_at = timestampstr()
|
||||
ns.login_fail_count = 0
|
||||
ns1 = DictObject(id=id, orgname=ns.username)
|
||||
if ns.get('parentid'):
|
||||
ns1.parentid = ns.parentid
|
||||
await create_org(sor, ns1)
|
||||
roles = [
|
||||
{
|
||||
'orgtypeid': 'customer',
|
||||
'roles': ['customer', 'admin']
|
||||
}
|
||||
]
|
||||
await create_user(sor, ns, roles)
|
||||
await create_user(sor, ns)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
@ -157,30 +133,6 @@ def get_dbname():
|
||||
return None
|
||||
return f('rbac')
|
||||
|
||||
def _req_ip(request):
|
||||
"""安全取客户端 IP(审计用),request 可能是 aiohttp Request 或 dict-like。"""
|
||||
try:
|
||||
return request.get('client_ip', '') if hasattr(request, 'get') else ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
async def _audit_login(sor, request, user_id, username, action, result='ok'):
|
||||
"""登录审计:检查系统是否安装审计模块(app_audit),有则写入登录审计日志。
|
||||
|
||||
审计是旁路,模块缺失或写入失败都不阻断登录主流程。
|
||||
"""
|
||||
try:
|
||||
from app_audit import audit_log
|
||||
except ImportError:
|
||||
return # 未安装审计模块,跳过
|
||||
try:
|
||||
await audit_log(sor, user_id, username, action, result=result,
|
||||
client_ip=_req_ip(request))
|
||||
except Exception as e:
|
||||
debug(f'audit_login({action}) failed: {e}')
|
||||
|
||||
|
||||
async def checkUserPassword(request, username, password):
|
||||
"""Authenticate user with password, supporting login lockout mechanism.
|
||||
|
||||
@ -197,40 +149,30 @@ async def checkUserPassword(request, username, password):
|
||||
recs = await sor.sqlExe(sql, {'username': username})
|
||||
if len(recs) < 1:
|
||||
debug(f'User {username} not found')
|
||||
await _audit_login(sor, request, None, username, 'login_fail', 'fail')
|
||||
return False
|
||||
|
||||
user = recs[0]
|
||||
# Check user status (disabled)
|
||||
user_status = getattr(user, 'user_status', '0') or '0'
|
||||
if user_status != '0':
|
||||
debug(f'User {username} is disabled (status={user_status})')
|
||||
await _audit_login(sor, request, user.id, username, 'login_fail', 'fail')
|
||||
return False
|
||||
fail_count = getattr(user, 'login_fail_count', 0) or 0
|
||||
last_fail = getattr(user, 'last_login_fail', None)
|
||||
|
||||
# Lockout check in Python (DB-agnostic)
|
||||
if _is_locked(fail_count, last_fail):
|
||||
debug(f'User {username} locked out')
|
||||
await _audit_login(sor, request, user.id, username, 'login_fail', 'fail')
|
||||
return False
|
||||
|
||||
# Verify password with standard SQL
|
||||
sql = "select * from users where username=${username}$ and password=${password}$"
|
||||
recs = await sor.sqlExe(sql, {'username': username, 'password': password})
|
||||
if len(recs) < 1:
|
||||
# 5分钟窗口:超过5分钟前的失败次数默认为0(原子 SQL,DB 无关)
|
||||
# Atomic increment -- standard SQL, works on all databases
|
||||
now_str = _now_ts()
|
||||
stale_before = (datetime.now() - timedelta(seconds=LOGIN_LOCKOUT_DURATION)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
await sor.sqlExe("""
|
||||
UPDATE users
|
||||
SET login_fail_count = CASE WHEN last_login_fail IS NULL OR last_login_fail < ${stale}$ THEN 1 ELSE login_fail_count + 1 END,
|
||||
SET login_fail_count = login_fail_count + 1,
|
||||
last_login_fail = ${now}$
|
||||
WHERE id = ${id}$
|
||||
""", {'id': user.id, 'now': now_str, 'stale': stale_before})
|
||||
""", {'id': user.id, 'now': now_str})
|
||||
debug(f'Login failed for {username}, fail_count incremented')
|
||||
await _audit_login(sor, request, user.id, username, 'login_fail', 'fail')
|
||||
return False
|
||||
|
||||
# Login successful -- atomic reset
|
||||
@ -245,7 +187,6 @@ async def checkUserPassword(request, username, password):
|
||||
await user_login(request, user.id,
|
||||
username=user.username,
|
||||
userorgid=user.orgid)
|
||||
await _audit_login(sor, request, user.id, user.username, 'login', 'ok')
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -259,21 +200,13 @@ async def basic_auth(sor, request):
|
||||
sql = "select * from users where username=${username}$ and password=${password}$"
|
||||
recs = await sor.sqlExe(sql, {'username':username,'password':password})
|
||||
if len(recs) < 1:
|
||||
await _audit_login(sor, request, None, username, 'login_fail', 'fail')
|
||||
return None
|
||||
# Check lockout in Python layer (DB-agnostic)
|
||||
user = recs[0]
|
||||
# Check user status (disabled)
|
||||
user_status = getattr(user, 'user_status', '0') or '0'
|
||||
if user_status != '0':
|
||||
debug(f'User {username} is disabled (status={user_status}) via basic auth')
|
||||
await _audit_login(sor, request, user.id, username, 'login_fail', 'fail')
|
||||
return None
|
||||
fail_count = getattr(user, 'login_fail_count', 0) or 0
|
||||
last_fail = getattr(user, 'last_login_fail', None)
|
||||
if _is_locked(fail_count, last_fail):
|
||||
debug(f'User {username} locked out via basic auth')
|
||||
await _audit_login(sor, request, user.id, username, 'login_fail', 'fail')
|
||||
return None
|
||||
# Update last_login on successful basic auth (standard SQL)
|
||||
now_str = _now_ts()
|
||||
@ -284,9 +217,8 @@ async def basic_auth(sor, request):
|
||||
WHERE id = ${id}$
|
||||
""", {'id': recs[0].id, 'now': now_str})
|
||||
await user_login(request, recs[0].id,
|
||||
username=recs[0].username,
|
||||
userorgid=recs[0].orgid)
|
||||
await _audit_login(sor, request, recs[0].id, recs[0].username, 'login', 'ok')
|
||||
username=recs[0].username,
|
||||
userorgid=recs[0].orgid)
|
||||
return recs[0].id
|
||||
|
||||
async def getAuthenticationUserid(sor, request):
|
||||
|
||||
148
rbac/init.py
148
rbac/init.py
@ -1,84 +1,32 @@
|
||||
from ahserver.auth_api import AuthAPI
|
||||
from ahserver.serverenv import ServerEnv
|
||||
from sqlor.dbpools import DBPools
|
||||
from .orgs import (
|
||||
get_platform_providers
|
||||
)
|
||||
from .userperm import UserPermissions
|
||||
from .user_stats import get_user_stats
|
||||
from .rbac_tools import (
|
||||
query_path_roles,
|
||||
scan_unauth_files
|
||||
)
|
||||
from rbac.check_perm import (
|
||||
objcheckperm,
|
||||
objcheckperm,
|
||||
get_org_users,
|
||||
sor_get_org_users,
|
||||
checkUserPassword,
|
||||
register_user,
|
||||
register_auth_method,
|
||||
create_org,
|
||||
checkUserPassword,
|
||||
register_user,
|
||||
register_auth_method,
|
||||
create_org,
|
||||
create_user
|
||||
)
|
||||
from rbac.set_role_perms import (
|
||||
sor_add_user_roles,
|
||||
set_role_perm,
|
||||
set_role_perm,
|
||||
set_role_perms
|
||||
)
|
||||
from sqlor.dbpools import DBPools
|
||||
|
||||
|
||||
def _get_rbac_dbname():
|
||||
env = ServerEnv()
|
||||
return env.get_module_dbname('rbac')
|
||||
|
||||
|
||||
async def on_rbac_role_event(data):
|
||||
"""role 表变更后,全量失效 rp_caches"""
|
||||
up = ServerEnv().userpermissions
|
||||
up.invalidate_rp_cache()
|
||||
|
||||
|
||||
async def on_rbac_userrole_event(data):
|
||||
"""userrole 表变更后,精确失效对应用户的 ur_caches"""
|
||||
ns = data.get('ns', {})
|
||||
userid = ns.get('userid')
|
||||
up = ServerEnv().userpermissions
|
||||
if userid:
|
||||
up.invalidate_user_cache(userid)
|
||||
else:
|
||||
up.invalidate_all_user_caches()
|
||||
|
||||
|
||||
async def on_rbac_permission_event(data):
|
||||
"""permission 表变更后,全量失效 rp_caches"""
|
||||
up = ServerEnv().userpermissions
|
||||
up.invalidate_rp_cache()
|
||||
|
||||
|
||||
async def on_rbac_rolepermission_event(data):
|
||||
"""rolepermission 表变更后,全量失效 rp_caches"""
|
||||
up = ServerEnv().userpermissions
|
||||
up.invalidate_rp_cache()
|
||||
|
||||
|
||||
def register_rbac_event_listeners():
|
||||
db = DBPools()
|
||||
dbname = _get_rbac_dbname()
|
||||
|
||||
# role 表
|
||||
db.bind(f'{dbname}:role:c:after', on_rbac_role_event)
|
||||
db.bind(f'{dbname}:role:u:after', on_rbac_role_event)
|
||||
db.bind(f'{dbname}:role:d:after', on_rbac_role_event)
|
||||
|
||||
# userrole 表
|
||||
db.bind(f'{dbname}:userrole:c:after', on_rbac_userrole_event)
|
||||
db.bind(f'{dbname}:userrole:u:after', on_rbac_userrole_event)
|
||||
db.bind(f'{dbname}:userrole:d:after', on_rbac_userrole_event)
|
||||
|
||||
# permission 表
|
||||
db.bind(f'{dbname}:permission:c:after', on_rbac_permission_event)
|
||||
db.bind(f'{dbname}:permission:u:after', on_rbac_permission_event)
|
||||
db.bind(f'{dbname}:permission:d:after', on_rbac_permission_event)
|
||||
|
||||
# rolepermission 表
|
||||
db.bind(f'{dbname}:rolepermission:c:after', on_rbac_rolepermission_event)
|
||||
db.bind(f'{dbname}:rolepermission:u:after', on_rbac_rolepermission_event)
|
||||
db.bind(f'{dbname}:rolepermission:d:after', on_rbac_rolepermission_event)
|
||||
from appPublic.log import debug
|
||||
from ahserver.cache_sync import get_cache_sync
|
||||
|
||||
async def get_owner_orgid(*args, **kw):
|
||||
return '0'
|
||||
@ -86,6 +34,58 @@ async def get_owner_orgid(*args, **kw):
|
||||
async def sor_get_owner_orgid(sor, orgid):
|
||||
return '0'
|
||||
|
||||
def _bind_rbac_events(dbpools, dbname, up):
|
||||
"""Bind database events to RBAC cache invalidation handlers.
|
||||
|
||||
Events are dispatched by sqlor after C/U/D operations.
|
||||
Format: {dbname}:{tablename}:{c|u|d}:after
|
||||
"""
|
||||
bindings = [
|
||||
# users table: invalidate specific user cache on C/U/D
|
||||
(f'{dbname}.users:c:after', up.on_user_create),
|
||||
(f'{dbname}.users:u:after', up.on_user_update),
|
||||
(f'{dbname}.users:d:after', up.on_user_delete),
|
||||
# rolepermission table: invalidate role-permission cache on any change
|
||||
(f'{dbname}.rolepermission:c:after', up.on_rolepermission_change),
|
||||
(f'{dbname}.rolepermission:u:after', up.on_rolepermission_change),
|
||||
(f'{dbname}.rolepermission:d:after', up.on_rolepermission_change),
|
||||
# permission table: invalidate role-permission cache on update
|
||||
(f'{dbname}.permission:u:after', up.on_permission_change),
|
||||
# role table: invalidate ALL caches (affects all users)
|
||||
(f'{dbname}.role:c:after', up.on_role_change),
|
||||
(f'{dbname}.role:u:after', up.on_role_change),
|
||||
(f'{dbname}.role:d:after', up.on_role_change),
|
||||
# userrole table: invalidate specific user cache based on userid
|
||||
(f'{dbname}.userrole:c:after', up.on_userrole_change),
|
||||
(f'{dbname}.userrole:u:after', up.on_userrole_change),
|
||||
(f'{dbname}.userrole:d:after', up.on_userrole_change),
|
||||
]
|
||||
for event_name, handler in bindings:
|
||||
dbpools.bind(event_name, handler)
|
||||
debug(f'RBAC event bound: {event_name}')
|
||||
|
||||
|
||||
async def start_cache_sync():
|
||||
"""Start cache_sync and register RBAC reload callbacks."""
|
||||
env = ServerEnv()
|
||||
cache_sync = get_cache_sync()
|
||||
|
||||
# Get Redis URL from session config
|
||||
try:
|
||||
redis_url = env.conf.website.session_redis.url
|
||||
except AttributeError:
|
||||
redis_url = "redis://127.0.0.1:6379"
|
||||
|
||||
await cache_sync.start(redis_url)
|
||||
debug(f'RBAC cache_sync started with Redis URL: {redis_url}')
|
||||
|
||||
# Register callbacks for cache invalidation messages from other processes
|
||||
up = env.userpermissions
|
||||
cache_sync.register('rbac:rp', up.invalidate_rp_cache)
|
||||
cache_sync.register('rbac:ur:all', up.invalidate_all_user_caches)
|
||||
# Note: rbac:ur:{userid} callbacks are handled by the invalidate_user_cache method itself
|
||||
|
||||
|
||||
def load_rbac():
|
||||
AuthAPI.checkUserPermission = objcheckperm
|
||||
env = ServerEnv()
|
||||
@ -103,11 +103,19 @@ def load_rbac():
|
||||
env.sor_get_org_users = sor_get_org_users
|
||||
env.get_owner_orgid = get_owner_orgid
|
||||
env.sor_add_user_roles = sor_add_user_roles
|
||||
env.get_user_stats = get_user_stats
|
||||
env.query_path_roles = query_path_roles
|
||||
env.scan_unauth_files = scan_unauth_files
|
||||
# Cache invalidation methods for use after role/permission changes
|
||||
env.invalidate_user_perm_cache = env.userpermissions.invalidate_user_cache
|
||||
env.invalidate_all_perm_caches = env.userpermissions.invalidate_all_user_caches
|
||||
env.invalidate_role_perm_cache = env.userpermissions.invalidate_rp_cache
|
||||
# Bind hot_reload event — instance method, WeakMethod safe (stored on env)
|
||||
if hasattr(env, 'event_dispatcher'):
|
||||
env.event_dispatcher.bind('hot_reload', env.userpermissions.on_hot_reload)
|
||||
register_rbac_event_listeners()
|
||||
|
||||
# Bind database events for automatic cache invalidation
|
||||
dbpools = DBPools()
|
||||
dbname = env.get_module_dbname('rbac')
|
||||
if dbname:
|
||||
_bind_rbac_events(dbpools, dbname, env.userpermissions)
|
||||
debug(f'RBAC event listeners bound for database: {dbname}')
|
||||
else:
|
||||
debug('RBAC event listeners skipped: no database configured for rbac module')
|
||||
|
||||
@ -114,6 +114,31 @@ async def set_role_perms(dbname, module, orgtype, role, items):
|
||||
for tblname in items:
|
||||
await set_role_perm(dbname, module, orgtype, role, tblname)
|
||||
|
||||
async def send_rbac_invalidation():
|
||||
"""Send cache invalidation message to all processes via Redis Pub/Sub."""
|
||||
try:
|
||||
from ahserver.cache_sync import get_cache_sync
|
||||
cache_sync = get_cache_sync()
|
||||
# Use default Redis URL for CLI scripts
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
redis_url = env.conf.website.session_redis.url
|
||||
except (AttributeError, Exception):
|
||||
redis_url = "redis://127.0.0.1:6379"
|
||||
|
||||
await cache_sync.start(redis_url)
|
||||
# Invalidate both role-permission and all user caches
|
||||
# (CLI scripts typically change permissions/roles)
|
||||
await cache_sync.invalidate('rbac:rp')
|
||||
await cache_sync.invalidate('rbac:ur:all')
|
||||
debug('RBAC CLI: sent cache invalidation messages')
|
||||
# Give a moment for the message to be published
|
||||
await asyncio.sleep(0.1)
|
||||
await cache_sync.stop()
|
||||
except Exception as e:
|
||||
print(f'Warning: Failed to send cache invalidation: {e}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
async def main():
|
||||
if len(sys.argv) < 6:
|
||||
@ -124,6 +149,8 @@ if __name__ == '__main__':
|
||||
orgtype = sys.argv[3]
|
||||
role = sys.argv[4]
|
||||
await set_role_perms(dbname, module, orgtype, role, sys.argv[5:])
|
||||
# Send invalidation message to all running Sage processes
|
||||
await send_rbac_invalidation()
|
||||
|
||||
def run(coro):
|
||||
p = '.'
|
||||
|
||||
177
rbac/userperm.py
177
rbac/userperm.py
@ -4,21 +4,9 @@ from sqlor.dbpools import get_sor_context
|
||||
from ahserver.serverenv import ServerEnv
|
||||
from appPublic.Singleton import SingletonDecorator
|
||||
from appPublic.log import debug, error
|
||||
from appPublic.jsonConfig import getConfig
|
||||
from appPublic.share_cache import cache_get, cache_set, cache_invalidate
|
||||
from ahserver.cache_sync import get_cache_sync
|
||||
|
||||
|
||||
def _cache_enabled(module_name='rbac'):
|
||||
"""Check if cache is enabled for the given module in config.json"""
|
||||
try:
|
||||
config = getConfig()
|
||||
module_cache = config.module_cache
|
||||
if module_cache is None:
|
||||
return True # Default to enabled if not configured
|
||||
return getattr(module_cache, module_name, True)
|
||||
except Exception:
|
||||
return True # Default to enabled on error
|
||||
|
||||
class LRUCache:
|
||||
"""Async-safe LRU cache with TTL support.
|
||||
|
||||
@ -39,8 +27,6 @@ class LRUCache:
|
||||
|
||||
def get(self, key):
|
||||
import time
|
||||
if not _cache_enabled('rbac'):
|
||||
return None
|
||||
if key not in self._cache:
|
||||
return None
|
||||
value, expire_at = self._cache[key]
|
||||
@ -52,8 +38,6 @@ class LRUCache:
|
||||
|
||||
def set(self, key, value):
|
||||
import time
|
||||
if not _cache_enabled('rbac'):
|
||||
return
|
||||
if key in self._cache:
|
||||
self._cache.move_to_end(key)
|
||||
self._cache[key] = (value, time.time() + self.ttl)
|
||||
@ -99,96 +83,82 @@ class UserPermissions:
|
||||
# Async lock for rp_caches initialization (lazy init)
|
||||
self._rp_lock = None
|
||||
|
||||
def on_hot_reload(self, data=None):
|
||||
"""Event handler for hot_reload event. Clears all caches."""
|
||||
from appPublic.log import debug
|
||||
debug(f'[rbac] on_hot_reload called, clearing caches (data={data})')
|
||||
self.ur_caches.clear()
|
||||
self.invalidate_rp_cache()
|
||||
|
||||
def on_user_update(self, data):
|
||||
async def on_user_update(self, data):
|
||||
"""Event handler for users table update.
|
||||
Clears the specific user's permission cache.
|
||||
"""
|
||||
try:
|
||||
userid = getattr(data, 'id', None)
|
||||
if userid:
|
||||
self.invalidate_user_cache(userid)
|
||||
asyncio.ensure_future(cache_invalidate('rbac', f'user_roles:{userid}'))
|
||||
await self.invalidate_user_cache(userid)
|
||||
debug(f'RBAC cache invalidated for user id={userid} (users update)')
|
||||
except Exception as e:
|
||||
error(f'RBAC on_user_update handler error: {e}')
|
||||
|
||||
def on_user_create(self, data):
|
||||
async def on_user_create(self, data):
|
||||
"""Event handler for users table insert.
|
||||
Clears the specific user's permission cache.
|
||||
"""
|
||||
try:
|
||||
userid = getattr(data, 'id', None)
|
||||
if userid:
|
||||
self.invalidate_user_cache(userid)
|
||||
asyncio.ensure_future(cache_invalidate('rbac', f'user_roles:{userid}'))
|
||||
await self.invalidate_user_cache(userid)
|
||||
debug(f'RBAC cache invalidated for user id={userid} (users create)')
|
||||
except Exception as e:
|
||||
error(f'RBAC on_user_create handler error: {e}')
|
||||
|
||||
def on_user_delete(self, data):
|
||||
async def on_user_delete(self, data):
|
||||
"""Event handler for users table delete.
|
||||
Clears the specific user's permission cache.
|
||||
"""
|
||||
try:
|
||||
userid = getattr(data, 'id', None)
|
||||
if userid:
|
||||
self.invalidate_user_cache(userid)
|
||||
asyncio.ensure_future(cache_invalidate('rbac', f'user_roles:{userid}'))
|
||||
await self.invalidate_user_cache(userid)
|
||||
debug(f'RBAC cache invalidated for user id={userid} (users delete)')
|
||||
except Exception as e:
|
||||
error(f'RBAC on_user_delete handler error: {e}')
|
||||
|
||||
def on_rolepermission_change(self, data):
|
||||
async def on_rolepermission_change(self, data):
|
||||
"""Event handler for rolepermission table C/U/D.
|
||||
Clears the role-permission cache.
|
||||
"""
|
||||
try:
|
||||
self.invalidate_rp_cache()
|
||||
asyncio.ensure_future(cache_invalidate('rbac', 'role_perms'))
|
||||
await self.invalidate_rp_cache()
|
||||
debug('RBAC role-permission cache invalidated (rolepermission change)')
|
||||
except Exception as e:
|
||||
error(f'RBAC on_rolepermission_change handler error: {e}')
|
||||
|
||||
def on_permission_change(self, data):
|
||||
async def on_permission_change(self, data):
|
||||
"""Event handler for permission table update.
|
||||
Clears the role-permission cache.
|
||||
"""
|
||||
try:
|
||||
self.invalidate_rp_cache()
|
||||
asyncio.ensure_future(cache_invalidate('rbac', 'role_perms'))
|
||||
await self.invalidate_rp_cache()
|
||||
debug('RBAC role-permission cache invalidated (permission change)')
|
||||
except Exception as e:
|
||||
error(f'RBAC on_permission_change handler error: {e}')
|
||||
|
||||
def on_role_change(self, data):
|
||||
async def on_role_change(self, data):
|
||||
"""Event handler for role table C/U/D.
|
||||
Clears all user caches and role-permission cache,
|
||||
since role changes may affect any user.
|
||||
"""
|
||||
try:
|
||||
self.invalidate_all_user_caches()
|
||||
self.invalidate_rp_cache()
|
||||
asyncio.ensure_future(cache_invalidate('rbac', 'role_perms'))
|
||||
await self.invalidate_all_user_caches()
|
||||
await self.invalidate_rp_cache()
|
||||
debug('RBAC all caches invalidated (role change)')
|
||||
except Exception as e:
|
||||
error(f'RBAC on_role_change handler error: {e}')
|
||||
|
||||
def on_userrole_change(self, data):
|
||||
async def on_userrole_change(self, data):
|
||||
"""Event handler for userrole table C/U/D.
|
||||
Clears the specific user's permission cache based on userid.
|
||||
"""
|
||||
try:
|
||||
userid = getattr(data, 'userid', None)
|
||||
if userid:
|
||||
self.invalidate_user_cache(userid)
|
||||
asyncio.ensure_future(cache_invalidate('rbac', f'user_roles:{userid}'))
|
||||
await self.invalidate_user_cache(userid)
|
||||
debug(f'RBAC cache invalidated for user id={userid} (userrole change)')
|
||||
except Exception as e:
|
||||
error(f'RBAC on_userrole_change handler error: {e}')
|
||||
@ -208,28 +178,41 @@ class UserPermissions:
|
||||
return roles
|
||||
|
||||
async with get_sor_context(ServerEnv(), 'rbac') as sor:
|
||||
roles = await self.get_userroles(sor, userid)
|
||||
# When cache is enabled, get_userroles stored it and we can read back;
|
||||
# when cache is disabled, get_userroles returns the list directly.
|
||||
if roles is not None:
|
||||
return roles
|
||||
await self.get_userroles(sor, userid)
|
||||
return self.ur_caches.get(userid)
|
||||
return ['any', 'logined']
|
||||
return None
|
||||
|
||||
def invalidate_user_cache(self, userid):
|
||||
async def invalidate_user_cache(self, userid):
|
||||
"""Invalidate cache for a specific user.
|
||||
Call this after role changes, user creation, etc.
|
||||
Also broadcasts invalidation to all other processes via Redis Pub/Sub.
|
||||
"""
|
||||
self.ur_caches.invalidate(userid)
|
||||
# Broadcast to other processes
|
||||
cache_sync = get_cache_sync()
|
||||
if cache_sync.is_running:
|
||||
await cache_sync.invalidate(f'rbac:ur:{userid}')
|
||||
|
||||
def invalidate_all_user_caches(self):
|
||||
"""Invalidate all user role caches."""
|
||||
async def invalidate_all_user_caches(self):
|
||||
"""Invalidate all user role caches.
|
||||
Also broadcasts invalidation to all other processes via Redis Pub/Sub.
|
||||
"""
|
||||
self.ur_caches.clear()
|
||||
# Broadcast to other processes
|
||||
cache_sync = get_cache_sync()
|
||||
if cache_sync.is_running:
|
||||
await cache_sync.invalidate('rbac:ur:all')
|
||||
|
||||
def invalidate_rp_cache(self):
|
||||
"""Invalidate role-permission cache (after permission changes)."""
|
||||
async def invalidate_rp_cache(self):
|
||||
"""Invalidate role-permission cache (after permission changes).
|
||||
Also broadcasts invalidation to all other processes via Redis Pub/Sub.
|
||||
"""
|
||||
self.rp_caches = None
|
||||
self.rp_cache_loaded_at = 0
|
||||
# Broadcast to other processes
|
||||
cache_sync = get_cache_sync()
|
||||
if cache_sync.is_running:
|
||||
await cache_sync.invalidate('rbac:rp')
|
||||
|
||||
async def load_roleperms(self, sor):
|
||||
"""Load all role-permission mappings into cache.
|
||||
@ -243,28 +226,22 @@ class UserPermissions:
|
||||
now = time.time()
|
||||
|
||||
# Fast path: cache valid, no lock needed
|
||||
if _cache_enabled('rbac') and self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl:
|
||||
if self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl:
|
||||
return
|
||||
|
||||
# Slow path: acquire lock and double-check
|
||||
async with self._get_rp_lock():
|
||||
# Double-check after lock acquisition
|
||||
if _cache_enabled('rbac') and self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl:
|
||||
if self.rp_caches is not None and (now - self.rp_cache_loaded_at) < self.rp_cache_ttl:
|
||||
return
|
||||
|
||||
# Build in local dict first, assign atomically when complete.
|
||||
# Otherwise other coroutines see {} during the await and get 403.
|
||||
new_caches = {}
|
||||
self.rp_caches = {}
|
||||
sql_all = """select c.id, c.orgtypeid, c.name, b.path
|
||||
from rolepermission a, permission b, role c
|
||||
where a.permid = b.id
|
||||
and c.id = a.roleid
|
||||
order by c.orgtypeid, c.name"""
|
||||
recs = await sor.sqlExe(sql_all, {})
|
||||
if len(recs) == 0 and self.rp_caches:
|
||||
# DB returned empty — likely a bad connection. Keep previous valid cache.
|
||||
debug(f'load_roleperms: got 0 records, keeping previous cache ({sum(len(v) for v in self.rp_caches.values())} paths)')
|
||||
return
|
||||
for r in recs:
|
||||
if r.id == 'anonymous':
|
||||
k = 'anonymous'
|
||||
@ -274,22 +251,13 @@ order by c.orgtypeid, c.name"""
|
||||
k = 'logined'
|
||||
else:
|
||||
k = f'{r.orgtypeid}.{r.name}'
|
||||
arr = new_caches.get(k, [])
|
||||
arr = self.rp_caches.get(k, [])
|
||||
arr.append(r.path)
|
||||
new_caches[k] = arr
|
||||
# Atomic swap: other coroutines see old cache or fully-loaded new cache, never {}
|
||||
self.rp_caches = {k: set(v) for k, v in new_caches.items()}
|
||||
self.rp_caches[k] = arr
|
||||
self.rp_cache_loaded_at = now
|
||||
# Share to Redis for cross-worker reuse
|
||||
try:
|
||||
await cache_set("rbac", "role_perms", new_caches, ttl=self.rp_cache_ttl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def get_userroles(self, sor, userid):
|
||||
"""Load user roles from database and cache them.
|
||||
Returns the roles list directly (needed when cache is disabled).
|
||||
"""
|
||||
"""Load user roles from database and cache them."""
|
||||
recs = await sor.sqlExe('''select b.id, b.orgtypeid, b.name
|
||||
from users a, role b, userrole c
|
||||
where a.id = c.userid
|
||||
@ -300,22 +268,14 @@ where a.id = c.userid
|
||||
roles.append(f'{r.orgtypeid}.{r.name}')
|
||||
roles.append(f'{r.orgtypeid}.*')
|
||||
roles.append(f'*.{r.name}')
|
||||
roles = sorted(list(set(roles)))
|
||||
self.ur_caches.set(userid, roles)
|
||||
# Share to Redis for cross-worker reuse
|
||||
try:
|
||||
await cache_set("rbac", f"user_roles:{userid}", roles, ttl=self.cache_ttl)
|
||||
except Exception:
|
||||
pass
|
||||
return roles
|
||||
self.ur_caches.set(userid, sorted(list(set(roles))))
|
||||
|
||||
def check_roles_path(self, roles, path):
|
||||
"""Check if any of the roles has access to the given path.
|
||||
|
||||
Supports:
|
||||
- Exact match: '/customer_management/index.ui' or '/main/login.ui'
|
||||
- Wildcard prefix match: '/customer_management/**' or '/customer_management/%'
|
||||
matches any path starting with '/customer_management/'
|
||||
- Wildcard prefix match: '/customer_management/**' matches any path starting with '/customer_management/'
|
||||
- Path normalization: tries both the raw path and path with /main stripped
|
||||
"""
|
||||
for role in roles:
|
||||
@ -332,22 +292,16 @@ where a.id = c.userid
|
||||
return True
|
||||
# Also try wildcard match with normalized path
|
||||
for perm_path in paths:
|
||||
prefix = None
|
||||
if perm_path.endswith('**'):
|
||||
prefix = perm_path[:-2]
|
||||
elif perm_path.endswith('%'):
|
||||
prefix = perm_path[:-1]
|
||||
if prefix and (normalized.startswith(prefix) or path.startswith(prefix)):
|
||||
return True
|
||||
if normalized.startswith(prefix) or path.startswith(prefix):
|
||||
return True
|
||||
# Wildcard prefix match with raw path
|
||||
for perm_path in paths:
|
||||
prefix = None
|
||||
if perm_path.endswith('**'):
|
||||
prefix = perm_path[:-2]
|
||||
elif perm_path.endswith('%'):
|
||||
prefix = perm_path[:-1]
|
||||
if prefix and path.startswith(prefix):
|
||||
return True
|
||||
if path.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def is_user_has_path_perm(self, userid, path):
|
||||
@ -362,32 +316,13 @@ where a.id = c.userid
|
||||
if userid is None:
|
||||
roles = ['any', 'anonymous']
|
||||
|
||||
# L2: Try shared Redis cache before opening DB connection
|
||||
if _cache_enabled('rbac'):
|
||||
if self.rp_caches is None:
|
||||
cached_rp = await cache_get('rbac', 'role_perms', ttl=self.rp_cache_ttl)
|
||||
if cached_rp is not None:
|
||||
self.rp_caches = {k: set(v) for k, v in cached_rp.items()}
|
||||
self.rp_cache_loaded_at = __import__('time').time()
|
||||
if not roles and userid is not None:
|
||||
cached_roles = await cache_get('rbac', f'user_roles:{userid}', ttl=self.cache_ttl)
|
||||
if cached_roles is not None:
|
||||
roles = cached_roles
|
||||
self.ur_caches.set(userid, roles)
|
||||
|
||||
if not _cache_enabled('rbac') or self.rp_caches is None or not roles:
|
||||
if self.rp_caches is None or not roles:
|
||||
env = ServerEnv()
|
||||
async with get_sor_context(env, 'rbac') as sor:
|
||||
if not _cache_enabled('rbac') or self.rp_caches is None:
|
||||
if self.rp_caches is None:
|
||||
await self.load_roleperms(sor)
|
||||
if not roles:
|
||||
roles = await self.get_userroles(sor, userid)
|
||||
# When cache is enabled, fall back to cache read
|
||||
if roles is None:
|
||||
roles = self.ur_caches.get(userid)
|
||||
|
||||
# Safety fallback: if roles is still None (shouldn't happen), deny access
|
||||
if not roles:
|
||||
return False
|
||||
await self.get_userroles(sor, userid)
|
||||
roles = self.ur_caches.get(userid)
|
||||
|
||||
return self.check_roles_path(roles, path)
|
||||
|
||||
@ -70,7 +70,8 @@ type_roles = {
|
||||
}
|
||||
|
||||
rbac_tables = [
|
||||
"organization", "orgtypes", "role", "users", "userrole", "rolepermission", "permission" ]
|
||||
"organization", "orgtypes", "role", "users", "userrole", "rolepermission", "permission",
|
||||
"userapp", "userdepartment" ]
|
||||
|
||||
role_perms = {
|
||||
'superuser': [
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""rbac 模块 RBAC 权限注册。"""
|
||||
|
||||
import subprocess
|
||||
|
||||
MOD = "rbac"
|
||||
|
||||
# any — 无需登录(登录页、注册、验证码、公共资源等)
|
||||
PATHS_ANY = [
|
||||
f"/{MOD}/admin_menu.ui",
|
||||
f"/{MOD}/gen_sms_code.dspy",
|
||||
f"/{MOD}/login.css",
|
||||
f"/{MOD}/phone_login.dspy",
|
||||
f"/{MOD}/qr_scan.ui",
|
||||
f"/{MOD}/user/code_login.dspy",
|
||||
f"/{MOD}/user/login.ui",
|
||||
f"/{MOD}/user/logout.dspy",
|
||||
f"/{MOD}/user/register.dspy",
|
||||
f"/{MOD}/user/register.ui",
|
||||
f"/{MOD}/user/sms_register.dspy",
|
||||
f"/{MOD}/user/reset_password/index.ui",
|
||||
f"/{MOD}/user/reset_password/reset_password.dspy",
|
||||
f"/{MOD}/user/up_login.dspy",
|
||||
f"/{MOD}/usermenu.ui",
|
||||
f"/{MOD}/userpassword_login.dspy",
|
||||
f"/{MOD}/userpassword_login.ui",
|
||||
# 公共资源
|
||||
"/favicon.ico",
|
||||
"/i18n_getmsgs",
|
||||
]
|
||||
|
||||
# logined — 需要认证的页面和 API
|
||||
PATHS_LOGINED = [
|
||||
f"/{MOD}",
|
||||
f"/{MOD}/add_adminuser.dspy",
|
||||
f"/{MOD}/add_adminuser.ui",
|
||||
f"/{MOD}/add_provider.dspy",
|
||||
f"/{MOD}/add_provider.ui",
|
||||
f"/{MOD}/add_reseller.dspy",
|
||||
f"/{MOD}/find_unauth_files.dspy",
|
||||
f"/{MOD}/get_all_roles.dspy",
|
||||
f"/{MOD}/get_normal_roles.dspy",
|
||||
f"/{MOD}/get_provider.dspy",
|
||||
f"/{MOD}/get_reseller.dspy",
|
||||
f"/{MOD}/index.ui",
|
||||
f"/{MOD}/list_path_roles.dspy",
|
||||
f"/{MOD}/list_path_roles.ui",
|
||||
f"/{MOD}/organization",
|
||||
f"/{MOD}/orgtypes",
|
||||
f"/{MOD}/permission",
|
||||
f"/{MOD}/provider",
|
||||
f"/{MOD}/refresh_userperm.dspy",
|
||||
f"/{MOD}/reseller",
|
||||
f"/{MOD}/role",
|
||||
f"/{MOD}/rolepermission",
|
||||
f"/{MOD}/stat_active_users.ui",
|
||||
f"/{MOD}/stat_total_orgs.ui",
|
||||
f"/{MOD}/stat_total_users.ui",
|
||||
f"/{MOD}/user",
|
||||
f"/{MOD}/user/myrole.ui",
|
||||
f"/{MOD}/user/user.ui",
|
||||
f"/{MOD}/user/user_panel.ui",
|
||||
f"/{MOD}/user/userapikey",
|
||||
f"/{MOD}/user/userapikey/add_userapikey.dspy",
|
||||
f"/{MOD}/user/userapikey/delete_userapikey.dspy",
|
||||
f"/{MOD}/user/userapikey/get_userapikey.dspy",
|
||||
f"/{MOD}/user/userapikey/index.ui",
|
||||
f"/{MOD}/user/userapikey/update_userapikey.dspy",
|
||||
f"/{MOD}/user/userinfo.ui",
|
||||
f"/{MOD}/user/edit_profile.dspy",
|
||||
f"/{MOD}/user/save_profile.dspy",
|
||||
f"/{MOD}/user/wechat_login.ui",
|
||||
f"/{MOD}/userrole",
|
||||
f"/{MOD}/users",
|
||||
f"/{MOD}/usersync",
|
||||
f"/{MOD}/usersync/index.dspy",
|
||||
f"/{MOD}/api/add_user.dspy",
|
||||
f"/{MOD}/api/update_user.dspy",
|
||||
f"/{MOD}/api/get_search_roleid.dspy",
|
||||
]
|
||||
|
||||
# admin — 仅全局管理员(role: orgtypeid='*' name='admin')。
|
||||
# 管理员对他人的敏感操作端点,绝不进 PATHS_LOGINED(否则任何登录用户可重置他人密码)。
|
||||
# 与存量 /rbac/users/*.dspy(enable/disable/update)的 *.admin 授权口径一致。
|
||||
PATHS_ADMIN = [
|
||||
f"/{MOD}/admin_reset_password.ui",
|
||||
f"/{MOD}/api/admin_reset_password.dspy",
|
||||
f"/{MOD}/api/clear_login_fail.dspy",
|
||||
]
|
||||
|
||||
|
||||
def register_paths():
|
||||
for path in PATHS_ANY:
|
||||
subprocess.run(["py3/bin/python", "set_role_perm.py", "any", path])
|
||||
print(f" any: {path}")
|
||||
|
||||
for path in PATHS_LOGINED:
|
||||
subprocess.run(["py3/bin/python", "set_role_perm.py", "logined", path])
|
||||
print(f" logined: {path}")
|
||||
|
||||
for path in PATHS_ADMIN:
|
||||
subprocess.run(["py3/bin/python", "set_role_perm.py", "admin", path])
|
||||
print(f" admin: {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"=== {MOD} RBAC registration ===")
|
||||
register_paths()
|
||||
print("Done.")
|
||||
19
wwwroot/add_superuser.dspy
Normal file
19
wwwroot/add_superuser.dspy
Normal file
@ -0,0 +1,19 @@
|
||||
debug('/rbac/add_superuser.dspy')
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
ns = DictObject(**{
|
||||
"id": uuid(),
|
||||
"username":"superuser",
|
||||
"password":password_encode('111111'),
|
||||
"orgid":"0"
|
||||
})
|
||||
roles = [
|
||||
{
|
||||
"orgtypeid":"owner",
|
||||
"roles":["superuser", "admin"]
|
||||
}
|
||||
]
|
||||
await create_user(sor, ns, roles)
|
||||
return "OK"
|
||||
return "Error"
|
||||
@ -1,43 +0,0 @@
|
||||
{
|
||||
"widgettype": "Form",
|
||||
"options": {
|
||||
"title": "重置密码",
|
||||
"description": "为选中用户设置新密码(至少8位)",
|
||||
"fields": [
|
||||
{
|
||||
"name": "userid",
|
||||
"uitype": "hide",
|
||||
"value": "{{params_kw.get('id','')}}"
|
||||
},
|
||||
{
|
||||
"name": "new_password",
|
||||
"type": "str",
|
||||
"length": 255,
|
||||
"uitype": "password",
|
||||
"datatype": "str",
|
||||
"required": true,
|
||||
"label": "新密码"
|
||||
},
|
||||
{
|
||||
"name": "cfm_password",
|
||||
"type": "str",
|
||||
"length": 255,
|
||||
"uitype": "password",
|
||||
"datatype": "str",
|
||||
"required": true,
|
||||
"label": "确认密码"
|
||||
}
|
||||
]
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "submit",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "self",
|
||||
"options": {
|
||||
"url": "{{entire_url('api/admin_reset_password.dspy')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
|
||||
ns = params_kw.copy()
|
||||
for k,v in ns.items():
|
||||
if v == 'NaN' or v == 'null':
|
||||
ns[k] = None
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k, None)
|
||||
id = params_kw.id
|
||||
if not id or len(id) > 32:
|
||||
id = uuid()
|
||||
ns['id'] = id
|
||||
|
||||
if params_kw.get('password'):
|
||||
ns['password'] = password_encode(params_kw.get('password'))
|
||||
|
||||
ns['created_at'] = curDateString()
|
||||
|
||||
userorgid = await get_userorgid()
|
||||
if not userorgid:
|
||||
return {"widgettype":"Error","options":{"title":"Authorization Error","timeout":3,"cwidth":16,"cheight":9,"message":"Please login"}}
|
||||
ns['orgid'] = userorgid
|
||||
|
||||
try:
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
r = await sor.C('users', ns.copy())
|
||||
return {"widgettype":"Message","options":{"cwidth":16,"cheight":9,"title":"Add Success","timeout":3,"message":"ok"}}
|
||||
except Exception as e:
|
||||
debug(f'add_user error: {format_exc()}')
|
||||
return {"widgettype":"Error","options":{"title":"Add Error","cwidth":16,"cheight":9,"timeout":5,"message":str(e)}}
|
||||
@ -1,77 +0,0 @@
|
||||
ns = params_kw.copy()
|
||||
|
||||
userorgid = await get_userorgid()
|
||||
if not userorgid:
|
||||
return {
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"title":"Authorization Error",
|
||||
"timeout":3,
|
||||
"cwidth":16,
|
||||
"cheight":9,
|
||||
"message":"Please login"
|
||||
}
|
||||
}
|
||||
|
||||
target_id = ns.get('userid')
|
||||
new_password = ns.get('new_password')
|
||||
cfm_password = ns.get('cfm_password')
|
||||
|
||||
# 守卫:管理员不得重置自己的密码(自己改密走 /rbac/user/reset_password 自助入口)
|
||||
op_id = await get_user()
|
||||
if op_id and str(op_id) == str(target_id):
|
||||
return {"widgettype":"Error","options":{"title":"Reset Password Error","cwidth":16,"cheight":9,"timeout":3,"message":"cannot reset your own password, use self-service reset"}}
|
||||
|
||||
if not target_id:
|
||||
return {"widgettype":"Error","options":{"title":"Parameter Error","cwidth":16,"cheight":9,"timeout":3,"message":"missing userid"}}
|
||||
if not new_password or len(new_password) < 8:
|
||||
return {"widgettype":"Error","options":{"title":"Parameter Error","cwidth":16,"cheight":9,"timeout":3,"message":"new password must be at least 8 characters"}}
|
||||
if new_password != cfm_password:
|
||||
return {"widgettype":"Error","options":{"title":"Parameter Error","cwidth":16,"cheight":9,"timeout":3,"message":"two passwords do not match"}}
|
||||
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
try:
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, username, orgid FROM users WHERE id=${id}$",
|
||||
{"id": target_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not recs:
|
||||
return {"widgettype":"Error","options":{"title":"Reset Password Error","cwidth":16,"cheight":9,"timeout":3,"message":"user not exist"}}
|
||||
rec = recs[0]
|
||||
if rec.orgid and rec.orgid != userorgid:
|
||||
return {"widgettype":"Error","options":{"title":"Reset Password Error","cwidth":16,"cheight":9,"timeout":3,"message":"Record no exist or with wrong ownership"}}
|
||||
|
||||
encoded = password_encode(new_password)
|
||||
await sor.sqlExe(
|
||||
"UPDATE users SET password=${pwd}$, login_fail_count = 0, last_login_fail = NULL WHERE id=${id}$",
|
||||
{"pwd": encoded, "id": target_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
|
||||
# 审计:操作留痕(失败不阻断主流程)
|
||||
try:
|
||||
from app_audit import audit_log
|
||||
op_id = await get_user()
|
||||
await audit_log(sor, str(op_id or ''), '', 'password_reset',
|
||||
target=f'users:{rec.username}',
|
||||
detail='admin reset password (new password not logged)',
|
||||
client_ip=request.get('client_ip', '') if hasattr(request, 'get') else '')
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
except Exception as ae:
|
||||
debug(f'admin_reset_password audit failed: {ae}')
|
||||
|
||||
debug(f'admin_reset_password ok: target={rec.username} operator_orgid={userorgid}')
|
||||
return {
|
||||
"widgettype":"Message",
|
||||
"options":{
|
||||
"title":"Reset Password Success",
|
||||
"cwidth":16,
|
||||
"cheight":9,
|
||||
"timeout":3,
|
||||
"message":f"user {rec.username} password has been reset"
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
debug(f'admin_reset_password error: {format_exc()}')
|
||||
return {"widgettype":"Error","options":{"title":"Reset Password Error","cwidth":16,"cheight":9,"timeout":5,"message":str(e)}}
|
||||
@ -1,64 +0,0 @@
|
||||
ns = params_kw.copy()
|
||||
|
||||
userorgid = await get_userorgid()
|
||||
if not userorgid:
|
||||
return {
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"title":"Authorization Error",
|
||||
"timeout":3,
|
||||
"cwidth":16,
|
||||
"cheight":9,
|
||||
"message":"Please login"
|
||||
}
|
||||
}
|
||||
|
||||
target_id = ns.get('id')
|
||||
if not target_id:
|
||||
return {"widgettype":"Error","options":{"title":"Parameter Error","cwidth":16,"cheight":9,"timeout":3,"message":"missing id"}}
|
||||
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
try:
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, username, orgid FROM users WHERE id=${id}$",
|
||||
{"id": target_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
if not recs:
|
||||
return {"widgettype":"Error","options":{"title":"Clear Login Fail Error","cwidth":16,"cheight":9,"timeout":3,"message":"user not exist"}}
|
||||
rec = recs[0]
|
||||
if rec.orgid and rec.orgid != userorgid:
|
||||
return {"widgettype":"Error","options":{"title":"Clear Login Fail Error","cwidth":16,"cheight":9,"timeout":3,"message":"Record no exist or with wrong ownership"}}
|
||||
|
||||
await sor.sqlExe(
|
||||
"UPDATE users SET login_fail_count = 0, last_login_fail = NULL WHERE id=${id}$",
|
||||
{"id": target_id})
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
|
||||
# 审计:操作留痕(失败不阻断主流程)
|
||||
try:
|
||||
from app_audit import audit_log
|
||||
op_id = await get_user()
|
||||
await audit_log(sor, str(op_id or ''), '', 'login_fail_clear',
|
||||
target=f'users:{rec.username}',
|
||||
detail='admin clear login_fail_count',
|
||||
client_ip=request.get('client_ip', '') if hasattr(request, 'get') else '')
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
except Exception as ae:
|
||||
debug(f'clear_login_fail audit failed: {ae}')
|
||||
|
||||
debug(f'clear_login_fail ok: target={rec.username} operator_orgid={userorgid}')
|
||||
return {
|
||||
"widgettype":"Message",
|
||||
"options":{
|
||||
"title":"Clear Login Fail Success",
|
||||
"cwidth":16,
|
||||
"cheight":9,
|
||||
"timeout":3,
|
||||
"message":f"user {rec.username} login fail count cleared"
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
debug(f'clear_login_fail error: {format_exc()}')
|
||||
return {"widgettype":"Error","options":{"title":"Clear Login Fail Error","cwidth":16,"cheight":9,"timeout":5,"message":str(e)}}
|
||||
@ -1,14 +0,0 @@
|
||||
"""Return all role options for dropdown and code display.
|
||||
Used by userrole CRUD form and list view."""
|
||||
async with get_sor_context(request._run_ns, 'rbac') as sor:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, CONCAT(orgtypeid, '.', name) as name FROM role "
|
||||
"WHERE id NOT IN ('anonymous', 'any', 'logined') "
|
||||
"AND orgtypeid != '*' AND name != '*' "
|
||||
"AND orgtypeid IS NOT NULL AND name IS NOT NULL "
|
||||
"ORDER BY orgtypeid, name", {})
|
||||
|
||||
result = [{'roleid': '', 'roleid_text': '全部'}]
|
||||
for r in recs:
|
||||
result.append({'roleid': r.id, 'roleid_text': r.name})
|
||||
return result
|
||||
@ -1,33 +0,0 @@
|
||||
|
||||
ns = params_kw.copy()
|
||||
for k,v in ns.items():
|
||||
if v == 'NaN' or v == 'null':
|
||||
ns[k] = None
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k, None)
|
||||
|
||||
userorgid = await get_userorgid()
|
||||
if not userorgid:
|
||||
return {"widgettype":"Error","options":{"title":"Authorization Error","timeout":3,"cwidth":16,"cheight":9,"message":"Please login"}}
|
||||
ns['orgid'] = userorgid
|
||||
|
||||
if params_kw.get('password'):
|
||||
ns['password'] = password_encode(params_kw.get('password'))
|
||||
else:
|
||||
ns.pop('password', None)
|
||||
|
||||
try:
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
ns1 = {"orgid": userorgid, "id": params_kw.id}
|
||||
recs = await sor.R('users', ns1)
|
||||
if len(recs) < 1:
|
||||
return {"widgettype":"Error","options":{"title":"Update Error","cwidth":16,"cheight":9,"timeout":3,"message":"Record no exist or with wrong ownership"}}
|
||||
r = await sor.U('users', ns)
|
||||
debug('update success')
|
||||
return {"widgettype":"Message","options":{"title":"Update Success","cwidth":16,"cheight":9,"timeout":3,"message":"ok"}}
|
||||
except Exception as e:
|
||||
debug(f'update_user error: {format_exc()}')
|
||||
return {"widgettype":"Error","options":{"title":"Update Error","cwidth":16,"cheight":9,"timeout":5,"message":str(e)}}
|
||||
@ -1,8 +1,7 @@
|
||||
wwwroot = params_kw.get('wwwroot', '').strip()
|
||||
|
||||
async with get_sor_context(request._run_ns, 'rbac') as sor:
|
||||
# dspy 运行时无 __file__,用 ahserver 注入的 ref_real_path(本 dspy 文件真实绝对路径)
|
||||
title, message, is_error = await request._run_ns.scan_unauth_files(sor, wwwroot, request._run_ns.ref_real_path)
|
||||
title, message, is_error = await request._run_ns.scan_unauth_files(sor, wwwroot, __file__)
|
||||
|
||||
if is_error:
|
||||
return UiError(title=title, message=message)
|
||||
|
||||
@ -1,35 +1,26 @@
|
||||
phone = params_kw.cellphone
|
||||
if phone is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": "没有收到手机号"
|
||||
}
|
||||
}
|
||||
try:
|
||||
result = await sms_engine.generate_sms_code(phone)
|
||||
if result is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": "短信发送失败,请稍后重试"
|
||||
}
|
||||
}
|
||||
code_id, code = result
|
||||
debug(f'sms sent: code_id={code_id}, phone={phone}')
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"message": "验证码已发送",
|
||||
"key": code_id
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
debug(f'gen_sms_code error: {e}')
|
||||
exception(f'gen_sms_code error for {phone}: {e}')
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": f"发送验证码出错: {e}"
|
||||
}
|
||||
}
|
||||
return {
|
||||
"status": "error",
|
||||
"data":{
|
||||
"message": "没有收到手机号"
|
||||
}
|
||||
}
|
||||
# 使用短信模块发布的sms_engine实例生成验证码,参数手机号
|
||||
xx = await sms_engine.generate_sms_code(phone)
|
||||
if xx is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": "发送验证码出错"
|
||||
}
|
||||
}
|
||||
id, code = xx
|
||||
debug(f'{id=},{code=}')
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"message": "短信码已生成",
|
||||
"key": id
|
||||
}
|
||||
}
|
||||
|
||||
212
wwwroot/index.ui
Normal file
212
wwwroot/index.ui
Normal file
@ -0,0 +1,212 @@
|
||||
{% set roles = get_user_roles(get_user()) %}
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "100%",
|
||||
"padding": "0",
|
||||
"bgcolor": "#0B1120"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"alignItems": "center",
|
||||
"marginBottom": "24px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Title2",
|
||||
"options": {
|
||||
"text": "用户与权限",
|
||||
"color": "#F1F5F9",
|
||||
"fontWeight": "700"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Filler"
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"text": "用户管理、角色权限与安全审计",
|
||||
"fontSize": "14px",
|
||||
"color": "#64748B"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{% if 'reseller.admin' in roles or 'owner.superuser' in roles %}
|
||||
{
|
||||
"widgettype": "ResponsableBox",
|
||||
"options": {
|
||||
"gap": "16px",
|
||||
"minWidth": "250px",
|
||||
"marginBottom": "24px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"bgcolor": "#1E293B",
|
||||
"padding": "24px",
|
||||
"borderRadius": "12px",
|
||||
"border": "1px solid #334155",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.rbac_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/rbac/users')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Svg",
|
||||
"options": {
|
||||
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#60A5FA\" stroke-width=\"1.5\"><path d=\"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.953 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z\"/></svg>",
|
||||
"width": "36px",
|
||||
"height": "36px",
|
||||
"marginBottom": "16px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Title4",
|
||||
"options": {
|
||||
"text": "用户管理",
|
||||
"color": "#F1F5F9",
|
||||
"fontWeight": "600",
|
||||
"marginBottom": "8px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"text": "管理系统用户、角色分配与账户信息",
|
||||
"fontSize": "14px",
|
||||
"color": "#94A3B8"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"bgcolor": "#1E293B",
|
||||
"padding": "24px",
|
||||
"borderRadius": "12px",
|
||||
"border": "1px solid #334155",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.rbac_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/rbac/list_path_roles.ui')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Svg",
|
||||
"options": {
|
||||
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#A78BFA\" stroke-width=\"1.5\"><path d=\"M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z\"/></svg>",
|
||||
"width": "36px",
|
||||
"height": "36px",
|
||||
"marginBottom": "16px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Title4",
|
||||
"options": {
|
||||
"text": "路径权限角色",
|
||||
"color": "#F1F5F9",
|
||||
"fontWeight": "600",
|
||||
"marginBottom": "8px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"text": "查询各路径绑定的角色与权限配置",
|
||||
"fontSize": "14px",
|
||||
"color": "#94A3B8"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"bgcolor": "#1E293B",
|
||||
"padding": "24px",
|
||||
"borderRadius": "12px",
|
||||
"border": "1px solid #334155",
|
||||
"cursor": "pointer"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "app.rbac_content",
|
||||
"options": {
|
||||
"url": "{{entire_url('/rbac/find_unauth_files.dspy')}}"
|
||||
},
|
||||
"mode": "replace"
|
||||
}
|
||||
],
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Svg",
|
||||
"options": {
|
||||
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#EF4444\" stroke-width=\"1.5\"><path d=\"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z\"/></svg>",
|
||||
"width": "36px",
|
||||
"height": "36px",
|
||||
"marginBottom": "16px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Title4",
|
||||
"options": {
|
||||
"text": "扫描未授权文件",
|
||||
"color": "#F1F5F9",
|
||||
"fontWeight": "600",
|
||||
"marginBottom": "8px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"text": "检测未配置RBAC权限的页面文件",
|
||||
"fontSize": "14px",
|
||||
"color": "#94A3B8"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{% endif %}
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "rbac_content",
|
||||
"css": "filler",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"overflowY": "auto"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,242 +0,0 @@
|
||||
/* ===== Modern Login Popup Styling ===== */
|
||||
|
||||
/* Popup window card */
|
||||
.login-window {
|
||||
border-radius: 16px !important;
|
||||
box-shadow: 0 25px 60px -12px rgba(0, 0, 0, 0.35) !important;
|
||||
overflow: hidden !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Title bar - gradient brand header */
|
||||
.login-window .titlebar {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%) !important;
|
||||
padding: 8px 16px !important;
|
||||
border: none !important;
|
||||
min-height: 48px !important;
|
||||
}
|
||||
.login-window .titlebar .text-w,
|
||||
.login-window .titlebar .bricks-text {
|
||||
color: #ffffff !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Content area padding */
|
||||
.login-window .flexbox {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Tab panel modern styling */
|
||||
.login-window .tabpanel {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.login-window .tabpanel-tabs {
|
||||
display: flex !important;
|
||||
border-bottom: 2px solid #e2e8f0 !important;
|
||||
background: #f8fafc !important;
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
.login-window .tabpanel-tab {
|
||||
padding: 14px 24px !important;
|
||||
cursor: pointer !important;
|
||||
border-bottom: 3px solid transparent !important;
|
||||
margin-bottom: -2px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
color: #64748b !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 14px !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.login-window .tabpanel-tab:hover {
|
||||
color: #6366f1 !important;
|
||||
background: rgba(99, 102, 241, 0.04) !important;
|
||||
}
|
||||
.login-window .tabpanel-tab-active,
|
||||
.login-window .tabpanel-tab-selected {
|
||||
color: #6366f1 !important;
|
||||
border-bottom-color: #6366f1 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.login-window .tabpanel-content {
|
||||
padding: 0 !important;
|
||||
background: #ffffff !important;
|
||||
overflow: hidden !important;
|
||||
flex: 1 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.login-window .tabpanel-content .scrollpanel {
|
||||
height: 100% !important;
|
||||
}
|
||||
.login-window .tabpanel-content .scrollpanel .vcontainer {
|
||||
padding: 20px 24px !important;
|
||||
}
|
||||
|
||||
/* Form styling */
|
||||
.login-window .vcontainer {
|
||||
gap: 4px !important;
|
||||
}
|
||||
.login-window .inputbox {
|
||||
border-radius: 10px !important;
|
||||
border: 1.5px solid #e2e8f0 !important;
|
||||
padding: 10px 14px !important;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
|
||||
background: #f8fafc !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.login-window .inputbox:focus,
|
||||
.login-window .inputbox:focus-within {
|
||||
border-color: #6366f1 !important;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12) !important;
|
||||
background: #ffffff !important;
|
||||
outline: none !important;
|
||||
}
|
||||
.login-window input.inputbox {
|
||||
height: 42px !important;
|
||||
}
|
||||
|
||||
/* Form labels */
|
||||
.login-window .field-label,
|
||||
.login-window .bricks-form label {
|
||||
font-weight: 500 !important;
|
||||
color: #374151 !important;
|
||||
font-size: 13px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
/* Toolbar buttons */
|
||||
.login-window .htoolbar {
|
||||
padding: 8px 0 !important;
|
||||
gap: 8px !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.login-window .submit_btn {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
border-radius: 10px !important;
|
||||
padding: 10px 32px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 14px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3) !important;
|
||||
}
|
||||
.login-window .submit_btn:hover {
|
||||
opacity: 0.92 !important;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
.login-window .reset_btn,
|
||||
.login-window .clear_btn {
|
||||
border-radius: 10px !important;
|
||||
border: 1.5px solid #e2e8f0 !important;
|
||||
background: #ffffff !important;
|
||||
color: #64748b !important;
|
||||
padding: 10px 20px !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
.login-window .reset_btn:hover,
|
||||
.login-window .clear_btn:hover {
|
||||
border-color: #cbd5e1 !important;
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
|
||||
/* SMS send button */
|
||||
.sms-send-btn {
|
||||
border-radius: 10px !important;
|
||||
border: 1.5px solid #6366f1 !important;
|
||||
background: rgba(99, 102, 241, 0.06) !important;
|
||||
color: #6366f1 !important;
|
||||
padding: 8px 20px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 13px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
.sms-send-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.12) !important;
|
||||
}
|
||||
.sms-send-btn:disabled {
|
||||
opacity: 0.5 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Description text */
|
||||
.login-window .bricks-text {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Form description */
|
||||
.login-desc {
|
||||
color: #94a3b8 !important;
|
||||
font-size: 12px !important;
|
||||
text-align: center !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
/* Section separator */
|
||||
.login-separator {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 12px !important;
|
||||
margin: 8px 0 !important;
|
||||
color: #cbd5e1 !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
.login-separator::before,
|
||||
.login-separator::after {
|
||||
content: '' !important;
|
||||
flex: 1 !important;
|
||||
height: 1px !important;
|
||||
background: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* Dark theme support */
|
||||
[data-theme="dark"] .login-window {
|
||||
background: #1e293b !important;
|
||||
}
|
||||
[data-theme="dark"] .login-window .tabpanel-content {
|
||||
background: #1e293b !important;
|
||||
}
|
||||
[data-theme="dark"] .login-window .tabpanel-tabs {
|
||||
background: #0f172a !important;
|
||||
border-bottom-color: #334155 !important;
|
||||
}
|
||||
[data-theme="dark"] .login-window .tabpanel-tab {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
[data-theme="dark"] .login-window .tabpanel-tab-active,
|
||||
[data-theme="dark"] .login-window .tabpanel-tab-selected {
|
||||
color: #818cf8 !important;
|
||||
border-bottom-color: #818cf8 !important;
|
||||
}
|
||||
[data-theme="dark"] .login-window .inputbox {
|
||||
background: #0F172A !important;
|
||||
border-color: #475569 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
[data-theme="dark"] .login-window .inputbox:focus,
|
||||
[data-theme="dark"] .login-window .inputbox:focus-within {
|
||||
border-color: #818cf8 !important;
|
||||
box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.15) !important;
|
||||
}
|
||||
[data-theme="dark"] .login-window .field-label {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
[data-theme="dark"] .login-window .reset_btn,
|
||||
[data-theme="dark"] .login-window .clear_btn {
|
||||
background: #334155 !important;
|
||||
border-color: #475569 !important;
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
[data-theme="dark"] .sms-send-btn {
|
||||
border-color: #818cf8 !important;
|
||||
color: #818cf8 !important;
|
||||
background: rgba(129, 140, 248, 0.1) !important;
|
||||
}
|
||||
@ -96,17 +96,7 @@ try:
|
||||
await openCustomerAccounts(sor, ownerid, orgid)
|
||||
except Exception as e:
|
||||
exception(f'{e}')
|
||||
|
||||
# 检查是否有促销码,如果有则绑定客户归属关系
|
||||
promo_code_id = params_kw.get('promo_code_id')
|
||||
if promo_code_id:
|
||||
try:
|
||||
_new_orgid = d['data']['user'].orgid
|
||||
await bind_customer(request, DictObject(promo_code_id=promo_code_id, customerid=_new_orgid))
|
||||
debug(f'phone_login: customer {_new_orgid} bound via promo_code {promo_code_id}')
|
||||
except Exception as e:
|
||||
exception(f'phone_login: bind_customer failed: {e}')
|
||||
|
||||
|
||||
r = d['data']['user']
|
||||
await remember_user(r.id, username=r.username, userorgid=r.orgid)
|
||||
debug(f'here')
|
||||
|
||||
@ -5,9 +5,11 @@
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "recognized",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "if(event && event.params && event.params.text) window.open(event.params.text, '_blank');"
|
||||
"actiontype": "urlwidget",
|
||||
"target": "root.page_center",
|
||||
"options": {
|
||||
"url": "${text}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
@ -17,4 +19,4 @@
|
||||
"script": "this.dismiss()"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,207 +0,0 @@
|
||||
# 手机验证码登录 - 接收前端表单参数(cell_no, codeid, check_code)
|
||||
# 调用sms_engine验证后完成登录或自动注册
|
||||
debug(f'code_login.dspy: {params_kw=}')
|
||||
|
||||
def _login_msg(username, welcome):
|
||||
# 登录成功 Message;非用户主动点击登录(login_source=auto,如401自动弹窗)时不刷新页面
|
||||
msg = {
|
||||
"widgettype": "Message",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"auto_open": True,
|
||||
"title": "登录成功",
|
||||
"message": welcome
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "window.user_container",
|
||||
"options": {"url": entire_url('/rbac/user/userinfo.ui')}
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "script",
|
||||
"target": "body.login_window",
|
||||
"script": "this.destroy()"
|
||||
}
|
||||
]
|
||||
}
|
||||
if params_kw.get('login_source', 'manual') == 'auto':
|
||||
msg["options"]["refresh"] = False
|
||||
else:
|
||||
msg["binds"].append({
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "if(bricks.app && bricks.app.dispatch) bricks.app.dispatch('user_logined')"
|
||||
})
|
||||
return msg
|
||||
|
||||
cellphone = params_kw.cell_no
|
||||
key = params_kw.codeid
|
||||
sms_code = params_kw.check_code
|
||||
|
||||
if not cellphone:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "错误",
|
||||
"message": "需输入手机号"
|
||||
}
|
||||
}
|
||||
if not sms_code:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "错误",
|
||||
"message": "需输入验证码"
|
||||
}
|
||||
}
|
||||
if not key:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "错误",
|
||||
"message": "需要短信验证key"
|
||||
}
|
||||
}
|
||||
|
||||
# 验证短信码
|
||||
ok = await sms_engine.check_sms_code(key, sms_code)
|
||||
if not ok:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "验证失败",
|
||||
"message": "手机短信验证码出错"
|
||||
}
|
||||
}
|
||||
|
||||
# 验证通过,查找或注册用户
|
||||
ns = {
|
||||
"username": cellphone,
|
||||
"password": "^&%UHI",
|
||||
"cfm_password": "^&%UHI",
|
||||
"mobile": cellphone,
|
||||
"user_status": "0"
|
||||
}
|
||||
udata = DictObject(**ns)
|
||||
|
||||
try:
|
||||
async with get_sor_context(request._run_ns, 'rbac') as sor:
|
||||
recs = await sor.R('users', {'mobile': cellphone})
|
||||
if recs:
|
||||
if len(recs) == 1:
|
||||
r = recs[0]
|
||||
now_str = timestampstr()
|
||||
await sor.sqlExe("""
|
||||
UPDATE users
|
||||
SET last_login = ${now}$, login_fail_count = 0,
|
||||
last_login_fail = NULL
|
||||
WHERE id = ${id}$
|
||||
""", {'id': r.id, 'now': now_str})
|
||||
await remember_user(r.id, username=r.username, userorgid=r.orgid)
|
||||
return _login_msg(r.username, f"{r.username} 欢迎回来")
|
||||
# 多个用户绑定同一手机号
|
||||
if params_kw.selected_id:
|
||||
for r in recs:
|
||||
if r.id == params_kw.selected_id:
|
||||
now_str = timestampstr()
|
||||
await sor.sqlExe("""
|
||||
UPDATE users
|
||||
SET last_login = ${now}$, login_fail_count = 0,
|
||||
last_login_fail = NULL
|
||||
WHERE id = ${id}$
|
||||
""", {'id': r.id, 'now': now_str})
|
||||
await remember_user(r.id, username=r.username, userorgid=r.orgid)
|
||||
return _login_msg(r.username, f"{r.username} 欢迎回来")
|
||||
else:
|
||||
# 返回用户选择列表
|
||||
buttons = []
|
||||
for r in recs:
|
||||
buttons.append({
|
||||
"widgettype": "Button",
|
||||
"options": {
|
||||
"label": f"{r.username} ({r.id})",
|
||||
"width": "100%",
|
||||
"margin": "4px 0"
|
||||
},
|
||||
"binds": [{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "self",
|
||||
"options": {
|
||||
"url": entire_url('/rbac/user/code_login.dspy'),
|
||||
"params": {
|
||||
"cell_no": cellphone,
|
||||
"codeid": key,
|
||||
"check_code": sms_code,
|
||||
"selected_id": r.id
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
return {
|
||||
"widgettype": "VBox",
|
||||
"options": {"padding": "12px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"text": "该手机号关联多个账号,请选择:",
|
||||
"fontSize": "14px",
|
||||
"margin": "0 0 8px 0"
|
||||
}
|
||||
}
|
||||
] + buttons
|
||||
}
|
||||
|
||||
# 新用户自动注册
|
||||
d = await register_user(sor, udata)
|
||||
if d['status'] == 'error':
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 5,
|
||||
"title": "注册失败",
|
||||
"message": d['data']['message']
|
||||
}
|
||||
}
|
||||
try:
|
||||
ownerid = await get_owner_orgid(sor, orgid)
|
||||
await openCustomerAccounts(sor, ownerid, orgid)
|
||||
except Exception as e:
|
||||
exception(f'{e}')
|
||||
|
||||
# 检查是否有促销码,如果有则绑定客户归属关系
|
||||
promo_code_id = params_kw.get('promo_code_id')
|
||||
if promo_code_id:
|
||||
try:
|
||||
_new_orgid = d['data']['user'].orgid
|
||||
await bind_customer(request, DictObject(promo_code_id=promo_code_id, customerid=_new_orgid))
|
||||
debug(f'code_login: customer {_new_orgid} bound via promo_code {promo_code_id}')
|
||||
except Exception as e:
|
||||
exception(f'code_login: bind_customer failed: {e}')
|
||||
|
||||
r = d['data']['user']
|
||||
await remember_user(r.id, username=r.username, userorgid=r.orgid)
|
||||
return _login_msg(r.username, f"{r.username} 欢迎")
|
||||
except Exception as e:
|
||||
exception(f'code_login error: {e}')
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 5,
|
||||
"title": "系统错误",
|
||||
"message": f"{e}"
|
||||
}
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
debug(f'edit_profile.dspy: {params_kw=}')
|
||||
|
||||
userid = await get_user()
|
||||
if userid is None:
|
||||
return UiError(title='Error', message='You need login first')
|
||||
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
recs = await sor.R('users', {'id': userid})
|
||||
if not recs:
|
||||
return UiError(title='Error', message='User not found')
|
||||
user = recs[0]
|
||||
nick_name = user.nick_name or ''
|
||||
email = user.email or ''
|
||||
mobile = user.mobile or ''
|
||||
address = user.address or ''
|
||||
|
||||
return {
|
||||
"widgettype": "Form",
|
||||
"options": {
|
||||
"title": "完善个人信息",
|
||||
"description": "请填写或更新您的个人信息",
|
||||
"fields": [
|
||||
{
|
||||
"name": "nick_name",
|
||||
"type": "str",
|
||||
"length": 255,
|
||||
"uitype": "str",
|
||||
"datatype": "str",
|
||||
"required": False,
|
||||
"label": "显示名",
|
||||
"value": nick_name
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"type": "str",
|
||||
"length": 255,
|
||||
"uitype": "email",
|
||||
"datatype": "str",
|
||||
"required": False,
|
||||
"label": "邮件地址",
|
||||
"value": email
|
||||
},
|
||||
{
|
||||
"name": "mobile",
|
||||
"type": "str",
|
||||
"length": 255,
|
||||
"uitype": "tel",
|
||||
"datatype": "str",
|
||||
"required": False,
|
||||
"label": "手机号",
|
||||
"value": mobile
|
||||
},
|
||||
{
|
||||
"name": "address",
|
||||
"type": "str",
|
||||
"length": 255,
|
||||
"uitype": "str",
|
||||
"datatype": "str",
|
||||
"required": False,
|
||||
"label": "地址",
|
||||
"value": address
|
||||
}
|
||||
]
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "submit",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "self",
|
||||
"options": {
|
||||
"method": "POST",
|
||||
"url": entire_url('save_profile.dspy')
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,22 +1,16 @@
|
||||
{% set sms_code_url = entire_url('/rbac/gen_sms_code.dspy') %}
|
||||
{% set code_login_url = entire_url('/rbac/user/code_login.dspy') %}
|
||||
{% set sms_register_url = entire_url('/rbac/user/sms_register.dspy') %}
|
||||
{
|
||||
"id": "login_window",
|
||||
"widgettype": "PopupWindow",
|
||||
"options": {
|
||||
"title": "欢迎登录",
|
||||
"i18n": true,
|
||||
"css": "login-window",
|
||||
"title": "登录/注册",
|
||||
"auto_open": true,
|
||||
"archor": "cc",
|
||||
"cwidth": 26,
|
||||
"cheight": 30
|
||||
"anthor": "cc",
|
||||
"cwidth": 22,
|
||||
"cheight": 19
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "TabPanel",
|
||||
"id": "login_tabs",
|
||||
"options": {
|
||||
"tab_wide": "auto",
|
||||
"height": "100%",
|
||||
@ -25,165 +19,90 @@
|
||||
"items": [
|
||||
{
|
||||
"name": "userpasswd",
|
||||
"label": "密码登录",
|
||||
"label": "用户密码",
|
||||
"content": {
|
||||
"widgettype": "VScrollPanel",
|
||||
"options": {"height": "100%"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Form",
|
||||
"options": {
|
||||
"cols": 1,
|
||||
"fields": [
|
||||
{"name": "username", "label": "用户名", "uitype": "str"},
|
||||
{"name": "password", "label": "密码", "uitype": "password"}
|
||||
]
|
||||
"widgettype": "Form",
|
||||
"options": {
|
||||
"cols": 1,
|
||||
"fields": [
|
||||
{
|
||||
"name": "username",
|
||||
"label": "用户名",
|
||||
"uitype": "str"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "submit",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "self",
|
||||
"options": {
|
||||
"method": "POST",
|
||||
"url": "{{entire_url('up_login.dspy')}}",
|
||||
"params": {
|
||||
"login_source": "{{params_kw.get('login_source', 'manual')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "phonecode",
|
||||
"label": "手机登录",
|
||||
"content": {
|
||||
"widgettype": "VScrollPanel",
|
||||
"options": {"height": "100%"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"name": "password",
|
||||
"label": "密码",
|
||||
"uitype": "password"
|
||||
}
|
||||
]
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"gap": "8px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"otext": "未注册的手机号将自动创建账号",
|
||||
"i18n": true,
|
||||
"css": "login-desc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Form",
|
||||
"id": "phone_form",
|
||||
"options": {
|
||||
"cols": 1,
|
||||
"fields": [
|
||||
{"name": "cell_no", "label": "手机号", "uitype": "str"},
|
||||
{"name": "codeid", "uitype": "hide", "value": ""},
|
||||
{"name": "check_code", "label": "验证码", "uitype": "str"}
|
||||
]
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "submit",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "self",
|
||||
"options": {
|
||||
"method": "POST",
|
||||
"url": "{{code_login_url}}",
|
||||
"params": {
|
||||
"login_source": "{{params_kw.get('login_source', 'manual')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "gen_code_btn",
|
||||
"options": {"label": "发送验证码", "i18n": true, "css": "sms-send-btn"},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "var form=bricks.getWidgetById('phone_form',bricks.app);if(!form)return;var cell=form._getValue().cell_no;if(!cell||cell.length<11){alert('请输入正确的手机号');return;}var btn=this;btn.disabled=true;btn.text_w&&btn.text_w.set_otext('发送中...');fetch('{{sms_code_url}}?_webbricks_=1&cellphone='+encodeURIComponent(cell)).then(function(r){return r.json()}).then(function(d){if(d.status==='ok'){var w=form.name_inputs['codeid'];if(w)w.setValue(d.data.key);btn.text_w&&btn.text_w.set_otext('已发送');var s=60;var t=setInterval(function(){s--;if(s<=0){clearInterval(t);btn.disabled=false;btn.text_w&&btn.text_w.set_otext('重新发送')}else btn.text_w&&btn.text_w.set_otext(s+'s')},1000)}else{alert(d.data.message||'发送验证码出错');btn.disabled=false;btn.text_w&&btn.text_w.set_otext('发送验证码')}}).catch(function(e){alert('网络错误: '+e);btn.disabled=false;btn.text_w&&btn.text_w.set_otext('发送验证码')})"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "register",
|
||||
"label": "注册账号",
|
||||
"content": {
|
||||
"widgettype": "VBox",
|
||||
"options": {"gap": "8px", "height": "100%"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"wid": "self",
|
||||
"event": "submit",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "self",
|
||||
"options": {
|
||||
"otext": "手机号需短信验证后方可注册",
|
||||
"i18n": true,
|
||||
"css": "login-desc"
|
||||
"method": "POST",
|
||||
"url": "{{entire_url('up_login.dspy')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "checkcode",
|
||||
"label": "手机验证码",
|
||||
"content": {
|
||||
"widgettype": "Form",
|
||||
"options": {
|
||||
"toolbar": {
|
||||
"tools": [
|
||||
{
|
||||
"name": "gen_code",
|
||||
"label": "发送验证码"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "限中国国内手机",
|
||||
"fields": [
|
||||
{
|
||||
"name": "cell_no",
|
||||
"label": "手机号",
|
||||
"uitype": "str"
|
||||
},
|
||||
{
|
||||
"name": "codeid",
|
||||
"uitype": "hide",
|
||||
"value": "{{uuid()}}"
|
||||
},
|
||||
{
|
||||
"name": "check_code",
|
||||
"uitype": "str"
|
||||
}
|
||||
]
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "gen_code",
|
||||
"actiontype": "urlwidget",
|
||||
"datawidget": "self",
|
||||
"datamethod": "getValue",
|
||||
"target": "self",
|
||||
"options": {
|
||||
"url": "{{entire_url('../gen_sms_code.dspy')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "VScrollPanel",
|
||||
"options": {"height": "100%", "flex": "1"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Form",
|
||||
"id": "register_form",
|
||||
"options": {
|
||||
"cols": 1,
|
||||
"fields": [
|
||||
{"name": "username", "label": "用户名", "uitype": "str"},
|
||||
{"name": "mobile", "label": "手机号", "uitype": "str"},
|
||||
{"name": "codeid", "uitype": "hide", "value": ""},
|
||||
{"name": "check_code", "label": "短信验证码", "uitype": "str"},
|
||||
{"name": "password", "label": "密码", "uitype": "password"},
|
||||
{"name": "cfm_password", "label": "确认密码", "uitype": "password"}
|
||||
]
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "submit",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "self",
|
||||
"options": {
|
||||
"method": "POST",
|
||||
"url": "{{sms_register_url}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "reg_sms_btn",
|
||||
"options": {"label": "发送验证码", "i18n": true, "css": "sms-send-btn"},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "var form=bricks.getWidgetById('register_form',bricks.app);if(!form)return;var cell=form._getValue().mobile;if(!cell||cell.length<11){alert('请输入正确的手机号');return;}var btn=this;btn.disabled=true;btn.text_w&&btn.text_w.set_otext('发送中...');fetch('{{sms_code_url}}?_webbricks_=1&cellphone='+encodeURIComponent(cell)).then(function(r){return r.json()}).then(function(d){if(d.status==='ok'){var w=form.name_inputs['codeid'];if(w)w.setValue(d.data.key);btn.text_w&&btn.text_w.set_otext('已发送');var s=60;var t=setInterval(function(){s--;if(s<=0){clearInterval(t);btn.disabled=false;btn.text_w&&btn.text_w.set_otext('重新发送')}else btn.text_w&&btn.text_w.set_otext(s+'s')},1000)}else{alert(d.data.message||'发送验证码出错');btn.disabled=false;btn.text_w&&btn.text_w.set_otext('发送验证码')}}).catch(function(e){alert('网络错误: '+e);btn.disabled=false;btn.text_w&&btn.text_w.set_otext('发送验证码')})"
|
||||
}
|
||||
]
|
||||
"wid": "self",
|
||||
"event": "submit",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "self",
|
||||
"options": {
|
||||
"url": "{{entire_url('code_login.dspy')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -192,4 +111,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,38 +1,9 @@
|
||||
await forget_user()
|
||||
return {
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"padding": "24px",
|
||||
"alignItems": "center"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {
|
||||
"otext": "logout success",
|
||||
"i18n": True,
|
||||
"fontSize": "16px",
|
||||
"marginBottom": "16px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"options": {
|
||||
"label": "刷新页面",
|
||||
"bgcolor": "#3B82F6",
|
||||
"color": "white",
|
||||
"padding": "8px 24px",
|
||||
"borderRadius": "6px"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "location.reload()"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"widgettype":"Text",
|
||||
"options":{
|
||||
"otext":"logout success",
|
||||
"i18n":True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,117 +1,18 @@
|
||||
debug(f'register.dspy: {params_kw=}')
|
||||
debug(f'{params_kw=}')
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
|
||||
# 根据注册域名查找所属分销商
|
||||
parentid = None
|
||||
try:
|
||||
host = request.host
|
||||
env = request._run_ns
|
||||
domain_info = await env.get_domain_by_host(request, host)
|
||||
if domain_info:
|
||||
parentid = domain_info.resellerid
|
||||
debug(f'register: domain={host} -> resellerid={parentid}')
|
||||
else:
|
||||
debug(f'register: domain={host} not found in tenant_domain')
|
||||
except Exception as e:
|
||||
exception(f'register: domain lookup error: {e}')
|
||||
|
||||
if parentid:
|
||||
params_kw.parentid = parentid
|
||||
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
data = await register_user(sor, params_kw)
|
||||
data = DictObject(**data)
|
||||
if data.status == 'error':
|
||||
debug(f"register error: {data.data.message}")
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 5,
|
||||
"title": "注册失败",
|
||||
"message": data.data.message
|
||||
}
|
||||
}
|
||||
user = data.data.user
|
||||
orgid = user.orgid
|
||||
try:
|
||||
await openCustomerAccounts(sor, '0', orgid)
|
||||
debug(f'{orgid} accounts opened')
|
||||
except Exception as e:
|
||||
exception(f'{e},{orgid=}')
|
||||
data = await register_user(sor, params_kw)
|
||||
data = DictObject(**data)
|
||||
try:
|
||||
if data['status'] == 'error':
|
||||
debug(f"{data.data.message}")
|
||||
return UiError(title='Error', message=data.data.message)
|
||||
orgid = data.data.user.orgid
|
||||
await openCustomerAccounts(sor, '0', orgid)
|
||||
debug(f'{orgid} accounts opened')
|
||||
except Exception as e:
|
||||
exception(f'{e},{orgid=}')
|
||||
|
||||
# 检查是否有促销码,如果有则绑定客户归属关系
|
||||
promo_code_id = params_kw.get('promo_code_id')
|
||||
if promo_code_id:
|
||||
try:
|
||||
await bind_customer(request, DictObject(promo_code_id=promo_code_id, customerid=orgid))
|
||||
debug(f'register: customer {orgid} bound via promo_code {promo_code_id}')
|
||||
except Exception as e:
|
||||
exception(f'register: bind_customer failed for {orgid} via {promo_code_id}: {e}')
|
||||
else:
|
||||
# 非促销码注册: 创建自由客户归属记录 (bind_type=3 域名注册)
|
||||
try:
|
||||
reseller_id = parentid or '0'
|
||||
async with DBPools().sqlorContext(get_module_dbname('discount')) as disc_sor:
|
||||
existing = await disc_sor.sqlExe(
|
||||
'SELECT id FROM discount_customer_bind WHERE customerid = ${cid}$',
|
||||
{'cid': orgid}
|
||||
)
|
||||
if not existing:
|
||||
await disc_sor.C('discount_customer_bind', {
|
||||
'id': getID(),
|
||||
'customerid': orgid,
|
||||
'resellerid': reseller_id,
|
||||
'sale_id': '',
|
||||
'promo_code_id': '',
|
||||
'bind_type': '3',
|
||||
})
|
||||
debug(f'register: free customer {orgid} bind created (reseller={reseller_id})')
|
||||
except Exception as e:
|
||||
exception(f'register: create free customer bind failed for {orgid}: {e}')
|
||||
|
||||
# 注册成功后自动登录
|
||||
await remember_user(user.id, username=user.username, userorgid=user.orgid)
|
||||
return {
|
||||
"widgettype": "Message",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"auto_open": True,
|
||||
"title": "注册成功",
|
||||
"message": f"{user.username} 注册成功,已自动登录"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "window.user_container",
|
||||
"options": {
|
||||
"url": entire_url('/rbac/user/userinfo.ui')
|
||||
}
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "script",
|
||||
"target": "body.login_window",
|
||||
"script": "if(this.destroy) this.destroy()"
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "if(bricks.app && bricks.app.dispatch) bricks.app.dispatch('user_logined')"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 5,
|
||||
"title": "注册失败",
|
||||
"message": "系统错误,请稍后重试"
|
||||
}
|
||||
}
|
||||
return UiMessage(title="Success", message=f"register success {orgid}")
|
||||
return UiError(title='Error', message="register failed")
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
debug(f'save_profile.dspy: {params_kw=}')
|
||||
|
||||
userid = await get_user()
|
||||
if userid is None:
|
||||
return UiError(title='Error', message='You need login first')
|
||||
|
||||
data = {
|
||||
'id': userid,
|
||||
'nick_name': params_kw.nick_name or '',
|
||||
'email': params_kw.email or '',
|
||||
'mobile': params_kw.mobile or '',
|
||||
'address': params_kw.address or ''
|
||||
}
|
||||
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
try:
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
await sor.U('users', data)
|
||||
return {
|
||||
"widgettype": "Message",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"auto_open": True,
|
||||
"title": "保存成功",
|
||||
"message": "个人信息已更新"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "if(bricks.app && bricks.app.dispatch) bricks.app.dispatch('profile_updated')"
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
exception(f'save_profile error: {e}')
|
||||
return UiError(title='Error', message=f'保存失败: {e}')
|
||||
@ -1,201 +0,0 @@
|
||||
# 短信验证注册 - 接收前端表单参数(username, mobile, codeid, check_code, password, cfm_password)
|
||||
# 先验证短信码,通过后注册并自动登录
|
||||
debug(f'sms_register.dspy: {params_kw=}')
|
||||
|
||||
username = params_kw.username
|
||||
mobile = params_kw.mobile
|
||||
key = params_kw.codeid
|
||||
sms_code = params_kw.check_code
|
||||
password = params_kw.password
|
||||
cfm_password = params_kw.cfm_password
|
||||
|
||||
# 基本参数校验
|
||||
if not username:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "注册失败",
|
||||
"message": "请输入用户名"
|
||||
}
|
||||
}
|
||||
if not mobile:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "注册失败",
|
||||
"message": "请输入手机号"
|
||||
}
|
||||
}
|
||||
if not password:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "注册失败",
|
||||
"message": "请输入密码"
|
||||
}
|
||||
}
|
||||
if password != cfm_password:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "注册失败",
|
||||
"message": "两次输入的密码不一致"
|
||||
}
|
||||
}
|
||||
if not key or not sms_code:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "注册失败",
|
||||
"message": "请先发送并输入短信验证码"
|
||||
}
|
||||
}
|
||||
|
||||
# 验证短信码
|
||||
try:
|
||||
ok = await sms_engine.check_sms_code(key, sms_code)
|
||||
except Exception as e:
|
||||
exception(f'sms_register sms check error: {e}')
|
||||
ok = False
|
||||
|
||||
if not ok:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"title": "验证失败",
|
||||
"message": "短信验证码错误或已过期,请重新获取"
|
||||
}
|
||||
}
|
||||
|
||||
# 根据注册域名查找所属分销商
|
||||
parentid = None
|
||||
try:
|
||||
host = request.host
|
||||
env = request._run_ns
|
||||
domain_info = await env.get_domain_by_host(request, host)
|
||||
if domain_info:
|
||||
parentid = domain_info.resellerid
|
||||
debug(f'sms_register: domain={host} -> resellerid={parentid}')
|
||||
else:
|
||||
debug(f'sms_register: domain={host} not found in tenant_domain')
|
||||
except Exception as e:
|
||||
exception(f'sms_register: domain lookup error: {e}')
|
||||
|
||||
# 短信验证通过,注册用户
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
try:
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
# 检查用户名是否已存在
|
||||
existing_user = await sor.R('users', {'username': username})
|
||||
if existing_user:
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 5,
|
||||
"title": "注册失败",
|
||||
"message": "用户名已被占用"
|
||||
}
|
||||
}
|
||||
|
||||
# 调用注册函数
|
||||
reg_params = DictObject(
|
||||
username=username,
|
||||
mobile=mobile,
|
||||
password=password,
|
||||
cfm_password=cfm_password
|
||||
)
|
||||
if parentid:
|
||||
reg_params.parentid = parentid
|
||||
|
||||
data = await register_user(sor, reg_params)
|
||||
data = DictObject(**data)
|
||||
if data.status == 'error':
|
||||
debug(f"sms_register error: {data.data.message}")
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 5,
|
||||
"title": "注册失败",
|
||||
"message": data.data.message
|
||||
}
|
||||
}
|
||||
|
||||
user = data.data.user
|
||||
orgid = user.orgid
|
||||
try:
|
||||
await openCustomerAccounts(sor, '0', orgid)
|
||||
debug(f'{orgid} accounts opened')
|
||||
except Exception as e:
|
||||
exception(f'{e},{orgid=}')
|
||||
|
||||
# 检查是否有促销码,如果有则绑定客户归属关系
|
||||
promo_code_id = params_kw.get('promo_code_id')
|
||||
if promo_code_id:
|
||||
try:
|
||||
await bind_customer(request, DictObject(promo_code_id=promo_code_id, customerid=orgid))
|
||||
debug(f'sms_register: customer {orgid} bound via promo_code {promo_code_id}')
|
||||
except Exception as e:
|
||||
exception(f'sms_register: bind_customer failed for {orgid} via {promo_code_id}: {e}')
|
||||
|
||||
# 注册成功后自动登录
|
||||
await remember_user(user.id, username=user.username, userorgid=user.orgid)
|
||||
return {
|
||||
"widgettype": "Message",
|
||||
"options": {
|
||||
"timeout": 3,
|
||||
"auto_open": True,
|
||||
"title": "注册成功",
|
||||
"message": f"{user.username} 注册成功,已自动登录"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "urlwidget",
|
||||
"target": "window.user_container",
|
||||
"options": {
|
||||
"url": entire_url('/rbac/user/userinfo.ui')
|
||||
}
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "script",
|
||||
"target": "body.login_window",
|
||||
"script": "this.destroy()"
|
||||
},
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "dismissed",
|
||||
"actiontype": "script",
|
||||
"target": "self",
|
||||
"script": "if(bricks.app && bricks.app.dispatch) bricks.app.dispatch('user_logined')"
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
exception(f'sms_register error: {e}')
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 5,
|
||||
"title": "系统错误",
|
||||
"message": f"注册失败: {e}"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"widgettype": "Error",
|
||||
"options": {
|
||||
"timeout": 5,
|
||||
"title": "注册失败",
|
||||
"message": "系统错误,请稍后重试"
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,10 @@
|
||||
|
||||
debug(f'{params_kw=}')
|
||||
ns = {
|
||||
"username":params_kw.username,
|
||||
"password":password_encode(params_kw.password)
|
||||
}
|
||||
|
||||
async def _audit_login(sor, user_id, username, action, result='ok'):
|
||||
"""登录审计:检查系统是否有审计模块,有则写入。"""
|
||||
try:
|
||||
from app_audit import audit_log
|
||||
except ImportError:
|
||||
return
|
||||
try:
|
||||
ip = request.get('client_ip', '') if hasattr(request, 'get') else ''
|
||||
await audit_log(sor, user_id, username, action, result=result, client_ip=ip)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
info(f'{ns=}')
|
||||
db = DBPools()
|
||||
dbname = get_module_dbname('rbac')
|
||||
@ -23,12 +12,7 @@ async with db.sqlorContext(dbname) as sor:
|
||||
# Get user record with lockout fields (standard SQL)
|
||||
r = await sor.sqlExe('select * from users where username=${username}$', ns.copy())
|
||||
if len(r) == 0:
|
||||
await _audit_login(sor, None, ns['username'], 'login_fail', 'fail')
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": "user name or password error"
|
||||
},
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"timeout":3,
|
||||
@ -46,12 +30,7 @@ async with db.sqlorContext(dbname) as sor:
|
||||
stored = datetime.datetime.strptime(str(last_fail), '%Y-%m-%d %H:%M:%S')
|
||||
elapsed = (datetime.datetime.now() - stored).total_seconds()
|
||||
if elapsed < 300:
|
||||
await _audit_login(sor, user.id, ns['username'], 'login_fail', 'locked')
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": "Account locked due to too many failed login attempts. Please try again in 5 minutes."
|
||||
},
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"timeout":5,
|
||||
@ -71,16 +50,14 @@ async with db.sqlorContext(dbname) as sor:
|
||||
# Verify password
|
||||
r = await sor.sqlExe('select * from users where username=${username}$ and password=${password}$', ns.copy())
|
||||
if len(r) == 0:
|
||||
# 5分钟窗口:超过5分钟前的失败次数默认为0(原子 SQL)
|
||||
# Atomic increment -- standard SQL
|
||||
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
stale_before = (datetime.datetime.now() - datetime.timedelta(seconds=300)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
await sor.sqlExe("""
|
||||
UPDATE users
|
||||
SET login_fail_count = CASE WHEN last_login_fail IS NULL OR last_login_fail < ${stale}$ THEN 1 ELSE login_fail_count + 1 END,
|
||||
SET login_fail_count = login_fail_count + 1,
|
||||
last_login_fail = ${now}$
|
||||
WHERE id = ${id}$
|
||||
""", {'id': user.id, 'now': now_str, 'stale': stale_before})
|
||||
await _audit_login(sor, user.id, ns['username'], 'login_fail', 'fail')
|
||||
""", {'id': user.id, 'now': now_str})
|
||||
|
||||
new_fail_count = fail_count + 1
|
||||
if new_fail_count >= 3:
|
||||
@ -88,10 +65,6 @@ async with db.sqlorContext(dbname) as sor:
|
||||
else:
|
||||
msg = f"user name or password error ({3 - new_fail_count} attempts remaining)"
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": msg
|
||||
},
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"timeout":3,
|
||||
@ -108,54 +81,34 @@ async with db.sqlorContext(dbname) as sor:
|
||||
WHERE id = ${id}$
|
||||
""", {'id': user.id, 'now': now_str})
|
||||
await remember_user(r[0].id, username=r[0].username, userorgid=r[0].orgid)
|
||||
await _audit_login(sor, r[0].id, r[0].username, 'login', 'ok')
|
||||
login_source = params_kw.get('login_source', 'manual')
|
||||
msg = {
|
||||
"status": "ok",
|
||||
"data": { "user": r[0]},
|
||||
return {
|
||||
"widgettype":"Message",
|
||||
"options":{
|
||||
"timeout":3,
|
||||
"anchor": "cc",
|
||||
"auto_open":True,
|
||||
"title":"Login",
|
||||
"message":f"{r[0].username} Welcome back"
|
||||
},
|
||||
"binds":[
|
||||
{
|
||||
"wid":"self",
|
||||
"event":"opened",
|
||||
"actiontype":"script",
|
||||
"target":"window.login_window",
|
||||
"script":"this.destroy()"
|
||||
}
|
||||
{
|
||||
"wid":"self",
|
||||
"event":"dismissed",
|
||||
"actiontype":"urlwidget",
|
||||
"target":"window.user_container",
|
||||
"options":{
|
||||
"url":entire_url('/rbac/user/userinfo.ui')
|
||||
}
|
||||
},
|
||||
{
|
||||
"wid":"self",
|
||||
"event":"dismissed",
|
||||
"actiontype":"script",
|
||||
"target": f'body.login_window',
|
||||
"script":"this.destroy()"
|
||||
}
|
||||
]
|
||||
}
|
||||
if login_source == 'auto':
|
||||
# 非用户主动点击登录(如401自动弹窗):不刷新页面,标记不刷新
|
||||
msg["options"]["refresh"] = False
|
||||
else:
|
||||
# 用户主动点击登录:登录成功后刷新页面(跳转/index.ui)。
|
||||
# 注意(2026-09-08 修):旧实现用 actiontype=urlwidget target="window" ——
|
||||
# bricks 里 'window' 解析为 bricks.app 根控件而非浏览器窗口,实际是
|
||||
# in-place 渲染 index.ui 进当前文档。桌面文档即 shell 页(baseURI 正确)
|
||||
# 无感;但从 login.ui 文档进入时 baseURI 停留在 /rbac/user/,后续所有
|
||||
# 根绝对 urlwidget 解析错误 → 反复 401/500 → 登录窗反复打开。
|
||||
# 故:文档是登录页时做真实浏览器跳转;否则保持原 in-place 语义。
|
||||
msg["binds"].insert(0, {
|
||||
"wid":"self",
|
||||
"event":"dismissed",
|
||||
"actiontype":"script",
|
||||
"target":"self",
|
||||
"script":"if(window.location.pathname.indexOf('login.ui')>=0){window.location.replace('/');return;}var w=bricks.getWidgetById('window',this);if(w){bricks.widgetBuild({widgettype:'urlwidget',options:{url:'/index.ui'}},w).then(function(nw){if(nw){w.clear_widgets();w.add_widget(nw);}});}"
|
||||
})
|
||||
return msg
|
||||
return {
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": "system error"
|
||||
},
|
||||
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"timeout":3,
|
||||
|
||||
25
wwwroot/user/userapikey/add_userapikey.dspy
Normal file
25
wwwroot/user/userapikey/add_userapikey.dspy
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
ns = params_kw.copy()
|
||||
id = params_kw.id
|
||||
if not id or len(id) > 32:
|
||||
id = uuid()
|
||||
ns['id'] = id
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('sage') as sor:
|
||||
r = await sor.C('userapikey', ns.copy())
|
||||
return {
|
||||
"widgettype":"Message",
|
||||
"options":{
|
||||
"user_data":ns,
|
||||
"title":"Add Success",
|
||||
"message":"ok"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"title":"Add Error",
|
||||
"message":"failed"
|
||||
}
|
||||
}
|
||||
24
wwwroot/user/userapikey/delete_userapikey.dspy
Normal file
24
wwwroot/user/userapikey/delete_userapikey.dspy
Normal file
@ -0,0 +1,24 @@
|
||||
|
||||
ns = {
|
||||
'id':params_kw['id'],
|
||||
}
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('sage') as sor:
|
||||
r = await sor.D('userapikey', ns)
|
||||
print('delete success');
|
||||
return {
|
||||
"widgettype":"Message",
|
||||
"options":{
|
||||
"title":"Delete Success",
|
||||
"message":"ok"
|
||||
}
|
||||
}
|
||||
|
||||
print('Delete failed');
|
||||
return {
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"title":"Delete Error",
|
||||
"message":"failed"
|
||||
}
|
||||
}
|
||||
74
wwwroot/user/userapikey/get_userapikey.dspy
Normal file
74
wwwroot/user/userapikey/get_userapikey.dspy
Normal file
@ -0,0 +1,74 @@
|
||||
|
||||
ns = params_kw.copy()
|
||||
print(f'get_userapikey.dspy:{ns=}')
|
||||
if not ns.get('page'):
|
||||
ns['page'] = 1
|
||||
if not ns.get('sort'):
|
||||
|
||||
ns['sort'] = 'id'
|
||||
|
||||
filterjson = params_kw.get('data_filter')
|
||||
userid = await get_user()
|
||||
ns['userid'] = userid
|
||||
if not filterjson:
|
||||
fields = [ f['name'] for f in [
|
||||
{
|
||||
"name": "id",
|
||||
"title": "id",
|
||||
"type": "str",
|
||||
"length": 32
|
||||
},
|
||||
{
|
||||
"name": "providerid",
|
||||
"title": "\u4f9b\u5e94\u5546id",
|
||||
"type": "str",
|
||||
"length": 200
|
||||
},
|
||||
{
|
||||
"name": "customerid",
|
||||
"title": "\u7528\u6237id",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "apikey",
|
||||
"title": "api\u5bc6\u94a5",
|
||||
"type": "str",
|
||||
"length": 4000,
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "secretkey",
|
||||
"title": "\u9644\u5c5e\u5bc6\u94a5",
|
||||
"type": "str",
|
||||
"length": 4000
|
||||
},
|
||||
{
|
||||
"name": "rfname",
|
||||
"title": "\u51fd\u6570\u540d",
|
||||
"type": "str",
|
||||
"length": 400
|
||||
}
|
||||
] ]
|
||||
filterjson = default_filterjson(fields, ns)
|
||||
sql = '''select a.*, b.providerid_text, c.customerid_text
|
||||
from (select y.* from users x, userapikey y where x.id=${userid}$ and x.orgid = y.customerid) a left join (select id as providerid,
|
||||
orgname as providerid_text from organization where 1 = 1) b on a.providerid = b.providerid left join (select id as customerid,
|
||||
orgname as customerid_text from organization where 1 = 1) c on a.customerid = c.customerid'''
|
||||
|
||||
if filterjson:
|
||||
dbf = DBFilter(filterjson)
|
||||
conds = dbf.gen(ns)
|
||||
if conds:
|
||||
ns.update(dbf.consts)
|
||||
sql += ' and ' + conds
|
||||
info(f'{ns=},{sql=}')
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('sage') as sor:
|
||||
r = await sor.sqlPaging(sql, ns)
|
||||
return r
|
||||
return {
|
||||
"total":0,
|
||||
"rows":[]
|
||||
}
|
||||
126
wwwroot/user/userapikey/index.ui
Normal file
126
wwwroot/user/userapikey/index.ui
Normal file
@ -0,0 +1,126 @@
|
||||
|
||||
{
|
||||
"widgettype":"Tabular",
|
||||
"options":{
|
||||
|
||||
|
||||
"editable":{
|
||||
"new_data_url":"{{entire_url('add_userapikey.dspy')}}",
|
||||
"delete_data_url":"{{entire_url('delete_userapikey.dspy')}}",
|
||||
"update_data_url":"{{entire_url('update_userapikey.dspy')}}"
|
||||
},
|
||||
|
||||
"data_url":"{{entire_url('./get_userapikey.dspy')}}",
|
||||
"data_method":"GET",
|
||||
"data_params":{{json.dumps(params_kw, indent=4)}},
|
||||
"row_options":{
|
||||
|
||||
|
||||
|
||||
|
||||
"browserfields":{
|
||||
"excloud": [],
|
||||
"cwidth": {}
|
||||
},
|
||||
|
||||
|
||||
"editexclouded":[
|
||||
"id"
|
||||
],
|
||||
|
||||
"fields":[
|
||||
{
|
||||
"name": "id",
|
||||
"title": "id",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"cwidth": 18,
|
||||
"uitype": "str",
|
||||
"datatype": "str",
|
||||
"label": "id"
|
||||
},
|
||||
{
|
||||
"name": "providerid",
|
||||
"title": "\u4f9b\u5e94\u5546id",
|
||||
"type": "str",
|
||||
"length": 200,
|
||||
"label": "\u4f9b\u5e94\u5546id",
|
||||
"cwidth":16,
|
||||
"uitype": "code",
|
||||
"cwidth":18,
|
||||
"valueField": "providerid",
|
||||
"textField": "providerid_text",
|
||||
"params": {
|
||||
"dbname": "sage",
|
||||
"table": "organization",
|
||||
"tblvalue": "id",
|
||||
"tbltext": "orgname",
|
||||
"valueField": "providerid",
|
||||
"textField": "providerid_text"
|
||||
},
|
||||
"dataurl": "\/get_code.dspy"
|
||||
},
|
||||
{
|
||||
"name": "customerid",
|
||||
"title": "\u7528\u6237id",
|
||||
"type": "str",
|
||||
"length": 32,
|
||||
"cwidth":18,
|
||||
"default": "0",
|
||||
"label": "\u7528\u6237id",
|
||||
"cwidth":16,
|
||||
"uitype": "code",
|
||||
"valueField": "customerid",
|
||||
"textField": "customerid_text",
|
||||
"params": {
|
||||
"dbname": "sage",
|
||||
"table": "organization",
|
||||
"tblvalue": "id",
|
||||
"tbltext": "orgname",
|
||||
"valueField": "customerid",
|
||||
"textField": "customerid_text"
|
||||
},
|
||||
"dataurl": "\/get_code.dspy"
|
||||
},
|
||||
{
|
||||
"name": "apikey",
|
||||
"title": "api\u5bc6\u94a5",
|
||||
"type": "str",
|
||||
"length": 4000,
|
||||
"default": "0",
|
||||
"cwidth": 18,
|
||||
"uitype": "text",
|
||||
"rows": 4,
|
||||
"datatype": "str",
|
||||
"label": "api\u5bc6\u94a5"
|
||||
},
|
||||
{
|
||||
"name": "secretkey",
|
||||
"title": "\u9644\u5c5e\u5bc6\u94a5",
|
||||
"type": "str",
|
||||
"length": 4000,
|
||||
"cwidth": 18,
|
||||
"uitype": "text",
|
||||
"rows": 4,
|
||||
"datatype": "str",
|
||||
"label": "\u9644\u5c5e\u5bc6\u94a5"
|
||||
},
|
||||
{
|
||||
"name": "rfname",
|
||||
"title": "\u51fd\u6570\u540d",
|
||||
"type": "str",
|
||||
"length": 400,
|
||||
"cwidth": 18,
|
||||
"uitype": "text",
|
||||
"rows": 4,
|
||||
"datatype": "str",
|
||||
"label": "\u51fd\u6570\u540d"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"page_rows":160,
|
||||
"cache_limit":5
|
||||
}
|
||||
|
||||
}
|
||||
22
wwwroot/user/userapikey/update_userapikey.dspy
Normal file
22
wwwroot/user/userapikey/update_userapikey.dspy
Normal file
@ -0,0 +1,22 @@
|
||||
|
||||
ns = params_kw.copy()
|
||||
db = DBPools()
|
||||
async with db.sqlorContext('sage') as sor:
|
||||
r = await sor.U('userapikey', ns)
|
||||
print('update success');
|
||||
return {
|
||||
"widgettype":"Message",
|
||||
"options":{
|
||||
"title":"Update Success",
|
||||
"message":"ok"
|
||||
}
|
||||
}
|
||||
|
||||
print('update failed');
|
||||
return {
|
||||
"widgettype":"Error",
|
||||
"options":{
|
||||
"title":"Update Error",
|
||||
"message":"failed"
|
||||
}
|
||||
}
|
||||
@ -32,16 +32,6 @@
|
||||
"label":"我的角色",
|
||||
"url":"{{entire_url('user/myrole.ui')}}"
|
||||
},
|
||||
{
|
||||
"name":"editprofile",
|
||||
"label":"完善信息",
|
||||
"url":"{{entire_url('user/edit_profile.dspy')}}",
|
||||
"popup_options":{
|
||||
"title":"完善个人信息",
|
||||
"cwidth":22,
|
||||
"height":"75%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name":"logout",
|
||||
"label":"签退",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user