fix: 修复鉴权与权限分配缺陷并清理前后端冗余
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -690,3 +690,17 @@
|
|||||||
- 原 `docs/development_page_guide.md` 迁移到仓库根目录并改名为 `DEVELOPMENT_GUIDE.md`,标题改为“开发指南”,用于承载新增页面、模块、权限、迁移、API 和前端交互规范。
|
- 原 `docs/development_page_guide.md` 迁移到仓库根目录并改名为 `DEVELOPMENT_GUIDE.md`,标题改为“开发指南”,用于承载新增页面、模块、权限、迁移、API 和前端交互规范。
|
||||||
- `docs` 目录只保留 `project_context.md`,删除已不作为当前入口维护的 open items 与早期设计文档;当前开发者入口由 `README.md` 和 `DEVELOPMENT_GUIDE.md` 承担。
|
- `docs` 目录只保留 `project_context.md`,删除已不作为当前入口维护的 open items 与早期设计文档;当前开发者入口由 `README.md` 和 `DEVELOPMENT_GUIDE.md` 承担。
|
||||||
- `README.md` 更新目录结构和文档入口说明,移除平台限定描述,命令示例改为更通用的 `python` / `npm` 写法,并把开发规范引用指向 `DEVELOPMENT_GUIDE.md`。
|
- `README.md` 更新目录结构和文档入口说明,移除平台限定描述,命令示例改为更通用的 `python` / `npm` 写法,并把开发规范引用指向 `DEVELOPMENT_GUIDE.md`。
|
||||||
|
|
||||||
|
## 2026-06-12:framework-stable 分支第三轮全量复查与修复
|
||||||
|
- 鉴权加固:`decode_access_token` 现在只接受纯数字 `sub`,伪造或异常 payload 统一走 401,不再因 `int(subject)` 抛 `ValueError` 返回 500;新增伪造 subject 回归测试。
|
||||||
|
- 模块生命周期一致性:`disable` 钩子改为与 install/upgrade/uninstall 相同的“先查 `migration_records` 再执行”模式,记录 key 带模块版本(`hook:<key>:disable:<version>:<hook>`);模块反复启停不会重复执行非幂等 SQL,删除了旧的无守卫 `_run_lifecycle_hooks`,生命周期测试补充重复启停场景。
|
||||||
|
- RBAC 静默降级修复:`assign_roles`、`approve_user`/`create_user` 的 `role_ids`、`assign_permissions` 的 `permission_ids` 现在严格校验 ID 全部存在,无效 ID 返回 400(`error.roleNotFound` / 新增 `error.permissionNotFound`),不再静默部分生效;新增对应接口测试。
|
||||||
|
- 数据一致性:管理员直接创建的用户补记 `approved_by` 和 `approved_at`,与注册审批通过、免审注册路径保持一致。
|
||||||
|
- 审计补缺:`get_token_secret` 显示完整 Token 时记录 `api_token.reveal` 审计;语言包补充 `auditLog.action.api_token.reveal`。
|
||||||
|
- 后端清理:删除无引用的 `ApiTokenRepository.get()` 和前端从未调用的 `GET /api/users/{user_id}` 端点;`_user_profile_snapshot` 重复实现合并为 `app/services/users.py` 的 `user_profile_snapshot`,`auth.py` 复用。
|
||||||
|
- 前端修复:保存系统设置不再调用 `appStore.setLocale` 强制覆盖当前用户语言(`default_locale` 只影响无本地偏好的会话);`AppShell` 公告弹窗改为标记已读成功后才关闭,失败时保持弹出;`PermissionView.loadData` 增加错误提示,避免首屏白屏;校验错误字段 `permission_ids` 的 label 修正为新增的 `field.permissions`。
|
||||||
|
- 前端去重与收敛:`LOCALE_STORAGE_KEY` 统一定义在 `config/app.ts`;`initialLocale()` 收敛到 `i18n/index.ts` 并导出复用,`stores/app.ts` 不再依赖 `settingsStore`;`isFeatureEnabled` 收敛为 `settingsStore.featureEnabled(...)`;剪贴板复制(含 `execCommand` 降级)抽到 `web/src/utils/clipboard.ts`,`TokenManageView` 与 `ApiDocsView` 共用,顺带修复 ApiDocs 复制无降级问题。
|
||||||
|
- 前端清理:`APP_SLUG`、`DEPRECATED_PERMISSION_CODES`、`TranslateParam` 改为模块内私有;删除无引用的 `.module-placeholder`、`.api-response-list` 样式和 `.filter-select` 选择器;移除模板中无样式的 `announcement-manage-card`、`demo-crud-toolbar`、`demo-crud-category-filter`、`announcement-popup-modal` class,脚手架模板同步去掉 `__KEBAB__-toolbar`。
|
||||||
|
- smoke 增强:`web/scripts/smoke.mjs` 新增基础语言包 `i18n/locales/*.json` 的 key 一致性校验(原来只校验模块语言包)。
|
||||||
|
- 有意保留的重复/设计边界:`modules/core.py` 与 `core/permissions.py` 的 READ 常量重复用于避免循环 import;`creator_usernames`/`actor_usernames` 在各模块 repository 重复属于模块自包含约定;dashboard API 和 `/api/audit-logs/export` 作为 API 契约保留;列表页分页/筛选模式按脚手架约定逐页实现,不抽公共 composable。
|
||||||
|
- 验证结果:后端 `compileall` 通过、`pytest` 36 passed(新增 2 个用例);前端 `npm run test:smoke`、`npx vue-tsc --noEmit --noUnusedLocals --noUnusedParameters`、`npm run build`、`npm run test:regression`(4 passed)全部通过。
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export const __CONSTANT___MANAGE_OTHERS = actionPermission("__SNAKE__", "manage_
|
|||||||
function webViewTemplate() {
|
function webViewTemplate() {
|
||||||
return render(`<template>
|
return render(`<template>
|
||||||
<section class="work-card table-page-card">
|
<section class="work-card table-page-card">
|
||||||
<div class="toolbar __KEBAB__-toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('__CAMEL__.searchPlaceholder')" clearable />
|
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('__CAMEL__.searchPlaceholder')" clearable />
|
||||||
<n-input v-model:value="filters.category" :placeholder="t('__CAMEL__.categoryPlaceholder')" clearable />
|
<n-input v-model:value="filters.category" :placeholder="t('__CAMEL__.categoryPlaceholder')" clearable />
|
||||||
|
|||||||
@@ -56,15 +56,6 @@ def list_role_options(
|
|||||||
return UserService(db).list_role_options()
|
return UserService(db).list_role_options()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserListItem)
|
|
||||||
def get_user(
|
|
||||||
user_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: User = Depends(require_permission(USER_READ)),
|
|
||||||
) -> User:
|
|
||||||
return UserService(db).get_user(user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=UserListItem)
|
@router.post("", response_model=UserListItem)
|
||||||
def create_user(
|
def create_user(
|
||||||
payload: UserCreateRequest,
|
payload: UserCreateRequest,
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ def decode_access_token(token: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
if int(payload.get("exp", 0)) < int(datetime.now(timezone.utc).timestamp()):
|
if int(payload.get("exp", 0)) < int(datetime.now(timezone.utc).timestamp()):
|
||||||
return None
|
return None
|
||||||
subject = payload.get("sub")
|
subject = str(payload.get("sub") or "")
|
||||||
return str(subject) if subject else None
|
return subject if subject.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
def create_api_token() -> str:
|
def create_api_token() -> str:
|
||||||
|
|||||||
+1
-14
@@ -93,7 +93,7 @@ def sync_module_states(engine) -> None:
|
|||||||
continue
|
continue
|
||||||
state = state_by_key.get(module.key)
|
state = state_by_key.get(module.key)
|
||||||
if state is not None and state["status"] == "enabled":
|
if state is not None and state["status"] == "enabled":
|
||||||
_run_lifecycle_hooks(conn, module, "disable")
|
_run_recorded_lifecycle_hooks(conn, module, "disable")
|
||||||
_upsert_module_state(conn, module.key, module.version, "disabled", module.dependencies)
|
_upsert_module_state(conn, module.key, module.version, "disabled", module.dependencies)
|
||||||
|
|
||||||
for key, state in state_by_key.items():
|
for key, state in state_by_key.items():
|
||||||
@@ -147,19 +147,6 @@ def _run_recorded_lifecycle_hooks(conn, module, event: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_lifecycle_hooks(conn, module, event: str) -> None:
|
|
||||||
for hook in (item for item in module.lifecycle_hooks if item.event == event):
|
|
||||||
for statement in hook.statements:
|
|
||||||
conn.execute(text(statement))
|
|
||||||
_record_migration(
|
|
||||||
conn,
|
|
||||||
f"hook:{module.key}:{event}:{hook.key}",
|
|
||||||
"module_hook",
|
|
||||||
module.key,
|
|
||||||
hook.description or f"{module.key}@{module.version}:{event}:{hook.key}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_module_state(conn, key: str, version: str, status: str, dependencies: tuple[str, ...]) -> None:
|
def _upsert_module_state(conn, key: str, version: str, status: str, dependencies: tuple[str, ...]) -> None:
|
||||||
payload = json.dumps(list(dependencies), ensure_ascii=False)
|
payload = json.dumps(list(dependencies), ensure_ascii=False)
|
||||||
existing = conn.execute(text("SELECT id FROM module_states WHERE `key` = :key"), {"key": key}).first()
|
existing = conn.execute(text("SELECT id FROM module_states WHERE `key` = :key"), {"key": key}).first()
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ class ApiTokenRepository:
|
|||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def get(self, token_id: int) -> ApiToken | None:
|
|
||||||
return self.db.get(ApiToken, token_id)
|
|
||||||
|
|
||||||
def get_for_user(self, token_id: int, user_id: int) -> ApiToken | None:
|
def get_for_user(self, token_id: int, user_id: int) -> ApiToken | None:
|
||||||
return self.db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.user_id == user_id).first()
|
return self.db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.user_id == user_id).first()
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,16 @@ class ApiTokenService:
|
|||||||
raise not_found("error.apiTokenNotFound", "API token not found")
|
raise not_found("error.apiTokenNotFound", "API token not found")
|
||||||
if not api_token.token_value:
|
if not api_token.token_value:
|
||||||
raise not_found("error.apiTokenSecretUnavailable", "API token secret is unavailable")
|
raise not_found("error.apiTokenSecretUnavailable", "API token secret is unavailable")
|
||||||
|
record_audit(
|
||||||
|
self.db,
|
||||||
|
user.id,
|
||||||
|
"api_token.reveal",
|
||||||
|
"api_token",
|
||||||
|
str(api_token.id),
|
||||||
|
api_token.name,
|
||||||
|
audit_detail(api_token.name, meta=_token_snapshot(api_token)),
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
return api_token.token_value
|
return api_token.token_value
|
||||||
|
|
||||||
def _new_unique_token(self) -> str:
|
def _new_unique_token(self) -> str:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.schemas.settings import PublicSettings
|
|||||||
from app.services.audit import audit_changes, audit_detail, record_audit
|
from app.services.audit import audit_changes, audit_detail, record_audit
|
||||||
from app.services.permissions import get_user_permission_codes
|
from app.services.permissions import get_user_permission_codes
|
||||||
from app.services.settings import SettingService
|
from app.services.settings import SettingService
|
||||||
|
from app.services.users import user_profile_snapshot
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
@@ -113,7 +114,7 @@ class AuthService:
|
|||||||
return token, user, permissions
|
return token, user, permissions
|
||||||
|
|
||||||
def update_profile(self, user: User, payload: ProfileUpdateRequest) -> User:
|
def update_profile(self, user: User, payload: ProfileUpdateRequest) -> User:
|
||||||
before = _user_profile_snapshot(user)
|
before = user_profile_snapshot(user)
|
||||||
user.full_name = payload.full_name
|
user.full_name = payload.full_name
|
||||||
user.phone = payload.phone
|
user.phone = payload.phone
|
||||||
user.email = payload.email
|
user.email = payload.email
|
||||||
@@ -126,7 +127,7 @@ class AuthService:
|
|||||||
"user",
|
"user",
|
||||||
str(user.id),
|
str(user.id),
|
||||||
user.username,
|
user.username,
|
||||||
audit_detail(user.username, audit_changes(before, _user_profile_snapshot(user))),
|
audit_detail(user.username, audit_changes(before, user_profile_snapshot(user))),
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
return user
|
return user
|
||||||
@@ -145,13 +146,3 @@ class AuthService:
|
|||||||
audit_detail(user.username, meta={"password_changed": True}),
|
audit_detail(user.username, meta={"password_changed": True}),
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _user_profile_snapshot(user: User) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
"full_name": user.full_name,
|
|
||||||
"phone": user.phone,
|
|
||||||
"email": user.email,
|
|
||||||
"company": user.company,
|
|
||||||
"department": user.department,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class RoleService:
|
|||||||
if role.code == ADMIN_ROLE:
|
if role.code == ADMIN_ROLE:
|
||||||
role.permissions = self.roles.permissions()
|
role.permissions = self.roles.permissions()
|
||||||
else:
|
else:
|
||||||
role.permissions = self.roles.permissions_by_ids(payload.permission_ids)
|
role.permissions = self._permissions_by_ids_strict(payload.permission_ids)
|
||||||
record_audit(
|
record_audit(
|
||||||
self.db,
|
self.db,
|
||||||
actor_id,
|
actor_id,
|
||||||
@@ -93,6 +93,13 @@ class RoleService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return role
|
return role
|
||||||
|
|
||||||
|
def _permissions_by_ids_strict(self, permission_ids: list[int]) -> list[Permission]:
|
||||||
|
unique_ids = list(set(permission_ids))
|
||||||
|
permissions = self.roles.permissions_by_ids(unique_ids)
|
||||||
|
if len(permissions) != len(unique_ids):
|
||||||
|
raise bad_request("error.permissionNotFound", "Permission not found")
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
def _role_snapshot(role: Role) -> dict[str, object]:
|
def _role_snapshot(role: Role) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ class UserService:
|
|||||||
department=payload.department,
|
department=payload.department,
|
||||||
password_hash=hash_password(payload.password),
|
password_hash=hash_password(payload.password),
|
||||||
approval_status="approved",
|
approval_status="approved",
|
||||||
|
approved_by=actor.id,
|
||||||
|
approved_at=utc_now(),
|
||||||
is_active=True,
|
is_active=True,
|
||||||
roles=self._roles_or_default(payload.role_ids),
|
roles=self._roles_or_default(payload.role_ids),
|
||||||
)
|
)
|
||||||
@@ -82,7 +84,7 @@ class UserService:
|
|||||||
|
|
||||||
def update_user(self, actor: User, user_id: int, payload: UserUpdateRequest) -> User:
|
def update_user(self, actor: User, user_id: int, payload: UserUpdateRequest) -> User:
|
||||||
user = self.get_user(user_id)
|
user = self.get_user(user_id)
|
||||||
before = _user_profile_snapshot(user)
|
before = user_profile_snapshot(user)
|
||||||
user.full_name = payload.full_name
|
user.full_name = payload.full_name
|
||||||
user.phone = payload.phone
|
user.phone = payload.phone
|
||||||
user.email = payload.email
|
user.email = payload.email
|
||||||
@@ -95,7 +97,7 @@ class UserService:
|
|||||||
"user",
|
"user",
|
||||||
str(user.id),
|
str(user.id),
|
||||||
user.username,
|
user.username,
|
||||||
audit_detail(user.username, audit_changes(before, _user_profile_snapshot(user))),
|
audit_detail(user.username, audit_changes(before, user_profile_snapshot(user))),
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
return user
|
return user
|
||||||
@@ -209,7 +211,7 @@ class UserService:
|
|||||||
|
|
||||||
def assign_roles(self, actor: User, user_id: int, payload: AssignRolesRequest) -> User:
|
def assign_roles(self, actor: User, user_id: int, payload: AssignRolesRequest) -> User:
|
||||||
user = self.get_user(user_id)
|
user = self.get_user(user_id)
|
||||||
roles = self.roles.by_ids(payload.role_ids)
|
roles = self._roles_by_ids_strict(payload.role_ids)
|
||||||
self._guard_last_admin_role_change(user, roles)
|
self._guard_last_admin_role_change(user, roles)
|
||||||
self._guard_self_admin_role(actor, user, roles)
|
self._guard_self_admin_role(actor, user, roles)
|
||||||
before = {"roles": _role_codes(user.roles)}
|
before = {"roles": _role_codes(user.roles)}
|
||||||
@@ -231,12 +233,18 @@ class UserService:
|
|||||||
raise forbidden("error.lastAdminRequired", "The last administrator cannot be changed")
|
raise forbidden("error.lastAdminRequired", "The last administrator cannot be changed")
|
||||||
|
|
||||||
def _roles_or_default(self, role_ids: list[int] | None) -> list[Role]:
|
def _roles_or_default(self, role_ids: list[int] | None) -> list[Role]:
|
||||||
roles = self.roles.by_ids(role_ids or [])
|
if role_ids:
|
||||||
if roles:
|
return self._roles_by_ids_strict(role_ids)
|
||||||
return roles
|
|
||||||
default_role = self.roles.get_by_code(USER_ROLE)
|
default_role = self.roles.get_by_code(USER_ROLE)
|
||||||
return [default_role] if default_role else []
|
return [default_role] if default_role else []
|
||||||
|
|
||||||
|
def _roles_by_ids_strict(self, role_ids: list[int]) -> list[Role]:
|
||||||
|
unique_ids = list(set(role_ids))
|
||||||
|
roles = self.roles.by_ids(unique_ids)
|
||||||
|
if len(roles) != len(unique_ids):
|
||||||
|
raise bad_request("error.roleNotFound", "Role not found")
|
||||||
|
return roles
|
||||||
|
|
||||||
def _guard_last_admin_role_change(self, target: User, roles: list[Role]) -> None:
|
def _guard_last_admin_role_change(self, target: User, roles: list[Role]) -> None:
|
||||||
if not any(role.code == ADMIN_ROLE for role in target.roles) or self.users.count_admins() > 1:
|
if not any(role.code == ADMIN_ROLE for role in target.roles) or self.users.count_admins() > 1:
|
||||||
return
|
return
|
||||||
@@ -254,7 +262,7 @@ def _role_codes(roles: list[Role]) -> list[str]:
|
|||||||
return sorted(role.code for role in roles)
|
return sorted(role.code for role in roles)
|
||||||
|
|
||||||
|
|
||||||
def _user_profile_snapshot(user: User) -> dict[str, str]:
|
def user_profile_snapshot(user: User) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"full_name": user.full_name,
|
"full_name": user.full_name,
|
||||||
"phone": user.phone,
|
"phone": user.phone,
|
||||||
@@ -267,7 +275,7 @@ def _user_profile_snapshot(user: User) -> dict[str, str]:
|
|||||||
def _user_snapshot(user: User) -> dict[str, object]:
|
def _user_snapshot(user: User) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
**_user_profile_snapshot(user),
|
**user_profile_snapshot(user),
|
||||||
"approval_status": user.approval_status,
|
"approval_status": user.approval_status,
|
||||||
"is_active": user.is_active,
|
"is_active": user.is_active,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,6 +359,11 @@ def test_module_lifecycle_hooks_run_for_install_upgrade_and_disable(tmp_path, mo
|
|||||||
db_init.sync_module_states(engine)
|
db_init.sync_module_states(engine)
|
||||||
db_init.sync_module_states(engine)
|
db_init.sync_module_states(engine)
|
||||||
|
|
||||||
|
monkeypatch.setattr(db_init, "get_app_modules", lambda: (module_v2,))
|
||||||
|
db_init.sync_module_states(engine)
|
||||||
|
monkeypatch.setattr(db_init, "get_app_modules", lambda: ())
|
||||||
|
db_init.sync_module_states(engine)
|
||||||
|
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
events = [row[0] for row in conn.execute(text("SELECT event FROM lifecycle_events ORDER BY rowid")).all()]
|
events = [row[0] for row in conn.execute(text("SELECT event FROM lifecycle_events ORDER BY rowid")).all()]
|
||||||
state = conn.execute(text("SELECT version, status FROM module_states WHERE `key` = 'hooked'")).one()
|
state = conn.execute(text("SELECT version, status FROM module_states WHERE `key` = 'hooked'")).one()
|
||||||
@@ -373,7 +378,7 @@ def test_module_lifecycle_hooks_run_for_install_upgrade_and_disable(tmp_path, mo
|
|||||||
assert events == ["install", "upgrade", "disable"]
|
assert events == ["install", "upgrade", "disable"]
|
||||||
assert state == ("1.1.0", "disabled")
|
assert state == ("1.1.0", "disabled")
|
||||||
assert records == [
|
assert records == [
|
||||||
"hook:hooked:disable:counter",
|
"hook:hooked:disable:1.1.0:counter",
|
||||||
"hook:hooked:install:1.0.0:counter",
|
"hook:hooked:install:1.0.0:counter",
|
||||||
"hook:hooked:upgrade:1.1.0:counter",
|
"hook:hooked:upgrade:1.1.0:counter",
|
||||||
]
|
]
|
||||||
@@ -883,6 +888,80 @@ def test_register_without_approval_can_login(tmp_path, monkeypatch):
|
|||||||
assert client.get("/api/dashboard/summary", headers=user_headers).status_code == 200
|
assert client.get("/api/dashboard/summary", headers=user_headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_role_permission_assignment_rejects_unknown_ids(tmp_path, monkeypatch):
|
||||||
|
client = create_client(tmp_path, monkeypatch)
|
||||||
|
payload = install_sqlite(client, tmp_path)
|
||||||
|
admin_headers = login(client, payload["admin_username"], payload["admin_password"])
|
||||||
|
|
||||||
|
invalid_create = client.post(
|
||||||
|
"/api/users",
|
||||||
|
json={
|
||||||
|
"username": "managed01",
|
||||||
|
"password": "UserPass123",
|
||||||
|
"full_name": "受管用户",
|
||||||
|
"phone": "13800000006",
|
||||||
|
"email": "managed01@example.com",
|
||||||
|
"company": "公司",
|
||||||
|
"department": "部门",
|
||||||
|
"role_ids": [99999],
|
||||||
|
},
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert invalid_create.status_code == 400
|
||||||
|
assert invalid_create.json()["detail"]["code"] == "error.roleNotFound"
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
"/api/users",
|
||||||
|
json={
|
||||||
|
"username": "managed01",
|
||||||
|
"password": "UserPass123",
|
||||||
|
"full_name": "受管用户",
|
||||||
|
"phone": "13800000006",
|
||||||
|
"email": "managed01@example.com",
|
||||||
|
"company": "公司",
|
||||||
|
"department": "部门",
|
||||||
|
"role_ids": [],
|
||||||
|
},
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert created.status_code == 200
|
||||||
|
user_id = created.json()["id"]
|
||||||
|
|
||||||
|
from app.core.install import load_install_config
|
||||||
|
|
||||||
|
engine = create_engine(load_install_config().database_url)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
approved = conn.execute(
|
||||||
|
text("SELECT approved_by, approved_at FROM users WHERE id = :id"), {"id": user_id}
|
||||||
|
).one()
|
||||||
|
engine.dispose()
|
||||||
|
assert approved[0] is not None
|
||||||
|
assert approved[1] is not None
|
||||||
|
|
||||||
|
invalid_assign = client.put(f"/api/users/{user_id}/roles", json={"role_ids": [1, 99999]}, headers=admin_headers)
|
||||||
|
assert invalid_assign.status_code == 400
|
||||||
|
assert invalid_assign.json()["detail"]["code"] == "error.roleNotFound"
|
||||||
|
|
||||||
|
role = client.post("/api/roles", json={"code": "ops", "name": "运维", "description": ""}, headers=admin_headers)
|
||||||
|
assert role.status_code == 200
|
||||||
|
invalid_permissions = client.put(
|
||||||
|
f"/api/roles/{role.json()['id']}/permissions", json={"permission_ids": [99999]}, headers=admin_headers
|
||||||
|
)
|
||||||
|
assert invalid_permissions.status_code == 400
|
||||||
|
assert invalid_permissions.json()["detail"]["code"] == "error.permissionNotFound"
|
||||||
|
|
||||||
|
|
||||||
|
def test_forged_token_subject_is_rejected_as_unauthorized(tmp_path, monkeypatch):
|
||||||
|
client = create_client(tmp_path, monkeypatch)
|
||||||
|
install_sqlite(client, tmp_path)
|
||||||
|
|
||||||
|
from app.core.security import create_access_token
|
||||||
|
|
||||||
|
forged = create_access_token("not-a-number")
|
||||||
|
response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {forged}"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_profile_and_password_change(tmp_path, monkeypatch):
|
def test_profile_and_password_change(tmp_path, monkeypatch):
|
||||||
client = create_client(tmp_path, monkeypatch)
|
client = create_client(tmp_path, monkeypatch)
|
||||||
payload = install_sqlite(client, tmp_path)
|
payload = install_sqlite(client, tmp_path)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const appConfig = readOptionalJson(path.join(projectDir, "app.config.json"));
|
|||||||
const moduleEntries = new Map();
|
const moduleEntries = new Map();
|
||||||
|
|
||||||
assert(requiredLocales.length > 0, "No base locale files found");
|
assert(requiredLocales.length > 0, "No base locale files found");
|
||||||
|
assertSameJsonKeys(baseMessages, "i18n/locales");
|
||||||
assertModuleFilters(appConfig, moduleNames);
|
assertModuleFilters(appConfig, moduleNames);
|
||||||
|
|
||||||
for (const moduleName of moduleNames) {
|
for (const moduleName of moduleNames) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const FIELD_LABEL_KEYS: Record<string, I18nKey> = {
|
|||||||
description: "field.description",
|
description: "field.description",
|
||||||
reason: "field.reason",
|
reason: "field.reason",
|
||||||
role_ids: "field.roles",
|
role_ids: "field.roles",
|
||||||
permission_ids: "field.roles",
|
permission_ids: "field.permissions",
|
||||||
title: "field.title",
|
title: "field.title",
|
||||||
content: "field.content",
|
content: "field.content",
|
||||||
target_type: "field.targetType",
|
target_type: "field.targetType",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
<n-modal
|
<n-modal
|
||||||
v-model:show="showAnnouncementModal"
|
v-model:show="showAnnouncementModal"
|
||||||
preset="card"
|
preset="card"
|
||||||
class="modal-card announcement-popup-modal"
|
class="modal-card"
|
||||||
:title="popupAnnouncement?.title || t('dashboard.announcements')"
|
:title="popupAnnouncement?.title || t('dashboard.announcements')"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
@@ -107,7 +107,7 @@ const collapsed = ref(localStorage.getItem(SIDEBAR_KEY) === "1");
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const menuItems = computed(() => getVisibleMenuItems((code) => authStore.has(code), isFeatureEnabled));
|
const menuItems = computed(() => getVisibleMenuItems((code) => authStore.has(code), (feature) => settingsStore.featureEnabled(feature)));
|
||||||
const menuOptions = computed<MenuOption[]>(() => toMenuOptions(menuItems.value));
|
const menuOptions = computed<MenuOption[]>(() => toMenuOptions(menuItems.value));
|
||||||
const activeMenu = computed(() => (hasMenuPath(menuItems.value, route.path) ? route.path : null));
|
const activeMenu = computed(() => (hasMenuPath(menuItems.value, route.path) ? route.path : null));
|
||||||
const expandedKeys = ref<string[]>([]);
|
const expandedKeys = ref<string[]>([]);
|
||||||
@@ -160,10 +160,6 @@ function renderIcon(icon: Component) {
|
|||||||
return () => h(NIcon, null, { default: () => h(icon) });
|
return () => h(NIcon, null, { default: () => h(icon) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFeatureEnabled(feature?: string) {
|
|
||||||
return feature !== "api" || settingsStore.apiEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMenuOptions(items: AppMenuItem[]) {
|
function toMenuOptions(items: AppMenuItem[]) {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const option: MenuOption = {
|
const option: MenuOption = {
|
||||||
@@ -192,10 +188,10 @@ async function closeTickerAnnouncement(item: { id: number }) {
|
|||||||
|
|
||||||
async function acknowledgePopup() {
|
async function acknowledgePopup() {
|
||||||
const item = popupAnnouncement.value;
|
const item = popupAnnouncement.value;
|
||||||
showAnnouncementModal.value = false;
|
|
||||||
if (item) {
|
if (item) {
|
||||||
await markAnnouncementRead(item);
|
await markAnnouncementRead(item);
|
||||||
}
|
}
|
||||||
|
showAnnouncementModal.value = Boolean(popupAnnouncement.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAnnouncementRead(item: { id: number }) {
|
async function markAnnouncementRead(item: { id: number }) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const rawName = __APP_CONFIG__.appName?.trim();
|
const rawName = __APP_CONFIG__.appName?.trim();
|
||||||
|
|
||||||
export const APP_NAME = rawName || "App";
|
export const APP_NAME = rawName || "App";
|
||||||
export const APP_SLUG = normalizeSlug(__APP_CONFIG__.appSlug) || normalizeSlug(APP_NAME) || "app";
|
const APP_SLUG = normalizeSlug(__APP_CONFIG__.appSlug) || normalizeSlug(APP_NAME) || "app";
|
||||||
export const DEFAULT_DATABASE_NAME = APP_SLUG;
|
export const DEFAULT_DATABASE_NAME = APP_SLUG;
|
||||||
export const DEFAULT_SQLITE_PATH = `runtime/${APP_SLUG}.db`;
|
export const DEFAULT_SQLITE_PATH = `runtime/${APP_SLUG}.db`;
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ export function appKey(name: string) {
|
|||||||
return `${APP_SLUG}.${name}`;
|
return `${APP_SLUG}.${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LOCALE_STORAGE_KEY = appKey("locale");
|
||||||
|
|
||||||
function normalizeSlug(value: string | undefined) {
|
function normalizeSlug(value: string | undefined) {
|
||||||
return value?.trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "";
|
return value?.trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const DEPRECATED_PERMISSION_CODES = new Set(["route:approvals", "action:announcement:operate"]);
|
const DEPRECATED_PERMISSION_CODES = new Set(["route:approvals", "action:announcement:operate"]);
|
||||||
|
|
||||||
export function isActivePermissionCode(code: string) {
|
export function isActivePermissionCode(code: string) {
|
||||||
return !DEPRECATED_PERMISSION_CODES.has(code);
|
return !DEPRECATED_PERMISSION_CODES.has(code);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, reactive } from "vue";
|
import { computed, reactive } from "vue";
|
||||||
import { createI18n } from "vue-i18n";
|
import { createI18n } from "vue-i18n";
|
||||||
|
|
||||||
import { appKey } from "../config/app";
|
import { LOCALE_STORAGE_KEY } from "../config/app";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCALE,
|
DEFAULT_LOCALE,
|
||||||
defaultMessages,
|
defaultMessages,
|
||||||
@@ -18,8 +18,6 @@ import {
|
|||||||
export { DEFAULT_LOCALE, isLocale, locales };
|
export { DEFAULT_LOCALE, isLocale, locales };
|
||||||
export type { I18nKey, Locale, TranslateParams };
|
export type { I18nKey, Locale, TranslateParams };
|
||||||
|
|
||||||
const LOCALE_KEY = appKey("locale");
|
|
||||||
|
|
||||||
export const i18n = createI18n({
|
export const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: initialLocale(),
|
locale: initialLocale(),
|
||||||
@@ -81,8 +79,8 @@ export function formatDateTime(value: string | number | Date) {
|
|||||||
return new Date(value).toLocaleString(currentLocale());
|
return new Date(value).toLocaleString(currentLocale());
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialLocale(): Locale {
|
export function initialLocale(): Locale {
|
||||||
const saved = localStorage.getItem(LOCALE_KEY);
|
const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
return isLocale(saved) ? saved : DEFAULT_LOCALE;
|
return isLocale(saved) ? saved : DEFAULT_LOCALE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"departmentOrPosition": "Department/Position",
|
"departmentOrPosition": "Department/Position",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
|
"permissions": "Permissions",
|
||||||
"reason": "Reason",
|
"reason": "Reason",
|
||||||
"roleCode": "Role code",
|
"roleCode": "Role code",
|
||||||
"roleName": "Role name",
|
"roleName": "Role name",
|
||||||
@@ -148,6 +149,7 @@
|
|||||||
"lastAdminRoleRequired": "The last administrator role cannot be removed",
|
"lastAdminRoleRequired": "The last administrator role cannot be removed",
|
||||||
"selfAdminRoleRequired": "You cannot remove your own administrator role",
|
"selfAdminRoleRequired": "You cannot remove your own administrator role",
|
||||||
"roleNotFound": "Role not found",
|
"roleNotFound": "Role not found",
|
||||||
|
"permissionNotFound": "Permission not found",
|
||||||
"roleCodeExists": "Role code already exists",
|
"roleCodeExists": "Role code already exists",
|
||||||
"builtinRoleCannotDelete": "Built-in roles cannot be deleted",
|
"builtinRoleCannotDelete": "Built-in roles cannot be deleted",
|
||||||
"databaseConnectionFailed": "Database connection failed. Check the connection config",
|
"databaseConnectionFailed": "Database connection failed. Check the connection config",
|
||||||
@@ -376,7 +378,8 @@
|
|||||||
},
|
},
|
||||||
"api_token": {
|
"api_token": {
|
||||||
"create": "Created token",
|
"create": "Created token",
|
||||||
"delete": "Deleted token"
|
"delete": "Deleted token",
|
||||||
|
"reveal": "Revealed token secret"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"target": {
|
"target": {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"departmentOrPosition": "部门/职位",
|
"departmentOrPosition": "部门/职位",
|
||||||
"role": "角色",
|
"role": "角色",
|
||||||
"roles": "角色",
|
"roles": "角色",
|
||||||
|
"permissions": "权限",
|
||||||
"reason": "驳回原因",
|
"reason": "驳回原因",
|
||||||
"roleCode": "角色编码",
|
"roleCode": "角色编码",
|
||||||
"roleName": "角色名称",
|
"roleName": "角色名称",
|
||||||
@@ -148,6 +149,7 @@
|
|||||||
"lastAdminRoleRequired": "不能移除最后一个管理员的管理员角色",
|
"lastAdminRoleRequired": "不能移除最后一个管理员的管理员角色",
|
||||||
"selfAdminRoleRequired": "不能移除自己的管理员角色",
|
"selfAdminRoleRequired": "不能移除自己的管理员角色",
|
||||||
"roleNotFound": "角色不存在",
|
"roleNotFound": "角色不存在",
|
||||||
|
"permissionNotFound": "权限不存在",
|
||||||
"roleCodeExists": "角色编码已存在",
|
"roleCodeExists": "角色编码已存在",
|
||||||
"builtinRoleCannotDelete": "内置角色不能删除",
|
"builtinRoleCannotDelete": "内置角色不能删除",
|
||||||
"databaseConnectionFailed": "数据库连接失败,请检查连接配置",
|
"databaseConnectionFailed": "数据库连接失败,请检查连接配置",
|
||||||
@@ -376,7 +378,8 @@
|
|||||||
},
|
},
|
||||||
"api_token": {
|
"api_token": {
|
||||||
"create": "创建 Token",
|
"create": "创建 Token",
|
||||||
"delete": "删除 Token"
|
"delete": "删除 Token",
|
||||||
|
"reveal": "显示完整 Token"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"target": {
|
"target": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import zhCN from "./locales/zh-CN.json";
|
import zhCN from "./locales/zh-CN.json";
|
||||||
|
|
||||||
export type Locale = string;
|
export type Locale = string;
|
||||||
export type TranslateParam = string | number | boolean | null | undefined;
|
type TranslateParam = string | number | boolean | null | undefined;
|
||||||
export type TranslateParams = Record<string, TranslateParam>;
|
export type TranslateParams = Record<string, TranslateParam>;
|
||||||
export type MessageTree = { [key: string]: string | MessageTree };
|
export type MessageTree = { [key: string]: string | MessageTree };
|
||||||
export type LocaleMessages = MessageTree & { language: string };
|
export type LocaleMessages = MessageTree & { language: string };
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="work-card table-page-card">
|
<section class="work-card table-page-card">
|
||||||
<div class="toolbar demo-crud-toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('demoCrud.searchPlaceholder')" clearable />
|
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('demoCrud.searchPlaceholder')" clearable />
|
||||||
<n-input v-model:value="filters.category" class="demo-crud-category-filter" :placeholder="t('demoCrud.categoryPlaceholder')" clearable />
|
<n-input v-model:value="filters.category" :placeholder="t('demoCrud.categoryPlaceholder')" clearable />
|
||||||
<n-button @click="searchItems">{{ t("common.search") }}</n-button>
|
<n-button @click="searchItems">{{ t("common.search") }}</n-button>
|
||||||
</div>
|
</div>
|
||||||
<permission-button :permission="DEMO_ITEM_CREATE" type="primary" @click="openCreate">{{ t("demoCrud.add") }}</permission-button>
|
<permission-button :permission="DEMO_ITEM_CREATE" type="primary" @click="openCreate">{{ t("demoCrud.add") }}</permission-button>
|
||||||
|
|||||||
+6
-11
@@ -3,14 +3,12 @@ import { createRouter, createWebHistory } from "vue-router";
|
|||||||
import { AUTH_EXPIRED_EVENT } from "../api/client";
|
import { AUTH_EXPIRED_EVENT } from "../api/client";
|
||||||
import { getInstallStatus } from "../api/install";
|
import { getInstallStatus } from "../api/install";
|
||||||
import { getMe } from "../api/auth";
|
import { getMe } from "../api/auth";
|
||||||
import { appKey } from "../config/app";
|
import { LOCALE_STORAGE_KEY } from "../config/app";
|
||||||
import { appStore } from "../stores/app";
|
import { appStore } from "../stores/app";
|
||||||
import { authStore } from "../stores/auth";
|
import { authStore } from "../stores/auth";
|
||||||
import { settingsStore } from "../stores/settings";
|
import { settingsStore } from "../stores/settings";
|
||||||
import { createAppPageRoutes, getFallbackPath } from "./page-registry";
|
import { createAppPageRoutes, getFallbackPath } from "./page-registry";
|
||||||
|
|
||||||
const LOCALE_KEY = appKey("locale");
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/install", component: () => import("../views/InstallView.vue"), meta: { public: true } },
|
{ path: "/install", component: () => import("../views/InstallView.vue"), meta: { public: true } },
|
||||||
{ path: "/login", component: () => import("../views/LoginView.vue"), meta: { public: true } },
|
{ path: "/login", component: () => import("../views/LoginView.vue"), meta: { public: true } },
|
||||||
@@ -51,7 +49,7 @@ router.beforeEach(async (to) => {
|
|||||||
if (status?.installed && !settingsStore.loaded) {
|
if (status?.installed && !settingsStore.loaded) {
|
||||||
try {
|
try {
|
||||||
const publicSettings = await settingsStore.loadPublic();
|
const publicSettings = await settingsStore.loadPublic();
|
||||||
if (!localStorage.getItem(LOCALE_KEY)) {
|
if (!localStorage.getItem(LOCALE_STORAGE_KEY)) {
|
||||||
await appStore.setLocale(publicSettings.default_locale);
|
await appStore.setLocale(publicSettings.default_locale);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -77,12 +75,13 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
const permission = to.meta.permission as string | undefined;
|
const permission = to.meta.permission as string | undefined;
|
||||||
const feature = to.meta.feature as string | undefined;
|
const feature = to.meta.feature as string | undefined;
|
||||||
if (feature && !isFeatureEnabled(feature)) {
|
const featureEnabled = (value?: string) => settingsStore.featureEnabled(value);
|
||||||
const path = getFallbackPath((code) => authStore.has(code), isFeatureEnabled);
|
if (feature && !featureEnabled(feature)) {
|
||||||
|
const path = getFallbackPath((code) => authStore.has(code), featureEnabled);
|
||||||
return to.path === path ? true : path;
|
return to.path === path ? true : path;
|
||||||
}
|
}
|
||||||
if (permission && !authStore.has(permission)) {
|
if (permission && !authStore.has(permission)) {
|
||||||
const path = getFallbackPath((code) => authStore.has(code), isFeatureEnabled);
|
const path = getFallbackPath((code) => authStore.has(code), featureEnabled);
|
||||||
return to.path === path ? true : path;
|
return to.path === path ? true : path;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -95,7 +94,3 @@ async function loadInstallStatus() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFeatureEnabled(feature?: string) {
|
|
||||||
return feature !== "api" || settingsStore.apiEnabled();
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-13
@@ -1,12 +1,10 @@
|
|||||||
import { reactive } from "vue";
|
import { reactive } from "vue";
|
||||||
|
|
||||||
import { appKey } from "../config/app";
|
import { appKey, LOCALE_STORAGE_KEY } from "../config/app";
|
||||||
import { setI18nLocale } from "../i18n";
|
import { initialLocale, setI18nLocale } from "../i18n";
|
||||||
import { DEFAULT_LOCALE, isLocale, type Locale } from "../i18n/messages";
|
import { DEFAULT_LOCALE, isLocale, type Locale } from "../i18n/messages";
|
||||||
import { settingsStore } from "./settings";
|
|
||||||
|
|
||||||
const THEME_KEY = appKey("dark");
|
const THEME_KEY = appKey("dark");
|
||||||
const LOCALE_KEY = appKey("locale");
|
|
||||||
|
|
||||||
export const appStore = reactive({
|
export const appStore = reactive({
|
||||||
dark: localStorage.getItem(THEME_KEY) === "1",
|
dark: localStorage.getItem(THEME_KEY) === "1",
|
||||||
@@ -20,7 +18,7 @@ export const appStore = reactive({
|
|||||||
const nextLocale = isLocale(locale) ? locale : DEFAULT_LOCALE;
|
const nextLocale = isLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
await setI18nLocale(nextLocale);
|
await setI18nLocale(nextLocale);
|
||||||
appStore.locale = nextLocale;
|
appStore.locale = nextLocale;
|
||||||
localStorage.setItem(LOCALE_KEY, appStore.locale);
|
localStorage.setItem(LOCALE_STORAGE_KEY, appStore.locale);
|
||||||
document.documentElement.lang = appStore.locale;
|
document.documentElement.lang = appStore.locale;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -31,11 +29,3 @@ document.documentElement.lang = appStore.locale;
|
|||||||
function applyTheme(dark: boolean) {
|
function applyTheme(dark: boolean) {
|
||||||
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialLocale(): Locale {
|
|
||||||
const saved = localStorage.getItem(LOCALE_KEY);
|
|
||||||
if (isLocale(saved)) {
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
return settingsStore.defaultLocale();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,5 +42,8 @@ export const settingsStore = reactive({
|
|||||||
},
|
},
|
||||||
apiTokenRevealEnabled() {
|
apiTokenRevealEnabled() {
|
||||||
return this.publicSettings.api_token_reveal_enabled;
|
return this.publicSettings.api_token_reveal_enabled;
|
||||||
|
},
|
||||||
|
featureEnabled(feature?: string) {
|
||||||
|
return feature !== "api" || this.apiEnabled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1054,13 +1054,6 @@ a {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-placeholder {
|
|
||||||
min-height: 0;
|
|
||||||
flex: 1;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.announcement-ticker {
|
.announcement-ticker {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1353,7 +1346,6 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-keyword,
|
.filter-keyword,
|
||||||
.filter-select,
|
|
||||||
.filter-date-range {
|
.filter-date-range {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -1980,21 +1972,6 @@ a {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-response-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-response-list span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 5px 8px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-detail-modal {
|
.api-detail-modal {
|
||||||
width: min(980px, calc(100vw - 32px));
|
width: min(980px, calc(100vw - 32px));
|
||||||
max-height: min(86vh, 820px);
|
max-height: min(86vh, 820px);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export async function copyText(value: string) {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
input.value = value;
|
||||||
|
document.body.append(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
input.remove();
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="work-card table-page-card announcement-manage-card">
|
<section class="work-card table-page-card">
|
||||||
<div class="toolbar announcement-toolbar">
|
<div class="toolbar announcement-toolbar">
|
||||||
<div class="announcement-filter-row">
|
<div class="announcement-filter-row">
|
||||||
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('announcement.searchPlaceholder')" clearable />
|
<n-input v-model:value="filters.keyword" class="filter-keyword" :placeholder="t('announcement.searchPlaceholder')" clearable />
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ import {
|
|||||||
} from "../api/openapi";
|
} from "../api/openapi";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { openApiText } from "../i18n/openapi";
|
import { openApiText } from "../i18n/openapi";
|
||||||
|
import { copyText } from "../utils/clipboard";
|
||||||
import { saveBlob } from "../utils/download";
|
import { saveBlob } from "../utils/download";
|
||||||
import { showError } from "../utils/message";
|
import { showError } from "../utils/message";
|
||||||
import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table";
|
import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table";
|
||||||
@@ -384,7 +385,7 @@ async function loadDocument() {
|
|||||||
|
|
||||||
async function copyDocument() {
|
async function copyDocument() {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(documentText.value);
|
await copyText(documentText.value);
|
||||||
message.success(t("common.copied"));
|
message.success(t("common.copied"));
|
||||||
} catch {
|
} catch {
|
||||||
message.error(t("message.operationFailed"));
|
message.error(t("message.operationFailed"));
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<n-modal v-model:show="showDetailModal" preset="card" class="modal-card announcement-popup-modal" :title="selectedAnnouncement?.title || t('dashboard.announcements')">
|
<n-modal v-model:show="showDetailModal" preset="card" class="modal-card" :title="selectedAnnouncement?.title || t('dashboard.announcements')">
|
||||||
<div class="announcement-popup-content">{{ selectedAnnouncement?.content }}</div>
|
<div class="announcement-popup-content">{{ selectedAnnouncement?.content }}</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<n-button type="primary" @click="showDetailModal = false">{{ t("common.close") }}</n-button>
|
<n-button type="primary" @click="showDetailModal = false">{{ t("common.close") }}</n-button>
|
||||||
|
|||||||
@@ -189,7 +189,12 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
[roles.value, permissions.value] = await Promise.all([listRoles(), listPermissions()]);
|
try {
|
||||||
|
[roles.value, permissions.value] = await Promise.all([listRoles(), listPermissions()]);
|
||||||
|
} catch (error) {
|
||||||
|
showError(message, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
permissions.value = permissions.value.filter((permission) => isActivePermissionCode(permission.code));
|
permissions.value = permissions.value.filter((permission) => isActivePermissionCode(permission.code));
|
||||||
roles.value = roles.value.map((role) => ({
|
roles.value = roles.value.map((role) => ({
|
||||||
...role,
|
...role,
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ import { backupData, getSystemSettings, updateSystemSettings } from "../api/sett
|
|||||||
import type { SystemSettings } from "../api/types";
|
import type { SystemSettings } from "../api/types";
|
||||||
import PermissionButton from "../components/PermissionButton.vue";
|
import PermissionButton from "../components/PermissionButton.vue";
|
||||||
import { ensureLocaleNames, localeOptions, t } from "../i18n";
|
import { ensureLocaleNames, localeOptions, t } from "../i18n";
|
||||||
import { appStore } from "../stores/app";
|
|
||||||
import { settingsStore } from "../stores/settings";
|
import { settingsStore } from "../stores/settings";
|
||||||
import { saveBlob } from "../utils/download";
|
import { saveBlob } from "../utils/download";
|
||||||
import { showError } from "../utils/message";
|
import { showError } from "../utils/message";
|
||||||
@@ -175,7 +174,6 @@ async function saveSettings() {
|
|||||||
const updated = await updateSystemSettings(form);
|
const updated = await updateSystemSettings(form);
|
||||||
assignSettings(updated);
|
assignSettings(updated);
|
||||||
settingsStore.setPublic(updated);
|
settingsStore.setPublic(updated);
|
||||||
await appStore.setLocale(updated.default_locale);
|
|
||||||
message.success(t("settings.saved"));
|
message.success(t("settings.saved"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(message, error);
|
showError(message, error);
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ import StatusTag from "../components/StatusTag.vue";
|
|||||||
import { formatDateTime, t } from "../i18n";
|
import { formatDateTime, t } from "../i18n";
|
||||||
import { authStore } from "../stores/auth";
|
import { authStore } from "../stores/auth";
|
||||||
import { settingsStore } from "../stores/settings";
|
import { settingsStore } from "../stores/settings";
|
||||||
|
import { copyText } from "../utils/clipboard";
|
||||||
import { messageText, showError } from "../utils/message";
|
import { messageText, showError } from "../utils/message";
|
||||||
import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table";
|
import { sumColumnWidths, updateColumnWidth, withResizableColumns } from "../utils/table";
|
||||||
import { maxLengthRule, requiredRule, validateForm } from "../utils/validation";
|
import { maxLengthRule, requiredRule, validateForm } from "../utils/validation";
|
||||||
@@ -331,19 +332,6 @@ async function copyRevealedToken() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText(value: string) {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const input = document.createElement("textarea");
|
|
||||||
input.value = value;
|
|
||||||
document.body.append(input);
|
|
||||||
input.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
input.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExpiresAt(value: string | null) {
|
function formatExpiresAt(value: string | null) {
|
||||||
return value ? formatDateTime(value) : t("token.neverExpires");
|
return value ? formatDateTime(value) : t("token.neverExpires");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user