From 5f066d2f6b615dd57355afa2e4db00293eda9ebe Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 21 May 2026 19:40:12 +0300 Subject: [PATCH] Show effective access roles in HTML5 workspace --- .../api-server/src/api_server/html5_access.py | 53 +++++++++++++++++-- .../src/api_server/html5_access_controller.py | 10 ++++ services/api-server/src/api_server/main.py | 12 +++++ .../src/api_server/static/html5/html5.css | 1 + services/api-server/tests/test_api.py | 3 ++ 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/services/api-server/src/api_server/html5_access.py b/services/api-server/src/api_server/html5_access.py index cdfb043..c7c0f09 100644 --- a/services/api-server/src/api_server/html5_access.py +++ b/services/api-server/src/api_server/html5_access.py @@ -70,7 +70,8 @@ def render_html5_access_page(
Группы доступа
{''.join(_group_card(item) for item in groups[:80]) or '

Группы не найдены

'}
Пользователи
-
{''.join(_user_card(item) for item in users[:80]) or '

Пользователи не найдены

'}
+
{render_html5_access_user_detail(project_id=project_id, user_payload=None)}
+
{''.join(_user_card(project_id, item) for item in users[:80]) or '

Пользователи не найдены

'}
@@ -157,6 +158,35 @@ def render_html5_access_profile_apply_result(*, project_id: str, response: objec """ +def render_html5_access_user_detail(*, project_id: str, user_payload: dict | None) -> str: + if user_payload is None: + return """ +
+

Выберите пользователя, чтобы увидеть группы и эффективные роли.

+
+ """ + user = dict(user_payload.get("user") or {}) + roles = list(user_payload.get("effective_roles") or []) + groups = list(user.get("groups") or []) + name = str(user.get("name") or "") + full_name = str(user.get("full_name") or "") + return f""" +
+
+ пользователь + {escape(name)} +
+

{escape(full_name or "ФИО не загружено")}

+
+ {_metric("Группы", len(groups))} + {_metric("Эффективные роли", len(roles))} +
+ {_notice_list("Группы пользователя", groups)} +
{''.join(_role_card(_DictRole(item)) for item in roles) or '

Эффективные роли не найдены

'}
+
+ """ + + def render_html5_access_profile(*, project_id: str, profile: object | None) -> str: if profile is None: return """ @@ -308,11 +338,21 @@ def _group_card(group: object) -> str: return f'
{escape(name)}{escape(profile)} · {len(users)} пользователей
' -def _user_card(user: object) -> str: +def _user_card(project_id: str, user: object) -> str: name = str(getattr(user, "name", "")) full_name = str(getattr(user, "full_name", "") or "") groups = list(getattr(user, "groups", []) or []) - return f'
{escape(name)}{escape(full_name)} · {len(groups)} групп
' + return f""" +
+ {escape(name)} + {escape(full_name)} · {len(groups)} групп +
+ """ def _operation_card(operation: dict) -> str: @@ -359,3 +399,10 @@ class _DictProfile: self.roles = payload.get("roles") or [] self.attributes = payload.get("attributes") or {} self.source = payload.get("source") or "workspace" + + +class _DictRole: + def __init__(self, payload: dict): + self.role = str(payload.get("role") or payload.get("name") or "") + self.role_qualified_name = str(payload.get("role_qualified_name") or payload.get("qualified_name") or self.role) + self.source = str(payload.get("source") or "") diff --git a/services/api-server/src/api_server/html5_access_controller.py b/services/api-server/src/api_server/html5_access_controller.py index 47d4a2c..7d710b2 100644 --- a/services/api-server/src/api_server/html5_access_controller.py +++ b/services/api-server/src/api_server/html5_access_controller.py @@ -11,6 +11,7 @@ from api_server.html5_access import ( render_html5_access_profile_preview, render_html5_access_publish_plan, render_html5_access_publish_result, + render_html5_access_user_detail, ) from api_server.html5_forms import form_value, html5_csv_values @@ -106,6 +107,15 @@ async def html5_access_profile_apply( return render_html5_access_profile_apply_result(project_id=project_id, response=response, plan=plan) +async def html5_access_user_detail( + *, + project_id: str, + user_name: str, + access_user: Callable[[str, str], Awaitable[dict]], +) -> str: + return render_html5_access_user_detail(project_id=project_id, user_payload=await access_user(project_id, user_name)) + + def _selected_profile( normalized: object, profile_name: str | None, diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 5728995..0bd37f6 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -61,6 +61,7 @@ from api_server.html5_access_controller import ( html5_access_profile_preview as _html5_access_profile_preview, html5_access_publish_dry_run as _html5_access_publish_dry_run, html5_access_publish_plan as _html5_access_publish_plan, + html5_access_user_detail as _html5_access_user_detail, ) from api_server.html5_editor_controller import ( html5_editor_page as _html5_editor_page, @@ -1606,6 +1607,17 @@ async def html5_project_access_profile_publish_dry_run(project_id: str, profile_ ) +@app.get("/html5/projects/{project_id}/access/users/{user_name}") +async def html5_project_access_user(project_id: str, user_name: str) -> Response: + return _html5_response( + await _html5_access_user_detail( + project_id=project_id, + user_name=user_name, + access_user=get_project_access_user, + ) + ) + + @app.post("/html5/projects/{project_id}/forms/editor/preview") async def html5_project_form_editor_preview(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) diff --git a/services/api-server/src/api_server/static/html5/html5.css b/services/api-server/src/api_server/static/html5/html5.css index b3cb927..364f56c 100644 --- a/services/api-server/src/api_server/static/html5/html5.css +++ b/services/api-server/src/api_server/static/html5/html5.css @@ -12,6 +12,7 @@ .form-empty-structure{grid-column:1/-1;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.form-empty-structure strong,.form-empty-structure span{display:block}.form-empty-structure strong{color:#1f2937} .access-workspace{background:#f4f7fb}.access-layout{display:grid;grid-template-columns:300px minmax(0,1fr)340px;height:calc(100vh - 88px)}.access-main{border-top:0;border-bottom:0;overflow:auto}.access-nav,.access-side{overflow:auto}.tree-item[data-html5-access-profile-selected="true"]{background:#f8fbff;border-left:3px solid var(--brand);padding-left:9px}.access-empty{margin:16px;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.access-empty strong,.access-empty span{display:block}.access-empty strong{color:#1f2937}.access-profile,.access-plan,.access-result{border-bottom:1px solid var(--line);background:#fff}.access-summary{display:flex;gap:8px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);background:#f8fbff;color:#687385;font-size:12px;font-weight:800}.access-role-grid,.access-operations,.access-list{display:grid}.access-role-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.access-card{display:grid;gap:3px;min-width:0;padding:10px 12px;border-bottom:1px solid var(--line)}.access-role-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}.access-card strong,.access-card small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.access-card small{color:var(--muted)}.access-plan-head{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-plan-head form{margin:0}.access-actions{padding:12px}.access-warnings{margin:0;padding:0;list-style:none}.access-warnings li{padding:8px 12px;border-bottom:1px solid var(--line);color:var(--warn);font-weight:800}.access-json{margin:0;max-height:220px;overflow:auto;padding:12px;background:#fbfaf7;border-top:1px solid var(--line);font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}.access-main .primary{background:var(--brand);border-color:var(--brand);color:#fff} .access-builder{border-bottom:1px solid var(--line);background:#fff}.access-builder-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-builder-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.access-builder-form input,.access-builder-form textarea{min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.access-builder-form input{height:32px}.access-builder-form textarea{min-height:68px;padding:8px;resize:vertical}.access-builder-actions{grid-column:1/-1;display:flex;gap:8px;justify-content:flex-end}.access-builder-result{border-top:1px solid var(--line);background:#fff} +.access-card[hx-get]{cursor:pointer}.access-card[hx-get]:hover{background:#f8fbff}.access-user-detail{border-bottom:1px solid var(--line);background:#fff} @media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}} @media(max-width:980px){.access-layout{grid-template-columns:1fr;height:auto}.access-nav,.access-side{max-height:360px}.access-role-grid{grid-template-columns:1fr}.access-role-grid .access-card:nth-child(odd){border-right:0}} @media(max-width:980px){.access-builder-form{grid-template-columns:1fr}.access-builder-actions{justify-content:stretch}.access-builder-actions button{flex:1}} diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 5a02be6..addd712 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -1643,6 +1643,9 @@ def test_import_supports_structure_only_indexing(monkeypatch, tmp_path: Path): ) assert_html5_response_contract(html5_apply, "сохранено", "HTML5ПрофильHTTP", "CREATE_ACCESS_PROFILE") + html5_user = client.get(f"/html5/projects/{project_id}/access/users/ivanov") + assert_html5_response_contract(html5_user, "пользователь", "ivanov", "Эффективные роли", "Роль.Менеджер") + access_plan = client.get( f"/html5/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/plan" )