Compare commits

..

No commits in common. "main" and "feat/dataviz-users" have entirely different histories.

52 changed files with 959 additions and 2727 deletions

10
.gitignore vendored
View File

@ -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
View File

@ -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 | 密码 RC4appPublic.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=10000TTL **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() # 先加载 appbaserbac 注册开关读 params 表)
load_rbac()
load_dapi() # 依赖 rbac 的 register_auth_method必须在其后
```
依赖ahserverAuthAPI/ServerEnv、sqlorDBPools/get_sor_context、appPublicrc4/share_cache/jsonConfig 等、appbaseparams 表的 `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 + Redisrp 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`

View File

@ -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

View File

@ -1,137 +0,0 @@
用户管理: ユーザー管理
用户名: ユーザー名
密码: パスワード
确认密码: パスワード確認
姓名: 氏名
邮箱: メールアドレス
手机号: 電話番号
所属机构: 所属組織
角色: ロール
状态: ステータス
启用: 有効
停用: 無効
新增用户: ユーザー追加
编辑用户: ユーザー編集
删除用户: ユーザー削除
重置密码: パスワードリセット
角色管理: ロール管理
角色名称: ロール名
角色编码: ロールコード
角色描述: ロール説明
权限: 権限
新增角色: ロール追加
编辑角色: ロール編集
删除角色: ロール削除
分配权限: 権限割り当て
机构管理: 組織管理
机构名称: 組織名
机构编码: 組織コード
机构类型: 組織タイプ
上级机构: 上位組織
新增机构: 組織追加
编辑机构: 組織編集
删除机构: 組織削除
菜单管理: メニュー管理
菜单名称: メニュー名
菜单路径: メニューパス
菜单图标: メニューアイコン
菜单排序: メニュー並び順
上级菜单: 上位メニュー
新增菜单: メニュー追加
编辑菜单: メニュー編集
删除菜单: メニュー削除
权限管理: 権限管理
权限名称: 権限名
权限编码: 権限コード
权限类型: 権限タイプ
资源权限: リソース権限
操作权限: 操作権限
数据权限: データ権限
登录日志: ログインログ
登录时间: ログイン時間
登录IP: ログインIP
登录状态: ログインステータス
成功: 成功
失败: 失敗
操作日志: 操作ログ
操作类型: 操作タイプ
操作时间: 操作時間
操作人: 操作者
操作结果: 操作結果
API密钥管理: APIキー管理
密钥名称: キー名
密钥值: キー値
密钥状态: キーステータス
新增密钥: キー追加
编辑密钥: キー編集
删除密钥: キー削除
在线用户: オンラインユーザー
会话ID: セッションID
最后活动时间: 最終アクティビティ時間
强制下线: 強制ログアウト
安全设置: セキュリティ設定
密码策略: パスワードポリシー
密码长度: パスワード長さ
密码复杂度: パスワード複雑度
密码有效期: パスワード有効期限
登录失败锁定: ログイン失敗ロック
最大失败次数: 最大失敗回数
锁定时长: ロック期間
双因素认证: 二要素認証
启用双因素: 二要素認証を有効化
验证码: 認証コード
Token管理: トークン管理
Token名称: トークン名
Token过期时间: トークン有効期限
新增Token: トークン追加
编辑Token: トークン編集
删除Token: トークン削除
刷新Token: トークン更新
用户组: ユーザーグループ
组名称: グループ名
组描述: グループ説明
新增用户组: ユーザーグループ追加
编辑用户组: ユーザーグループ編集
删除用户组: ユーザーグループ削除
成员管理: メンバー管理
添加成员: メンバー追加
移除成员: メンバー削除
搜索: 検索
名称或编码: 名前またはコード
操作: 操作
描述: 説明
类型: タイプ
名称: 名前
编码: コード
备注: 備考
创建时间: 作成日時
更新时间: 更新日時
新增: 追加
保存: 保存
取消: キャンセル
确认: 確認
删除: 削除
编辑: 編集
查看: 表示
导出: エクスポート
打印: 印刷
刷新: 更新
返回: 戻る
提交: 送信
重置: リセット
Conform: 確認
Discard: 破棄
Submit: 送信
Reset: リセット
Cancel: キャンセル
全部: すべて
日期: 日付
管理员: 管理者
普通用户: 一般ユーザー
只读用户: 読み取り専用ユーザー
访问控制: アクセス制御
最后登录: 最終ログイン
清除登录次数: ログイン失敗回数をクリア
新密码: 新しいパスワード
确认清除该用户的连续登录失败次数?: このユーザーの連続ログイン失敗回数をクリアしますか?
为选中用户设置新密码至少8位: 選択したユーザーに新しいパスワード8文字以上を設定

View File

@ -1,137 +0,0 @@
用户管理: 사용자 관리
用户名: 사용자명
密码: 비밀번호
确认密码: 비밀번호 확인
姓名: 이름
邮箱: 이메일
手机号: 전화번호
所属机构: 소속 기관
角色: 역할
状态: 상태
启用: 활성화
停用: 비활성화
新增用户: 사용자 추가
编辑用户: 사용자 편집
删除用户: 사용자 삭제
重置密码: 비밀번호 재설정
角色管理: 역할 관리
角色名称: 역할명
角色编码: 역할 코드
角色描述: 역할 설명
权限: 권한
新增角色: 역할 추가
编辑角色: 역할 편집
删除角色: 역할 삭제
分配权限: 권한 할당
机构管理: 기관 관리
机构名称: 기관명
机构编码: 기관 코드
机构类型: 기관 유형
上级机构: 상위 기관
新增机构: 기관 추가
编辑机构: 기관 편집
删除机构: 기관 삭제
菜单管理: 메뉴 관리
菜单名称: 메뉴명
菜单路径: 메뉴 경로
菜单图标: 메뉴 아이콘
菜单排序: 메뉴 정렬
上级菜单: 상위 메뉴
新增菜单: 메뉴 추가
编辑菜单: 메뉴 편집
删除菜单: 메뉴 삭제
权限管理: 권한 관리
权限名称: 권한명
权限编码: 권한 코드
权限类型: 권한 유형
资源权限: 리소스 권한
操作权限: 작업 권한
数据权限: 데이터 권한
登录日志: 로그인 로그
登录时间: 로그인 시간
登录IP: 로그인 IP
登录状态: 로그인 상태
成功: 성공
失败: 실패
操作日志: 작업 로그
操作类型: 작업 유형
操作时间: 작업 시간
操作人: 작업자
操作结果: 작업 결과
API密钥管理: API 키 관리
密钥名称: 키 이름
密钥值: 키 값
密钥状态: 키 상태
新增密钥: 키 추가
编辑密钥: 키 편집
删除密钥: 키 삭제
在线用户: 온라인 사용자
会话ID: 세션 ID
最后活动时间: 마지막 활동 시간
强制下线: 강제 로그아웃
安全设置: 보안 설정
密码策略: 비밀번호 정책
密码长度: 비밀번호 길이
密码复杂度: 비밀번호 복잡도
密码有效期: 비밀번호 유효기간
登录失败锁定: 로그인 실패 잠금
最大失败次数: 최대 실패 횟수
锁定时长: 잠금 시간
双因素认证: 이중 인증
启用双因素: 이중 인증 활성화
验证码: 인증 코드
Token管理: 토큰 관리
Token名称: 토큰 이름
Token过期时间: 토큰 만료 시간
新增Token: 토큰 추가
编辑Token: 토큰 편집
删除Token: 토큰 삭제
刷新Token: 토큰 갱신
用户组: 사용자 그룹
组名称: 그룹 이름
组描述: 그룹 설명
新增用户组: 사용자 그룹 추가
编辑用户组: 사용자 그룹 편집
删除用户组: 사용자 그룹 삭제
成员管理: 멤버 관리
添加成员: 멤버 추가
移除成员: 멤버 제거
搜索: 검색
名称或编码: 이름 또는 코드
操作: 작업
描述: 설명
类型: 유형
名称: 이름
编码: 코드
备注: 비고
创建时间: 생성 시간
更新时间: 업데이트 시간
新增: 추가
保存: 저장
取消: 취소
确认: 확인
删除: 삭제
编辑: 편집
查看: 보기
导出: 내보내기
打印: 인쇄
刷新: 새로고침
返回: 뒤로
提交: 제출
重置: 초기화
Conform: 확인
Discard: 폐기
Submit: 제출
Reset: 초기화
Cancel: 취소
全部: 전체
日期: 날짜
管理员: 관리자
普通用户: 일반 사용자
只读用户: 읽기 전용 사용자
访问控制: 접근 제어
最后登录: 마지막 로그인
清除登录次数: 로그인 실패 횟수 초기화
新密码: 새 비밀번호
确认清除该用户的连续登录失败次数?: 이 사용자의 연속 로그인 실패 횟수를 초기화하시겠습니까?
为选中用户设置新密码至少8位: 선택한 사용자에게 새 비밀번호(8자 이상) 설정

View File

@ -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位

View File

@ -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": "禁用"
}
]
}

View File

@ -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
View 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
View File

@ -0,0 +1,14 @@
{
"tblname": "userdepartment",
"title":"用户",
"params": {
"browserfields": {
"exclouded": ["id", "userid"],
"cwidth": {}
},
"editexclouded": [
"id", "userid"
],
"record_toolbar": null
}
}

View File

@ -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",

View File

@ -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"
}
]
}

View File

@ -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;

View File

@ -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
View 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

Binary file not shown.

View 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

Binary file not shown.

View File

@ -67,7 +67,7 @@
{
"name": "created_at",
"title": "注册日期",
"type": "date"
"type": "timestamp"
},
{
"name": "last_login",

View File

@ -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_open1=开放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原子 SQLDB 无关)
# 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):

View File

@ -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')

View File

@ -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 = '.'

View File

@ -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)

View File

@ -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': [

View File

@ -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/*.dspyenable/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.")

View 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"

View File

@ -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')}}"
}
}
]
}

View File

@ -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)}}

View File

@ -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)}}

View File

@ -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)}}

View File

@ -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

View File

@ -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)}}

View File

@ -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)

View File

@ -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
View 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"
}
}
]
}

View File

@ -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;
}

View File

@ -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')

View File

@ -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()"
}
]
}
}

View File

@ -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}"
}
}

View File

@ -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')
}
}
]
}

View File

@ -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 @@
}
}
]
}
}

View File

@ -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,
}
}

View File

@ -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")

View File

@ -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}')

View File

@ -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": "系统错误,请稍后重试"
}
}

View File

@ -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,

View 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"
}
}

View 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"
}
}

View 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":[]
}

View 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
}
}

View 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"
}
}

View File

@ -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":"签退",