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.
Files changed (40) hide show
  1. account_kit-0.1.0/LICENSE +21 -0
  2. account_kit-0.1.0/MANIFEST.in +2 -0
  3. account_kit-0.1.0/PKG-INFO +108 -0
  4. account_kit-0.1.0/README.md +66 -0
  5. account_kit-0.1.0/pyproject.toml +58 -0
  6. account_kit-0.1.0/setup.cfg +4 -0
  7. account_kit-0.1.0/src/account_kit/__init__.py +9 -0
  8. account_kit-0.1.0/src/account_kit/admin_router.py +181 -0
  9. account_kit-0.1.0/src/account_kit/admin_schemas.py +58 -0
  10. account_kit-0.1.0/src/account_kit/catalog.py +167 -0
  11. account_kit-0.1.0/src/account_kit/config.py +81 -0
  12. account_kit-0.1.0/src/account_kit/crypto.py +11 -0
  13. account_kit-0.1.0/src/account_kit/db.py +14 -0
  14. account_kit-0.1.0/src/account_kit/deps.py +74 -0
  15. account_kit-0.1.0/src/account_kit/emailer.py +71 -0
  16. account_kit-0.1.0/src/account_kit/models.py +167 -0
  17. account_kit-0.1.0/src/account_kit/otp.py +99 -0
  18. account_kit-0.1.0/src/account_kit/profile.py +54 -0
  19. account_kit-0.1.0/src/account_kit/router.py +174 -0
  20. account_kit-0.1.0/src/account_kit/schema_setup.py +11 -0
  21. account_kit-0.1.0/src/account_kit/schemas.py +115 -0
  22. account_kit-0.1.0/src/account_kit/security.py +47 -0
  23. account_kit-0.1.0/src/account_kit/seed.py +59 -0
  24. account_kit-0.1.0/src/account_kit/service.py +329 -0
  25. account_kit-0.1.0/src/account_kit/templates/emails/verification_en.html +10 -0
  26. account_kit-0.1.0/src/account_kit/templates/emails/verification_zh.html +10 -0
  27. account_kit-0.1.0/src/account_kit/two_factor/__init__.py +1 -0
  28. account_kit-0.1.0/src/account_kit/two_factor/challenges.py +69 -0
  29. account_kit-0.1.0/src/account_kit/two_factor/router.py +165 -0
  30. account_kit-0.1.0/src/account_kit/two_factor/service.py +308 -0
  31. account_kit-0.1.0/src/account_kit/two_factor/totp.py +65 -0
  32. account_kit-0.1.0/src/account_kit.egg-info/PKG-INFO +108 -0
  33. account_kit-0.1.0/src/account_kit.egg-info/SOURCES.txt +38 -0
  34. account_kit-0.1.0/src/account_kit.egg-info/dependency_links.txt +1 -0
  35. account_kit-0.1.0/src/account_kit.egg-info/requires.txt +15 -0
  36. account_kit-0.1.0/src/account_kit.egg-info/top_level.txt +1 -0
  37. account_kit-0.1.0/tests/__init__.py +0 -0
  38. account_kit-0.1.0/tests/conftest.py +49 -0
  39. account_kit-0.1.0/tests/test_auth.py +128 -0
  40. 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,2 @@
1
+ graft tests
2
+ global-exclude __pycache__ *.py[cod]
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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