account-kit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- account_kit-0.1.0/LICENSE +21 -0
- account_kit-0.1.0/MANIFEST.in +2 -0
- account_kit-0.1.0/PKG-INFO +108 -0
- account_kit-0.1.0/README.md +66 -0
- account_kit-0.1.0/pyproject.toml +58 -0
- account_kit-0.1.0/setup.cfg +4 -0
- account_kit-0.1.0/src/account_kit/__init__.py +9 -0
- account_kit-0.1.0/src/account_kit/admin_router.py +181 -0
- account_kit-0.1.0/src/account_kit/admin_schemas.py +58 -0
- account_kit-0.1.0/src/account_kit/catalog.py +167 -0
- account_kit-0.1.0/src/account_kit/config.py +81 -0
- account_kit-0.1.0/src/account_kit/crypto.py +11 -0
- account_kit-0.1.0/src/account_kit/db.py +14 -0
- account_kit-0.1.0/src/account_kit/deps.py +74 -0
- account_kit-0.1.0/src/account_kit/emailer.py +71 -0
- account_kit-0.1.0/src/account_kit/models.py +167 -0
- account_kit-0.1.0/src/account_kit/otp.py +99 -0
- account_kit-0.1.0/src/account_kit/profile.py +54 -0
- account_kit-0.1.0/src/account_kit/router.py +174 -0
- account_kit-0.1.0/src/account_kit/schema_setup.py +11 -0
- account_kit-0.1.0/src/account_kit/schemas.py +115 -0
- account_kit-0.1.0/src/account_kit/security.py +47 -0
- account_kit-0.1.0/src/account_kit/seed.py +59 -0
- account_kit-0.1.0/src/account_kit/service.py +329 -0
- account_kit-0.1.0/src/account_kit/templates/emails/verification_en.html +10 -0
- account_kit-0.1.0/src/account_kit/templates/emails/verification_zh.html +10 -0
- account_kit-0.1.0/src/account_kit/two_factor/__init__.py +1 -0
- account_kit-0.1.0/src/account_kit/two_factor/challenges.py +69 -0
- account_kit-0.1.0/src/account_kit/two_factor/router.py +165 -0
- account_kit-0.1.0/src/account_kit/two_factor/service.py +308 -0
- account_kit-0.1.0/src/account_kit/two_factor/totp.py +65 -0
- account_kit-0.1.0/src/account_kit.egg-info/PKG-INFO +108 -0
- account_kit-0.1.0/src/account_kit.egg-info/SOURCES.txt +38 -0
- account_kit-0.1.0/src/account_kit.egg-info/dependency_links.txt +1 -0
- account_kit-0.1.0/src/account_kit.egg-info/requires.txt +15 -0
- account_kit-0.1.0/src/account_kit.egg-info/top_level.txt +1 -0
- account_kit-0.1.0/tests/__init__.py +0 -0
- account_kit-0.1.0/tests/conftest.py +49 -0
- account_kit-0.1.0/tests/test_auth.py +128 -0
- account_kit-0.1.0/tests/test_two_factor.py +74 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kqstone
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: account-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared async account service for FastAPI apps
|
|
5
|
+
Author: kqstone
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: fastapi,authentication,account,jwt,totp,2fa,sqlalchemy,asyncpg,postgresql
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: FastAPI
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Natural Language :: Chinese (Simplified)
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: fastapi>=0.110
|
|
28
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
29
|
+
Requires-Dist: asyncpg>=0.29
|
|
30
|
+
Requires-Dist: pydantic[email]>=2.0
|
|
31
|
+
Requires-Dist: python-jose[cryptography]>=3.3
|
|
32
|
+
Requires-Dist: bcrypt>=4.0
|
|
33
|
+
Requires-Dist: python-multipart
|
|
34
|
+
Requires-Dist: jinja2>=3.1
|
|
35
|
+
Requires-Dist: pyotp>=2.9
|
|
36
|
+
Requires-Dist: cryptography
|
|
37
|
+
Provides-Extra: dev
|
|
38
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
39
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
40
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# account-kit
|
|
44
|
+
|
|
45
|
+
dedd-online 与 dental-case-pro 共用的异步账号包。PostgreSQL + SQLAlchemy + asyncpg,表在 `auth` schema,用户主键是 UUID。
|
|
46
|
+
|
|
47
|
+
账号能力:注册、登录、邮件验证码、审批、单设备会话、自定义角色、后台权限(`is_admin`,不是角色)、等级目录、两步验证。限额、限流、积分留在宿主。
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from account_kit import init_db, mount_account, seed_defaults
|
|
51
|
+
from account_kit.config import AccountKitConfig
|
|
52
|
+
|
|
53
|
+
await init_db(engine)
|
|
54
|
+
await seed_defaults(session)
|
|
55
|
+
mount_account(app, get_db, AccountKitConfig(jwt_secret="..."))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 安装
|
|
59
|
+
|
|
60
|
+
Python 包从 [PyPI](https://pypi.org/project/account-kit/) 安装:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install account-kit
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
界面包发在 GitHub Packages(不是 npmjs.com)。安装前在项目根写 `.npmrc`:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
@kqstone:registry=https://npm.pkg.github.com
|
|
70
|
+
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`NPM_TOKEN` 需要带 `read:packages` 的 classic PAT(或等价权限)。CI 里可以用 `GITHUB_TOKEN`,但要先在包的 **Manage Actions access** 里给消费仓库(如 `kqstone/dedd-online`、`kqstone/dental-case-pro`)Read 权限。从 private 仓库首次发布的包默认为 private;可在包设置里改成 public,但无论可见性如何,安装都仍需要 token。
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install @kqstone/account-ui-vue # Vue 3
|
|
77
|
+
npm install @kqstone/account-ui-react # React 18 / 19
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
不单独发布 client 包,请求客户端打进这两个界面包:
|
|
81
|
+
|
|
82
|
+
- `@kqstone/account-ui-vue`:`LoginForm`、`RegisterForm`、`ResetPasswordForm`、`TierBadge`、`createAccountClient`
|
|
83
|
+
- `@kqstone/account-ui-react`:同上
|
|
84
|
+
|
|
85
|
+
界面包发布的是构建后的 `dist/`(ESM + `.d.ts`),样式在组件首次渲染时注入,不需要单独引入 CSS。
|
|
86
|
+
|
|
87
|
+
## 本地开发
|
|
88
|
+
|
|
89
|
+
宿主仓库用 `file:` 路径引用界面包时,先在 account-kit 里构建一次(`npm ci` 会通过 `prepare` 自动构建):
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cd packages/account-ui-vue && npm ci # 或 packages/account-ui-react
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
改了界面包源码后,重新 `npm run build`,再在宿主里重新安装依赖。
|
|
96
|
+
|
|
97
|
+
## 发布
|
|
98
|
+
|
|
99
|
+
版本号写在 `pyproject.toml` 和两个 `packages/*/package.json` 里,三处保持一致。推送 `v<版本号>` tag(如 `v0.1.0`)后,GitHub Action 先校验 tag 与三处版本一致、跑测试和构建,然后:
|
|
100
|
+
|
|
101
|
+
- Python 包发布到 [PyPI](https://pypi.org/project/account-kit/)(Trusted Publishing,不用 token)
|
|
102
|
+
- 两个界面包发布到 [GitHub Packages](https://github.com/kqstone?tab=packages)(`GITHUB_TOKEN`,`packages: write`)
|
|
103
|
+
|
|
104
|
+
日后若改发 npmjs.com:把 `publishConfig.registry` 改回默认、workflow 改为 `registry.npmjs.org` + `NPM_TOKEN`(或 npm trusted publishing),并更新本节安装说明。
|
|
105
|
+
|
|
106
|
+
## 许可证
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# account-kit
|
|
2
|
+
|
|
3
|
+
dedd-online 与 dental-case-pro 共用的异步账号包。PostgreSQL + SQLAlchemy + asyncpg,表在 `auth` schema,用户主键是 UUID。
|
|
4
|
+
|
|
5
|
+
账号能力:注册、登录、邮件验证码、审批、单设备会话、自定义角色、后台权限(`is_admin`,不是角色)、等级目录、两步验证。限额、限流、积分留在宿主。
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from account_kit import init_db, mount_account, seed_defaults
|
|
9
|
+
from account_kit.config import AccountKitConfig
|
|
10
|
+
|
|
11
|
+
await init_db(engine)
|
|
12
|
+
await seed_defaults(session)
|
|
13
|
+
mount_account(app, get_db, AccountKitConfig(jwt_secret="..."))
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
Python 包从 [PyPI](https://pypi.org/project/account-kit/) 安装:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install account-kit
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
界面包发在 GitHub Packages(不是 npmjs.com)。安装前在项目根写 `.npmrc`:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
@kqstone:registry=https://npm.pkg.github.com
|
|
28
|
+
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`NPM_TOKEN` 需要带 `read:packages` 的 classic PAT(或等价权限)。CI 里可以用 `GITHUB_TOKEN`,但要先在包的 **Manage Actions access** 里给消费仓库(如 `kqstone/dedd-online`、`kqstone/dental-case-pro`)Read 权限。从 private 仓库首次发布的包默认为 private;可在包设置里改成 public,但无论可见性如何,安装都仍需要 token。
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install @kqstone/account-ui-vue # Vue 3
|
|
35
|
+
npm install @kqstone/account-ui-react # React 18 / 19
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
不单独发布 client 包,请求客户端打进这两个界面包:
|
|
39
|
+
|
|
40
|
+
- `@kqstone/account-ui-vue`:`LoginForm`、`RegisterForm`、`ResetPasswordForm`、`TierBadge`、`createAccountClient`
|
|
41
|
+
- `@kqstone/account-ui-react`:同上
|
|
42
|
+
|
|
43
|
+
界面包发布的是构建后的 `dist/`(ESM + `.d.ts`),样式在组件首次渲染时注入,不需要单独引入 CSS。
|
|
44
|
+
|
|
45
|
+
## 本地开发
|
|
46
|
+
|
|
47
|
+
宿主仓库用 `file:` 路径引用界面包时,先在 account-kit 里构建一次(`npm ci` 会通过 `prepare` 自动构建):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
cd packages/account-ui-vue && npm ci # 或 packages/account-ui-react
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
改了界面包源码后,重新 `npm run build`,再在宿主里重新安装依赖。
|
|
54
|
+
|
|
55
|
+
## 发布
|
|
56
|
+
|
|
57
|
+
版本号写在 `pyproject.toml` 和两个 `packages/*/package.json` 里,三处保持一致。推送 `v<版本号>` tag(如 `v0.1.0`)后,GitHub Action 先校验 tag 与三处版本一致、跑测试和构建,然后:
|
|
58
|
+
|
|
59
|
+
- Python 包发布到 [PyPI](https://pypi.org/project/account-kit/)(Trusted Publishing,不用 token)
|
|
60
|
+
- 两个界面包发布到 [GitHub Packages](https://github.com/kqstone?tab=packages)(`GITHUB_TOKEN`,`packages: write`)
|
|
61
|
+
|
|
62
|
+
日后若改发 npmjs.com:把 `publishConfig.registry` 改回默认、workflow 改为 `registry.npmjs.org` + `NPM_TOKEN`(或 npm trusted publishing),并更新本节安装说明。
|
|
63
|
+
|
|
64
|
+
## 许可证
|
|
65
|
+
|
|
66
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "account-kit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared async account service for FastAPI apps"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "kqstone" }]
|
|
10
|
+
keywords = ["fastapi", "authentication", "account", "jwt", "totp", "2fa", "sqlalchemy", "asyncpg", "postgresql"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: FastAPI",
|
|
14
|
+
"Framework :: AsyncIO",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Natural Language :: Chinese (Simplified)",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
26
|
+
"Topic :: Security",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"fastapi>=0.110",
|
|
31
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
32
|
+
"asyncpg>=0.29",
|
|
33
|
+
"pydantic[email]>=2.0",
|
|
34
|
+
"python-jose[cryptography]>=3.3",
|
|
35
|
+
"bcrypt>=4.0",
|
|
36
|
+
"python-multipart",
|
|
37
|
+
"jinja2>=3.1",
|
|
38
|
+
"pyotp>=2.9",
|
|
39
|
+
"cryptography",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "httpx>=0.27"]
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["setuptools>=77"]
|
|
47
|
+
build-backend = "setuptools.build_meta"
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["src"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.package-data]
|
|
53
|
+
account_kit = ["templates/emails/*.html"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
asyncio_mode = "auto"
|
|
57
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
58
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Shared account kit: one async user model for every app."""
|
|
2
|
+
|
|
3
|
+
from account_kit.router import mount_account
|
|
4
|
+
from account_kit.schema_setup import init_db
|
|
5
|
+
from account_kit.seed import seed_defaults
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = ["init_db", "mount_account", "seed_defaults", "__version__"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from account_kit.admin_schemas import AdminUserPatch, RoleBody, RoleChangeCreate, RoleChangeReview, RolePatch, TierBody, TierPatch
|
|
8
|
+
from account_kit.catalog import apply_admin_user_patch, create_role, create_tier, delete_role, delete_tier, patch_role, patch_tier
|
|
9
|
+
from account_kit.config import get_config
|
|
10
|
+
from account_kit.deps import get_current_user, get_db, require_admin
|
|
11
|
+
from account_kit.models import Role, RoleChangeRequest, User, UserTier
|
|
12
|
+
from account_kit.service import assert_role_code, get_user_by_id, to_response
|
|
13
|
+
|
|
14
|
+
admin_router = APIRouter(tags=["account-admin"])
|
|
15
|
+
public_extra = APIRouter(tags=["auth"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _role_dict(row: Role) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"code": row.code,
|
|
21
|
+
"name": row.name,
|
|
22
|
+
"sort_order": row.sort_order,
|
|
23
|
+
"description": row.description,
|
|
24
|
+
"is_default": row.is_default,
|
|
25
|
+
"allow_register": row.allow_register,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _tier_dict(row: UserTier) -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"code": row.code,
|
|
32
|
+
"name": row.name,
|
|
33
|
+
"sort_order": row.sort_order,
|
|
34
|
+
"badge_color": row.badge_color,
|
|
35
|
+
"description": row.description,
|
|
36
|
+
"is_default": row.is_default,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@public_extra.get("/roles")
|
|
41
|
+
async def public_roles(db: AsyncSession = Depends(get_db)):
|
|
42
|
+
result = await db.execute(select(Role).where(Role.allow_register.is_(True)).order_by(Role.sort_order, Role.code))
|
|
43
|
+
return [_role_dict(row) for row in result.scalars().all()]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@public_extra.get("/tiers")
|
|
47
|
+
async def public_tiers(db: AsyncSession = Depends(get_db)):
|
|
48
|
+
result = await db.execute(select(UserTier).order_by(UserTier.sort_order, UserTier.code))
|
|
49
|
+
return [_tier_dict(row) for row in result.scalars().all()]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@public_extra.post("/role-change-requests")
|
|
53
|
+
async def request_role_change(
|
|
54
|
+
body: RoleChangeCreate,
|
|
55
|
+
current_user=Depends(get_current_user),
|
|
56
|
+
db: AsyncSession = Depends(get_db),
|
|
57
|
+
):
|
|
58
|
+
if not get_config().role_change_enabled:
|
|
59
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
60
|
+
code = assert_role_code(body.requested_role)
|
|
61
|
+
role = await db.get(Role, code)
|
|
62
|
+
if role is None:
|
|
63
|
+
raise HTTPException(status_code=400, detail="角色不存在")
|
|
64
|
+
if code == current_user.role:
|
|
65
|
+
raise HTTPException(status_code=400, detail="已经是该角色")
|
|
66
|
+
existing = await db.execute(
|
|
67
|
+
select(RoleChangeRequest).where(
|
|
68
|
+
RoleChangeRequest.user_id == current_user.id,
|
|
69
|
+
RoleChangeRequest.status == "pending",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
if existing.scalars().first():
|
|
73
|
+
raise HTTPException(status_code=409, detail="已有待审核的角色申请")
|
|
74
|
+
row = RoleChangeRequest(user_id=current_user.id, from_role=current_user.role, to_role=code, status="pending")
|
|
75
|
+
db.add(row)
|
|
76
|
+
await db.commit()
|
|
77
|
+
await db.refresh(row)
|
|
78
|
+
return {"id": row.id, "from_role": row.from_role, "to_role": row.to_role, "status": row.status}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@admin_router.get("/roles")
|
|
82
|
+
async def list_roles(_admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
83
|
+
result = await db.execute(select(Role).order_by(Role.sort_order, Role.code))
|
|
84
|
+
return [_role_dict(row) for row in result.scalars().all()]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@admin_router.post("/roles", status_code=201)
|
|
88
|
+
async def add_role(body: RoleBody, _admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
89
|
+
return _role_dict(await create_role(db, body))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@admin_router.patch("/roles/{code}")
|
|
93
|
+
async def edit_role(code: str, body: RolePatch, _admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
94
|
+
return _role_dict(await patch_role(db, code, body))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@admin_router.delete("/roles/{code}", status_code=204)
|
|
98
|
+
async def remove_role(code: str, _admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
99
|
+
await delete_role(db, code)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@admin_router.get("/tiers")
|
|
103
|
+
async def list_tiers(_admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
104
|
+
result = await db.execute(select(UserTier).order_by(UserTier.sort_order, UserTier.code))
|
|
105
|
+
return [_tier_dict(row) for row in result.scalars().all()]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@admin_router.post("/tiers", status_code=201)
|
|
109
|
+
async def add_tier(body: TierBody, _admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
110
|
+
return _tier_dict(await create_tier(db, body))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@admin_router.patch("/tiers/{code}")
|
|
114
|
+
async def edit_tier(code: str, body: TierPatch, _admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
115
|
+
return _tier_dict(await patch_tier(db, code, body))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@admin_router.delete("/tiers/{code}", status_code=204)
|
|
119
|
+
async def remove_tier(code: str, _admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
120
|
+
await delete_tier(db, code)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@admin_router.get("/users")
|
|
124
|
+
async def list_users(_admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
125
|
+
result = await db.execute(select(User).order_by(User.created_at.desc()))
|
|
126
|
+
return [await to_response(db, user) for user in result.scalars().all()]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@admin_router.patch("/users/{user_id}")
|
|
130
|
+
async def patch_user(
|
|
131
|
+
user_id: str,
|
|
132
|
+
body: AdminUserPatch,
|
|
133
|
+
_admin=Depends(require_admin),
|
|
134
|
+
db: AsyncSession = Depends(get_db),
|
|
135
|
+
):
|
|
136
|
+
user = await get_user_by_id(db, user_id)
|
|
137
|
+
if user is None:
|
|
138
|
+
raise HTTPException(status_code=404, detail="用户不存在")
|
|
139
|
+
user = await apply_admin_user_patch(db, user, body)
|
|
140
|
+
return await to_response(db, user)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@admin_router.delete("/users/{user_id}", status_code=204)
|
|
144
|
+
async def delete_user(user_id: str, _admin=Depends(require_admin), db: AsyncSession = Depends(get_db)):
|
|
145
|
+
config = get_config()
|
|
146
|
+
user = await get_user_by_id(db, user_id)
|
|
147
|
+
if user is None:
|
|
148
|
+
raise HTTPException(status_code=404, detail="用户不存在")
|
|
149
|
+
if config.on_deleted is not None:
|
|
150
|
+
await config.on_deleted(db, user)
|
|
151
|
+
await db.delete(user)
|
|
152
|
+
await db.commit()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@admin_router.post("/role-change-requests/{request_id}/review")
|
|
156
|
+
async def review_role_change(
|
|
157
|
+
request_id: str,
|
|
158
|
+
body: RoleChangeReview,
|
|
159
|
+
admin=Depends(require_admin),
|
|
160
|
+
db: AsyncSession = Depends(get_db),
|
|
161
|
+
):
|
|
162
|
+
if body.status not in ("approved", "rejected"):
|
|
163
|
+
raise HTTPException(status_code=400, detail="状态无效")
|
|
164
|
+
try:
|
|
165
|
+
request_uuid = uuid.UUID(request_id)
|
|
166
|
+
except ValueError:
|
|
167
|
+
raise HTTPException(status_code=404, detail="申请不存在")
|
|
168
|
+
row = await db.get(RoleChangeRequest, request_uuid)
|
|
169
|
+
if row is None or row.status != "pending":
|
|
170
|
+
raise HTTPException(status_code=404, detail="申请不存在")
|
|
171
|
+
row.status = body.status
|
|
172
|
+
from datetime import datetime, timezone
|
|
173
|
+
|
|
174
|
+
row.reviewed_at = datetime.now(timezone.utc)
|
|
175
|
+
row.reviewed_by = admin.id
|
|
176
|
+
if body.status == "approved":
|
|
177
|
+
user = await get_user_by_id(db, row.user_id)
|
|
178
|
+
if user is not None:
|
|
179
|
+
user.role = row.to_role
|
|
180
|
+
await db.commit()
|
|
181
|
+
return {"id": row.id, "status": row.status}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from account_kit.schemas import ApprovalStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RoleBody(BaseModel):
|
|
12
|
+
code: str = Field(min_length=1, max_length=50)
|
|
13
|
+
name: str = Field(min_length=1, max_length=50)
|
|
14
|
+
sort_order: int = 0
|
|
15
|
+
description: Optional[str] = None
|
|
16
|
+
is_default: bool = False
|
|
17
|
+
allow_register: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RolePatch(BaseModel):
|
|
21
|
+
name: Optional[str] = Field(default=None, min_length=1, max_length=50)
|
|
22
|
+
sort_order: Optional[int] = None
|
|
23
|
+
description: Optional[str] = None
|
|
24
|
+
is_default: Optional[bool] = None
|
|
25
|
+
allow_register: Optional[bool] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TierBody(BaseModel):
|
|
29
|
+
code: str = Field(min_length=1, max_length=20)
|
|
30
|
+
name: str = Field(min_length=1, max_length=50)
|
|
31
|
+
sort_order: int = 0
|
|
32
|
+
badge_color: Optional[str] = None
|
|
33
|
+
description: Optional[str] = None
|
|
34
|
+
is_default: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TierPatch(BaseModel):
|
|
38
|
+
name: Optional[str] = Field(default=None, min_length=1, max_length=50)
|
|
39
|
+
sort_order: Optional[int] = None
|
|
40
|
+
badge_color: Optional[str] = None
|
|
41
|
+
description: Optional[str] = None
|
|
42
|
+
is_default: Optional[bool] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AdminUserPatch(BaseModel):
|
|
46
|
+
role: Optional[str] = None
|
|
47
|
+
is_admin: Optional[bool] = None
|
|
48
|
+
is_active: Optional[bool] = None
|
|
49
|
+
approval_status: Optional[ApprovalStatus] = None
|
|
50
|
+
tier_code: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RoleChangeCreate(BaseModel):
|
|
54
|
+
requested_role: str = Field(min_length=1, max_length=50)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RoleChangeReview(BaseModel):
|
|
58
|
+
status: ApprovalStatus
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Role and tier catalog. Limits stay in the host; this only stores names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException
|
|
8
|
+
from sqlalchemy import func, select, update
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from account_kit.models import Role, User, UserTier, UserTierAssignment
|
|
12
|
+
from account_kit.service import assert_role_code
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _clear_default_role(db: AsyncSession) -> None:
|
|
16
|
+
await db.execute(update(Role).values(is_default=False))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _clear_default_tier(db: AsyncSession) -> None:
|
|
20
|
+
await db.execute(update(UserTier).values(is_default=False))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def create_role(db: AsyncSession, body) -> Role:
|
|
24
|
+
code = assert_role_code(body.code)
|
|
25
|
+
if await db.get(Role, code):
|
|
26
|
+
raise HTTPException(status_code=409, detail="角色已存在")
|
|
27
|
+
if body.is_default:
|
|
28
|
+
await _clear_default_role(db)
|
|
29
|
+
row = Role(
|
|
30
|
+
code=code,
|
|
31
|
+
name=body.name.strip(),
|
|
32
|
+
sort_order=body.sort_order,
|
|
33
|
+
description=body.description,
|
|
34
|
+
is_default=body.is_default,
|
|
35
|
+
allow_register=body.allow_register,
|
|
36
|
+
)
|
|
37
|
+
db.add(row)
|
|
38
|
+
await db.commit()
|
|
39
|
+
await db.refresh(row)
|
|
40
|
+
return row
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def patch_role(db: AsyncSession, code: str, body) -> Role:
|
|
44
|
+
row = await db.get(Role, code)
|
|
45
|
+
if row is None:
|
|
46
|
+
raise HTTPException(status_code=404, detail="角色不存在")
|
|
47
|
+
if body.name is not None:
|
|
48
|
+
row.name = body.name.strip()
|
|
49
|
+
if body.sort_order is not None:
|
|
50
|
+
row.sort_order = body.sort_order
|
|
51
|
+
if body.description is not None:
|
|
52
|
+
row.description = body.description
|
|
53
|
+
if body.allow_register is not None:
|
|
54
|
+
row.allow_register = body.allow_register
|
|
55
|
+
if body.is_default is True:
|
|
56
|
+
await _clear_default_role(db)
|
|
57
|
+
row.is_default = True
|
|
58
|
+
elif body.is_default is False and row.is_default:
|
|
59
|
+
raise HTTPException(status_code=400, detail="请先把另一个角色设为默认")
|
|
60
|
+
await db.commit()
|
|
61
|
+
await db.refresh(row)
|
|
62
|
+
return row
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def delete_role(db: AsyncSession, code: str) -> None:
|
|
66
|
+
row = await db.get(Role, code)
|
|
67
|
+
if row is None:
|
|
68
|
+
raise HTTPException(status_code=404, detail="角色不存在")
|
|
69
|
+
if row.is_default:
|
|
70
|
+
raise HTTPException(status_code=400, detail="不能删除默认角色")
|
|
71
|
+
used = await db.scalar(select(func.count()).select_from(User).where(User.role == code))
|
|
72
|
+
if used:
|
|
73
|
+
raise HTTPException(status_code=409, detail="仍有用户使用该角色")
|
|
74
|
+
await db.delete(row)
|
|
75
|
+
await db.commit()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def create_tier(db: AsyncSession, body) -> UserTier:
|
|
79
|
+
code = (body.code or "").strip()
|
|
80
|
+
if not code:
|
|
81
|
+
raise HTTPException(status_code=400, detail="等级代码无效")
|
|
82
|
+
if await db.get(UserTier, code):
|
|
83
|
+
raise HTTPException(status_code=409, detail="等级已存在")
|
|
84
|
+
if body.is_default:
|
|
85
|
+
await _clear_default_tier(db)
|
|
86
|
+
row = UserTier(
|
|
87
|
+
code=code,
|
|
88
|
+
name=body.name.strip(),
|
|
89
|
+
sort_order=body.sort_order,
|
|
90
|
+
badge_color=body.badge_color,
|
|
91
|
+
description=body.description,
|
|
92
|
+
is_default=body.is_default,
|
|
93
|
+
)
|
|
94
|
+
db.add(row)
|
|
95
|
+
await db.commit()
|
|
96
|
+
await db.refresh(row)
|
|
97
|
+
return row
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def patch_tier(db: AsyncSession, code: str, body) -> UserTier:
|
|
101
|
+
row = await db.get(UserTier, code)
|
|
102
|
+
if row is None:
|
|
103
|
+
raise HTTPException(status_code=404, detail="等级不存在")
|
|
104
|
+
if body.name is not None:
|
|
105
|
+
row.name = body.name.strip()
|
|
106
|
+
if body.sort_order is not None:
|
|
107
|
+
row.sort_order = body.sort_order
|
|
108
|
+
if body.badge_color is not None:
|
|
109
|
+
row.badge_color = body.badge_color
|
|
110
|
+
if body.description is not None:
|
|
111
|
+
row.description = body.description
|
|
112
|
+
if body.is_default is True:
|
|
113
|
+
await _clear_default_tier(db)
|
|
114
|
+
row.is_default = True
|
|
115
|
+
elif body.is_default is False and row.is_default:
|
|
116
|
+
raise HTTPException(status_code=400, detail="请先把另一个等级设为默认")
|
|
117
|
+
await db.commit()
|
|
118
|
+
await db.refresh(row)
|
|
119
|
+
return row
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def delete_tier(db: AsyncSession, code: str) -> None:
|
|
123
|
+
row = await db.get(UserTier, code)
|
|
124
|
+
if row is None:
|
|
125
|
+
raise HTTPException(status_code=404, detail="等级不存在")
|
|
126
|
+
if row.is_default:
|
|
127
|
+
raise HTTPException(status_code=400, detail="不能删除默认等级")
|
|
128
|
+
used = await db.scalar(
|
|
129
|
+
select(func.count()).select_from(UserTierAssignment).where(UserTierAssignment.tier_code == code)
|
|
130
|
+
)
|
|
131
|
+
if used:
|
|
132
|
+
raise HTTPException(status_code=409, detail="仍有用户属于该等级")
|
|
133
|
+
await db.delete(row)
|
|
134
|
+
await db.commit()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def set_user_tier(db: AsyncSession, user: User, tier_code: str) -> None:
|
|
138
|
+
tier = await db.get(UserTier, tier_code)
|
|
139
|
+
if tier is None:
|
|
140
|
+
raise HTTPException(status_code=400, detail="等级不存在")
|
|
141
|
+
row = await db.get(UserTierAssignment, user.id)
|
|
142
|
+
if row is None:
|
|
143
|
+
db.add(UserTierAssignment(user_id=user.id, tier_code=tier.code))
|
|
144
|
+
else:
|
|
145
|
+
row.tier_code = tier.code
|
|
146
|
+
row.updated_at = datetime.now(timezone.utc)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def apply_admin_user_patch(db, user: User, body) -> User:
|
|
150
|
+
if body.role is not None:
|
|
151
|
+
code = assert_role_code(body.role)
|
|
152
|
+
if await db.get(Role, code) is None:
|
|
153
|
+
raise HTTPException(status_code=400, detail="角色不存在")
|
|
154
|
+
user.role = code
|
|
155
|
+
if body.is_admin is not None:
|
|
156
|
+
user.is_admin = body.is_admin
|
|
157
|
+
if body.is_active is not None:
|
|
158
|
+
user.is_active = body.is_active
|
|
159
|
+
if body.approval_status is not None:
|
|
160
|
+
user.approval_status = body.approval_status
|
|
161
|
+
if body.approval_status == "approved" and user.approved_at is None:
|
|
162
|
+
user.approved_at = datetime.now(timezone.utc)
|
|
163
|
+
if body.tier_code is not None:
|
|
164
|
+
await set_user_tier(db, user, body.tier_code)
|
|
165
|
+
await db.commit()
|
|
166
|
+
await db.refresh(user)
|
|
167
|
+
return user
|