From 339833c5531f84204bef41608a625c95c962f29b Mon Sep 17 00:00:00 2001 From: Gabriel Reis <55472396+gabrielsrs@users.noreply.github.com> Date: Mon, 26 May 2025 23:35:09 -0300 Subject: [PATCH 1/7] fix(api): rename decorators --- zoneforge/api/rbac.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/zoneforge/api/rbac.py b/zoneforge/api/rbac.py index 8d09c9c..e2dbd3f 100644 --- a/zoneforge/api/rbac.py +++ b/zoneforge/api/rbac.py @@ -1,7 +1,7 @@ from flask_restx import Namespace, Resource, reqparse from werkzeug.exceptions import * # pylint: disable=wildcard-import,unused-wildcard-import,redefined-builtin -from zoneforge.api import release_access +from zoneforge.api import api_release_access from zoneforge.db import db from zoneforge.db.db_model import Group, Role, User @@ -14,7 +14,7 @@ @api.route("/group") class GroupResource(Resource): - @release_access("group_read") + @api_release_access("group_read") def get(self): group_entities = db.paginate(db.select(Group)) @@ -24,7 +24,7 @@ def get(self): ] }, 200 - @release_access("group_create") + @api_release_access("group_create") def post(self): args = rbac_parser.parse_args() group_name = args.get("name") @@ -46,7 +46,7 @@ def post(self): @api.route("/group/") class SpecificGroupResource(Resource): - @release_access("group_update") + @api_release_access("group_update") def put(self, group_id: int = None): args = rbac_parser.parse_args() group_name = args.get("name") @@ -69,7 +69,7 @@ def put(self, group_id: int = None): return {"message": "Group updated successfully"}, 200 - @release_access("group_delete") + @api_release_access("group_delete") def delete(self, group_id: int = None): group_entity = db.get_or_404(Group, group_id, description="Group id not exist") @@ -81,7 +81,7 @@ def delete(self, group_id: int = None): @api.route("/role") class RoleResource(Resource): - @release_access("role_read") + @api_release_access("role_read") def get(self): role_entities = db.paginate(db.select(Role)) @@ -89,7 +89,7 @@ def get(self): "roles": [{"id": role.id, "role_name": role.name} for role in role_entities] } - @release_access("role_create") + @api_release_access("role_create") def post(self): args = rbac_parser.parse_args() role_name = args.get("name") @@ -111,7 +111,7 @@ def post(self): @api.route("/role/") class SpecificRoleResource(Resource): - @release_access("role_update") + @api_release_access("role_update") def put(self, role_id: int = None): args = rbac_parser.parse_args() role_name = args.get("name") @@ -134,7 +134,7 @@ def put(self, role_id: int = None): return {"message": "Role updated successfully"}, 200 - @release_access("role_delete") + @api_release_access("role_delete") def delete(self, role_id: int = None): role_entity = db.get_or_404(Role, role_id, description="Role id not exist") @@ -146,7 +146,7 @@ def delete(self, role_id: int = None): @api.route("/group//user/") class UserAssignGroupResource(Resource): - @release_access("userAssignGroup_read") + @api_release_access("userAssignGroup_read") def post(self, group_id: int = None, user_id: int = None): db.get_or_404(Group, group_id, description="Group id not exist") user_entity = db.get_or_404(User, user_id, description="User id not exist") @@ -160,7 +160,7 @@ def post(self, group_id: int = None, user_id: int = None): return {"message": "User assign to a group successfully"}, 200 - @release_access("userAssignGroup_update") + @api_release_access("userAssignGroup_update") def put(self, group_id: int = None, user_id: int = None): db.get_or_404(Group, group_id, description="Group id not exist") user_entity = db.get_or_404(User, user_id, description="User id not exist") @@ -177,7 +177,7 @@ def put(self, group_id: int = None, user_id: int = None): return {"message": "User assign to the new group"}, 200 - @release_access("userAssignGroup_delete") + @api_release_access("userAssignGroup_delete") def delete(self, group_id: int = None, user_id: int = None): db.get_or_404(Group, group_id, description="Group id not exist") user_entity = db.get_or_404(User, user_id, description="User id not exist") @@ -197,7 +197,7 @@ def delete(self, group_id: int = None, user_id: int = None): @api.route("/group//role/") class RoleAssignGroupResource(Resource): - @release_access("roleAssignGroup_read") + @api_release_access("roleAssignGroup_read") def post(self, group_id: int = None, role_id: int = None): group_entity = db.get_or_404(Group, group_id, description="Group id not exist") role_entity = db.get_or_404(Role, role_id, description="Role id not exist") @@ -212,7 +212,7 @@ def post(self, group_id: int = None, role_id: int = None): return {"message": "Role assign to group successfully"}, 201 - @release_access("roleAssignGroup_delete") + @api_release_access("roleAssignGroup_delete") def delete(self, group_id: str = None, role_id: str = None): group_entity = db.get_or_404(Group, group_id, description="Group id not exist") role_entity = db.get_or_404(Role, role_id, description="Role id not exist") From e6ba4d1a1fc39280d6e2baca9510cc0a234d3179 Mon Sep 17 00:00:00 2001 From: Gabriel Reis <55472396+gabrielsrs@users.noreply.github.com> Date: Mon, 26 May 2025 23:35:09 -0300 Subject: [PATCH 2/7] feat(app): user authentication --- app.py | 48 +++++++++++++++--- zoneforge/api/__init__.py | 89 ++++++++++++++++++++++++--------- zoneforge/api/authentication.py | 3 ++ 3 files changed, 109 insertions(+), 31 deletions(-) diff --git a/app.py b/app.py index 5cb0a4e..2a9abb5 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,17 @@ import logging import sys import subprocess -from flask import Flask, render_template, request, redirect, url_for, flash, current_app +from flask import ( + Flask, + render_template, + request, + redirect, + url_for, + flash, + current_app, + make_response, + g, +) from flask_restx import Api from werkzeug.middleware.proxy_fix import ProxyFix from flask_minify import minify @@ -18,6 +28,7 @@ from zoneforge.api.authentication import LoginResource, SignupResource from zoneforge.api.rbac import api as ns_rbac from zoneforge.db import db +from zoneforge.api import app_release_access def get_logging_conf() -> dict: @@ -78,10 +89,17 @@ def create_app(): minify(app=app, html=True, js=True, cssless=True, static=True) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + + # Validate current user in template + app.context_processor( + lambda _=None: {"current_user": getattr(g, "current_user", None)} + ) + # API Setup api = Api(app, prefix="/api", doc="/api", validate=True) @app.route("/", methods=["GET"]) + @app_release_access() def home(): zf_zone = DnsZone() try: @@ -104,6 +122,7 @@ def home(): ) @app.route("/zone/", methods=["GET"]) + @app_release_access() def zone(zone_name): zone = get_zones( zonefile_folder=current_app.config["ZONE_FILE_FOLDER"], zone_name=zone_name @@ -140,11 +159,22 @@ def login(): login_response = LoginResource().post() if login_response[1] != 200: - flash(login_response[0]) + flash(login_response[0]["message"], "error") return render_template("login.html.j2") - return redirect(url_for("home")) + response = make_response(redirect(url_for("home"))) + response.set_cookie( + "access_token", + login_response[0]["token"], + httponly=True, + ) + response.set_cookie( + "refresh_token", + login_response[0]["refresh_token"], + httponly=True, + ) + return response return render_template("login.html.j2") @app.route("/signup", methods=["GET", "POST"]) @@ -152,15 +182,21 @@ def signup(): if request.method == "POST": signup_response = SignupResource().post() - flash(signup_response[0]) - if signup_response[1] != 200: - + flash(signup_response[0]["message"], "error") return render_template("signup.html.j2") + flash(signup_response[0]["message"], "success") return redirect(url_for("login")) return render_template("signup.html.j2") + @app.route("/logout", methods=["POST"]) + def logout(): + response = make_response(redirect(url_for("login"))) + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + return response + api.add_namespace(ns_status) api.add_namespace(ns_zone) api.add_namespace(ns_record) diff --git a/zoneforge/api/__init__.py b/zoneforge/api/__init__.py index 720b1e3..0e6ac97 100644 --- a/zoneforge/api/__init__.py +++ b/zoneforge/api/__init__.py @@ -1,5 +1,7 @@ +from functools import wraps + import jwt -from flask import current_app, g +from flask import current_app, flash, g, redirect, url_for from flask_restx import reqparse from werkzeug.exceptions import * # pylint: disable=wildcard-import,unused-wildcard-import,redefined-builtin @@ -19,34 +21,37 @@ ) -# Decorator to validate JWT token and user permission -def release_access(permission: str = None): - def wrapper(func): - def decorated(*args, **kwargs): - try: - g.args = token_parser.parse_args() - token = ( - g.args.get("Authorization").split(" ")[-1] - or g.args.get("access_token") - or None - ) +# Function to validate WT token and user permission +def _verify_jwt_and_permissions(permission: str = None): + args = token_parser.parse_args() + token = args.get("Authorization").split(" ")[-1] or args.get("access_token") or None - user_token_data = jwt.decode( - token, current_app.config["TOKEN_SECRET"], algorithms="HS256" - ) + user_token_data = jwt.decode( + token, current_app.config["TOKEN_SECRET"], algorithms="HS256" + ) - if permission and permission not in user_token_data["roles"]: - raise Forbidden( - "User do not have the required permissions to access this resource" - ) + if permission and permission not in user_token_data["roles"]: + raise Forbidden( + "User do not have the required permissions to access this resource" + ) - current_user = db.get_or_404(User, user_token_data["id"]) + current_user = db.get_or_404(User, user_token_data["id"]) - if not current_user: - raise NotFound("User not found") + if not current_user: + raise NotFound("User not found") + + return user_token_data - return func(*args, **kwargs) +# Decorator to handle API access +def api_release_access(permission: str = None): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + _verify_jwt_and_permissions(permission) + + return func(*args, **kwargs) except jwt.ExpiredSignatureError: return {"message": "Token expired"}, 401 @@ -59,6 +64,40 @@ def decorated(*args, **kwargs): except NotFound as user_not_found: return {"message": user_not_found.description}, 404 - return decorated + return wrapper + + return decorator + + +# Decorator to handle APP access +def app_release_access(permission: str = None): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + current_user = _verify_jwt_and_permissions(permission) + g.current_user = current_user + + return func(*args, **kwargs) + except jwt.ExpiredSignatureError: + flash("Session expired.", "error") + return redirect(url_for("login")) + + except jwt.InvalidTokenError: + flash("Invalid session.", "error") + return redirect(url_for("login")) + + except Forbidden: + flash( + "You don’t have permission to access this page or perform this action.", + "error", + ) + return redirect(url_for("login")) + + except NotFound: + flash("User not found.", "error") + return redirect(url_for("login")) + + return wrapper - return wrapper + return decorator diff --git a/zoneforge/api/authentication.py b/zoneforge/api/authentication.py index 2764b80..59464fc 100644 --- a/zoneforge/api/authentication.py +++ b/zoneforge/api/authentication.py @@ -139,5 +139,8 @@ def post(self): return {"message": "User created successfully"}, 200 + except BadRequest as credential_error: + return {"message": credential_error.description}, 400 + except Conflict as user_exist: return {"message": user_exist.description}, 409 From 9ee3b98e9a8fb55ff95d8e517577558ec339b76c Mon Sep 17 00:00:00 2001 From: Gabriel Reis <55472396+gabrielsrs@users.noreply.github.com> Date: Mon, 26 May 2025 23:35:10 -0300 Subject: [PATCH 3/7] style: add authentication templates --- static/css/authentication.css | 140 ++++++++++++++++++++++++++++++++++ static/css/home.css | 2 +- static/css/nav.css | 91 ++++++++++++++++++++++ static/js/nav.js | 12 +++ templates/layout.html.j2 | 3 + templates/login.html.j2 | 43 +++++++++++ templates/nav.html.j2 | 20 +++++ templates/signup.html.j2 | 42 ++++++++++ 8 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 static/css/authentication.css create mode 100644 static/css/nav.css create mode 100644 static/js/nav.js create mode 100644 templates/nav.html.j2 diff --git a/static/css/authentication.css b/static/css/authentication.css new file mode 100644 index 0000000..a601376 --- /dev/null +++ b/static/css/authentication.css @@ -0,0 +1,140 @@ +.auth-content { + display: flex; + flex-direction: column; + width: 300px; + margin: auto; +} + +.auth-title { + text-align: center; +} + +.auth-title > h1 { + margin-bottom: 0; + font-weight: 400; +} + +.flash-message { + margin: 16px 0; + text-align: center; +} + +.flash-message > span { + font-size: 0.83rem; + font-weight: 400; +} + +.error { + color: #d65454; +} + +.success { + color: #4CAF50; +} + +.auth-content > form { + display: flex; + flex-direction: column; +} + +.credentials { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 105px; + margin-bottom: 20px; +} + +.credentials label { + position: relative; +} + +.placeholder-credentials > span { + padding: 0 3px; + color: #aaa; + position: absolute; + top: 14px; + left: 17px; + cursor: text; + user-select: none; + transition: transform .08s ease-in-out; + transition: color .08s ease; +} + +.placeholder-credentials input { + height: 25px; + width: -webkit-fill-available; + padding: 10px 20px; + border: 1px solid #aaa; + border-radius: 5px; + background-color: #fff0; + font-size: 14px; + color: #000; + outline: none; + transition: border .08s ease; +} + +/* Remove the background color from autocomplete */ +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active{ + background-clip: text; + -webkit-text-fill-color: #000; + transition: background-color 5000s ease-in-out 0s; + box-shadow: inset 0 0 20px 20px #f4f4f9; +} + +.auth-content > form button { + height: 45px; + background-color: #4CAF50; + border: 1px solid #fefefe; + border-radius: 5px; + font-size: 14px; + color: #fff; + cursor: pointer; +} + +.auth-content > form button:hover { + background-color: #3e8f41; +} + +.auth-base-box { + margin-top: 25px; +} + +.auth-base-box > label { + display: block; + text-align: center; +} + +.auth-base-box > label > span{ + font-size: 14px; +} + +.auth-base-box > label > a { + color: #4CAF50; + text-decoration: none; +} + +.placeholder-credentials input:not(:placeholder-shown) { + border: 1px solid #aaa; +} + +.placeholder-credentials input:not(:placeholder-shown) ~ span { + background-color: #f4f4f9; + font-size: 14px; + color: #aaa; + transform: translateY(-21.5px); +} + +.placeholder-credentials input:focus { + border: 1px solid #4CAF50; +} + +.placeholder-credentials input:focus ~ span { + background-color: #f4f4f9; + font-size: 14px; + color: #4CAF50; + transform: translateY(-21.5px); +} \ No newline at end of file diff --git a/static/css/home.css b/static/css/home.css index 2555d92..bcfa837 100644 --- a/static/css/home.css +++ b/static/css/home.css @@ -33,7 +33,7 @@ header p { /* Layout */ .content { max-width: 1200px; - margin: 2rem auto; + margin: 0 auto 2rem; padding: 1rem; } diff --git a/static/css/nav.css b/static/css/nav.css new file mode 100644 index 0000000..1fb216f --- /dev/null +++ b/static/css/nav.css @@ -0,0 +1,91 @@ +nav { + display: flex; + color: #333; +} + +.nav-menu { + height: 24px; + padding: .5rem; + position: absolute; + cursor: pointer; + z-index: 1; +} + +.nav-menu svg { + position: inherit; + transition: opacity .08s ease-in; +} + +.nav-menu:hover svg { + transform: scale(1.1); +} + +.deactivate { + opacity: 0; +} + +.nav-content { + display: flex; + align-items: center; + height: 40px; + width: 100%; + box-shadow: inset 0 -4px 12px -10px #333; + position: relative; + transform: translateX(-107%); + transition: transform 0.3s cubic-bezier(0.85, 0, 0.58, 1); +} + +.nav-content-open { + transform: translateX(0); +} + +.shadow-menu { + height: 24px; + width: 40px; + margin-right: .5rem; + border-right: 1px solid #aaa; +} + +.nav-links { + display: flex; + flex: 1; +} + +.nav-logout { + display: flex; + justify-content: flex-end; + height: 25px; + width: 95px; + padding: .5rem; +} + +.nav-logout form { + overflow: hidden; +} + +.nav-logout button { + display: flex; + align-items: center; + gap: 8px; + padding-left: .5rem; + border: none; + border-left: 1px solid #aaa; + background-color: #fff0; + color: #333; + cursor: pointer; + transform: translateX(64%); + transition: transform 0.3s ease; +} + +.nav-logout button > span { + opacity: 0; + transition: opacity 0.3s ease; +} + +.nav-logout button:hover { + transform: translateX(0); +} + +.nav-logout button:hover > span { + opacity: 1; +} diff --git a/static/js/nav.js b/static/js/nav.js new file mode 100644 index 0000000..02daeee --- /dev/null +++ b/static/js/nav.js @@ -0,0 +1,12 @@ +const menuBtn = document.querySelector('.nav-menu') +const menuBtnSvg = menuBtn.querySelectorAll('svg') +const nav = document.querySelector('.nav-content') + + +menuBtn.addEventListener('click', () => { + nav.classList.toggle('nav-content-open') + + menuBtnSvg.forEach(svg => { + svg.classList.toggle("deactivate") + }) +}) \ No newline at end of file diff --git a/templates/layout.html.j2 b/templates/layout.html.j2 index 1141d3b..0886dbc 100644 --- a/templates/layout.html.j2 +++ b/templates/layout.html.j2 @@ -5,6 +5,7 @@ ZoneForge + {% block css %} {% endblock %} @@ -17,6 +18,7 @@

DNS Zone File Management Made Easy

+ {% include 'nav.html.j2' %}
{% block content %} {% endblock %} @@ -33,5 +35,6 @@ Apache License 2.0

+ \ No newline at end of file diff --git a/templates/login.html.j2 b/templates/login.html.j2 index 7b8a3ba..52374ce 100644 --- a/templates/login.html.j2 +++ b/templates/login.html.j2 @@ -1,4 +1,47 @@ {% extends 'layout.html.j2' %} +{% block css %} + + +{% endblock %} {% block content %} +
+
+

Login

+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + {{ message }} + {% endfor %} + {% endif %} + {% endwith %} +
+ +
+
+ + + +
+ + +
+ +
+ +
+
+ {% endblock %} diff --git a/templates/nav.html.j2 b/templates/nav.html.j2 new file mode 100644 index 0000000..5be19a2 --- /dev/null +++ b/templates/nav.html.j2 @@ -0,0 +1,20 @@ +{% if current_user %} + +{% endif %} \ No newline at end of file diff --git a/templates/signup.html.j2 b/templates/signup.html.j2 index 7b8a3ba..ce67978 100644 --- a/templates/signup.html.j2 +++ b/templates/signup.html.j2 @@ -1,4 +1,46 @@ {% extends 'layout.html.j2' %} +{% block css %} + + +{% endblock %} {% block content %} +
+
+

Create Account

+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + {{ message }} + {% endfor %} + {% endif %} + {% endwith %} +
+ +
+
+ + + +
+ + +
+ +
+ +
+
{% endblock %} From c2e75798cb66338dc92edadf76dec0a4da7221ed Mon Sep 17 00:00:00 2001 From: Gabriel Reis <55472396+gabrielsrs@users.noreply.github.com> Date: Mon, 26 May 2025 23:35:11 -0300 Subject: [PATCH 4/7] feat(api): response with entity data --- zoneforge/api/rbac.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/zoneforge/api/rbac.py b/zoneforge/api/rbac.py index e2dbd3f..0a7a61f 100644 --- a/zoneforge/api/rbac.py +++ b/zoneforge/api/rbac.py @@ -20,7 +20,14 @@ def get(self): return { "groups": [ - {"id": group.id, "group_name": group.name} for group in group_entities + { + "id": group.id, + "group_name": group.name, + "roles": [ + {"id": role.id, "role_name": role.name} for role in group.roles + ], + } + for group in group_entities ] }, 200 @@ -41,7 +48,11 @@ def post(self): db.session.add(group) db.session.commit() - return {"message": "Group created successfully"}, 201 + return { + "id": group.id, + "group_name": group.name, + "message": "Group created successfully", + }, 201 @api.route("/group/") @@ -87,7 +98,7 @@ def get(self): return { "roles": [{"id": role.id, "role_name": role.name} for role in role_entities] - } + }, 200 @api_release_access("role_create") def post(self): @@ -106,7 +117,11 @@ def post(self): db.session.add(role) db.session.commit() - return {"message": "Role created successfully"}, 201 + return { + "id": role.id, + "role_name": role.name, + "message": "Role created successfully", + }, 201 @api.route("/role/") @@ -144,10 +159,10 @@ def delete(self, role_id: int = None): return {"message": "Role deleted"}, 200 -@api.route("/group//user/") +@api.route("/user//group/") class UserAssignGroupResource(Resource): - @api_release_access("userAssignGroup_read") - def post(self, group_id: int = None, user_id: int = None): + @api_release_access("userAssignGroup_create") + def post(self, user_id: int = None, group_id: int = None): db.get_or_404(Group, group_id, description="Group id not exist") user_entity = db.get_or_404(User, user_id, description="User id not exist") @@ -161,7 +176,7 @@ def post(self, group_id: int = None, user_id: int = None): return {"message": "User assign to a group successfully"}, 200 @api_release_access("userAssignGroup_update") - def put(self, group_id: int = None, user_id: int = None): + def put(self, user_id: int = None, group_id: int = None): db.get_or_404(Group, group_id, description="Group id not exist") user_entity = db.get_or_404(User, user_id, description="User id not exist") @@ -178,7 +193,7 @@ def put(self, group_id: int = None, user_id: int = None): return {"message": "User assign to the new group"}, 200 @api_release_access("userAssignGroup_delete") - def delete(self, group_id: int = None, user_id: int = None): + def delete(self, user_id: int = None, group_id: int = None): db.get_or_404(Group, group_id, description="Group id not exist") user_entity = db.get_or_404(User, user_id, description="User id not exist") @@ -197,7 +212,7 @@ def delete(self, group_id: int = None, user_id: int = None): @api.route("/group//role/") class RoleAssignGroupResource(Resource): - @api_release_access("roleAssignGroup_read") + @api_release_access("roleAssignGroup_create") def post(self, group_id: int = None, role_id: int = None): group_entity = db.get_or_404(Group, group_id, description="Group id not exist") role_entity = db.get_or_404(Role, role_id, description="Role id not exist") From 7658398c0018995719d20bf083e292721c8dc490 Mon Sep 17 00:00:00 2001 From: Gabriel Reis <55472396+gabrielsrs@users.noreply.github.com> Date: Mon, 26 May 2025 23:35:11 -0300 Subject: [PATCH 5/7] feat(api): user resource --- zoneforge/api/idm.py | 114 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 zoneforge/api/idm.py diff --git a/zoneforge/api/idm.py b/zoneforge/api/idm.py new file mode 100644 index 0000000..b4ee52d --- /dev/null +++ b/zoneforge/api/idm.py @@ -0,0 +1,114 @@ +from flask_restx import Namespace, Resource, reqparse +import bcrypt +from werkzeug.exceptions import * # pylint: disable=wildcard-import,unused-wildcard-import,redefined-builtin + +from zoneforge.api import api_release_access +from zoneforge.db import db +from zoneforge.db.db_model import User + +api = Namespace("idm", description="Identity Manager") + +# Parsers +idm_parser = reqparse.RequestParser(bundle_errors=True) +idm_parser.add_argument("username", type=str, help="Missing username", required=True) +idm_parser.add_argument("password", type=str, help="Missing password", required=True) + + +@api.route("/user") +class UserResource(Resource): + @api_release_access("user_read") + def get(self): + user_entities = db.paginate(db.select(User)) + + return { + "users": [ + { + "id": user.id, + "user_name": user.username, + "group": getattr(user.group, "name", "None"), + } + for user in user_entities + ] + }, 200 + + @api_release_access("user_create") + def post(self): + args = idm_parser.parse_args() + + username = args.get("username") + password = args.get("password") + + if not username or len(username) < 3: + raise BadRequest("Username must be at least 3 characters long") + + if not password or len(password) < 6: + raise BadRequest("Password must be at least 6 characters long") + + user_exists = db.session.execute( + db.select(User).filter_by(username=username) + ).scalar_one_or_none() + + if user_exists: + raise Conflict("Username already exists") + + hashed_password = bcrypt.hashpw( + password.encode(encoding="utf-8"), bcrypt.gensalt() + ).decode("utf-8") + + user = User( + username=username, + password=hashed_password, + ) + + db.session.add(user) + db.session.commit() + + return { + "id": user.id, + "user_name": user.username, + "message": "User created successfully", + }, 201 + + +@api.route("/user/") +class SpecificUserResource(Resource): + @api_release_access("user_update") + def patch(self, user_id: int = None): + username_idm_parser = idm_parser.copy() + username_idm_parser.replace_argument( + "username", type=str, help="Missing username", required=False + ) + username_idm_parser.remove_argument("password") + args = username_idm_parser.parse_args() + + username = args.get("username") + + if username and len(username) < 3: + raise BadRequest("Username must be at least 3 characters long") + + if username: + user_entity = db.session.execute( + db.select(User).filter_by(username=username) + ).scalar_one_or_none() + + if user_entity and user_entity.id == int(user_id): + raise Conflict("This user already have this username") + + if user_entity: + raise Conflict("Username already exist") + + current_user = db.get_or_404(User, user_id, description="User id not exist") + current_user.username = username + + db.session.commit() + + return {"message": "User updated successfully"}, 200 + + @api_release_access("user_delete") + def delete(self, user_id: str = None): + user_entity = db.get_or_404(User, user_id, description="User id not exist") + + db.session.delete(user_entity) + db.session.commit() + + return {"message": "User deleted successfully"}, 200 From 122dff59651c2d702d28d7b0caa99477cb88b25f Mon Sep 17 00:00:00 2001 From: Gabriel Reis <55472396+gabrielsrs@users.noreply.github.com> Date: Mon, 26 May 2025 23:35:12 -0300 Subject: [PATCH 6/7] feat(app): manage accesses --- app.py | 42 ++++++++++++++++++++++++++-- zoneforge/api/idm.py | 66 ++++++++++++++------------------------------ 2 files changed, 60 insertions(+), 48 deletions(-) diff --git a/app.py b/app.py index 2a9abb5..c706353 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ current_app, make_response, g, + abort, ) from flask_restx import Api from werkzeug.middleware.proxy_fix import ProxyFix @@ -27,8 +28,11 @@ from zoneforge.api.authentication import api as ns_auth from zoneforge.api.authentication import LoginResource, SignupResource from zoneforge.api.rbac import api as ns_rbac +from zoneforge.api.idm import api as ns_idm from zoneforge.db import db from zoneforge.api import app_release_access +from zoneforge.api.rbac import RoleResource, GroupResource +from zoneforge.api.idm import UserResource def get_logging_conf() -> dict: @@ -99,7 +103,7 @@ def create_app(): api = Api(app, prefix="/api", doc="/api", validate=True) @app.route("/", methods=["GET"]) - @app_release_access() + # @app_release_access() # Without decorator the nav don't appear def home(): zf_zone = DnsZone() try: @@ -122,7 +126,6 @@ def home(): ) @app.route("/zone/", methods=["GET"]) - @app_release_access() def zone(zone_name): zone = get_zones( zonefile_folder=current_app.config["ZONE_FILE_FOLDER"], zone_name=zone_name @@ -197,12 +200,47 @@ def logout(): response.delete_cookie("refresh_token") return response + @app.route("/access/", methods=["GET"]) + @app_release_access("adm") + def access(access_name): + user_sort = request.args.get("sort", "") + user_sort_order = request.args.get("sort_order", "desc") + + if access_name not in ("users", "groups", "roles"): + return abort(404) + + users = groups = roles = [{}] + + if access_name == "users": + users = UserResource().get() + + if access_name in ("users", "groups"): + groups = GroupResource().get() + + if access_name in ("groups", "roles"): + roles = RoleResource().get() + + access = { + **users[0], + **groups[0], + **roles[0], + } + + return render_template( + "access.html.j2", + access_name=access_name, + access=access, + record_sort=user_sort, + record_sort_order=user_sort_order, + ) + api.add_namespace(ns_status) api.add_namespace(ns_zone) api.add_namespace(ns_record) api.add_namespace(ns_types) api.add_namespace(ns_auth) api.add_namespace(ns_rbac) + api.add_namespace(ns_idm) return app diff --git a/zoneforge/api/idm.py b/zoneforge/api/idm.py index b4ee52d..6ae0325 100644 --- a/zoneforge/api/idm.py +++ b/zoneforge/api/idm.py @@ -1,17 +1,16 @@ from flask_restx import Namespace, Resource, reqparse -import bcrypt from werkzeug.exceptions import * # pylint: disable=wildcard-import,unused-wildcard-import,redefined-builtin from zoneforge.api import api_release_access from zoneforge.db import db from zoneforge.db.db_model import User +from zoneforge.api.authentication import SignupResource api = Namespace("idm", description="Identity Manager") # Parsers idm_parser = reqparse.RequestParser(bundle_errors=True) idm_parser.add_argument("username", type=str, help="Missing username", required=True) -idm_parser.add_argument("password", type=str, help="Missing password", required=True) @api.route("/user") @@ -34,68 +33,43 @@ def get(self): @api_release_access("user_create") def post(self): args = idm_parser.parse_args() - username = args.get("username") - password = args.get("password") - - if not username or len(username) < 3: - raise BadRequest("Username must be at least 3 characters long") - if not password or len(password) < 6: - raise BadRequest("Password must be at least 6 characters long") + create_user = SignupResource().post() - user_exists = db.session.execute( - db.select(User).filter_by(username=username) - ).scalar_one_or_none() - - if user_exists: - raise Conflict("Username already exists") - - hashed_password = bcrypt.hashpw( - password.encode(encoding="utf-8"), bcrypt.gensalt() - ).decode("utf-8") - - user = User( - username=username, - password=hashed_password, - ) + user = {} + if create_user[1] == 200: + user_entity = db.session.execute( + db.select(User).filter_by(username=username) + ).scalar_one_or_none() - db.session.add(user) - db.session.commit() + user = {"id": user_entity.id, "user_name": user_entity.username} return { - "id": user.id, - "user_name": user.username, - "message": "User created successfully", - }, 201 + **user, + **create_user[0], + }, create_user[1] @api.route("/user/") class SpecificUserResource(Resource): @api_release_access("user_update") def patch(self, user_id: int = None): - username_idm_parser = idm_parser.copy() - username_idm_parser.replace_argument( - "username", type=str, help="Missing username", required=False - ) - username_idm_parser.remove_argument("password") - args = username_idm_parser.parse_args() - + args = idm_parser.parse_args() username = args.get("username") - if username and len(username) < 3: + if not username or len(username) < 3: raise BadRequest("Username must be at least 3 characters long") - if username: - user_entity = db.session.execute( - db.select(User).filter_by(username=username) - ).scalar_one_or_none() + user_entity = db.session.execute( + db.select(User).filter_by(username=username) + ).scalar_one_or_none() - if user_entity and user_entity.id == int(user_id): - raise Conflict("This user already have this username") + if user_entity and user_entity.id == int(user_id): + raise Conflict("This user already have this username") - if user_entity: - raise Conflict("Username already exist") + if user_entity: + raise Conflict("Username already exist") current_user = db.get_or_404(User, user_id, description="User id not exist") current_user.username = username From 5c25f9fe8df4da1f46b78c5ae4a09b45f67453bc Mon Sep 17 00:00:00 2001 From: Gabriel Reis <55472396+gabrielsrs@users.noreply.github.com> Date: Mon, 26 May 2025 23:35:13 -0300 Subject: [PATCH 7/7] style: manage accesses templates --- static/css/access.css | 217 +++++++++++++++++++++++ static/css/nav.css | 14 ++ static/js/access.js | 363 +++++++++++++++++++++++++++++++++++++++ templates/access.html.j2 | 177 +++++++++++++++++++ templates/nav.html.j2 | 9 +- 5 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 static/css/access.css create mode 100644 static/js/access.js create mode 100644 templates/access.html.j2 diff --git a/static/css/access.css b/static/css/access.css new file mode 100644 index 0000000..9f81b53 --- /dev/null +++ b/static/css/access.css @@ -0,0 +1,217 @@ +.manager { + width: 920px; + justify-self: center; +} + +/* Header */ +.header-row { + flex-direction: column; + padding: 0; + gap: 1rem; +} + +.create-new-access { + display: flex; + padding: 0.5rem; +} + +.header-items { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +/* Nav */ +.header-nav { + display: flex; + align-items: center; + gap: .5rem; + margin: 0; + list-style-type: none; + color: #333333a6; + padding: 0.5rem 0; +} + +.header-nav > li { + display: flex; +} + +.header-nav li:hover { + transform: scale(1.2); +} + +.header-nav > li > a { + text-decoration: none; + color: inherit; + font-size: 1rem; + padding: .2rem .5rem; + cursor: pointer; +} + +.access-selected { + color: #4CAF50; +} + +/* Create new record */ + +/* Toggle new record */ +.new-access-content { + display: none; +} + +.new-access-active { + display: flex; +} + +.new-access { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: .5rem; + margin-bottom: .5rem; +} + +.input-fields { + display: flex; +} + +.input-fields > div, +.select-content { + display: flex; + flex-direction: column; + position: relative; + margin-right: 20px; +} + +.input-fields > div label { + font-size: 1rem; + margin-bottom: .2rem; + font-weight: 600; +} + +#password-content { + display: flex; + padding: .2rem 0; + height: 16px; + gap: .2rem; +} + +#allow-generate-password + label { + font-size: .8rem; + margin: 0; + line-height: 1.1rem; +} + +#allow-generate-password { + align-self: center; + height: 15px; + width: 15px; + margin: 0; +} + +#display-selected-roles > span { + padding: .2rem; + font-size: .8rem; +} + +.relative-select, +#display-selected-roles { + position: relative; +} + +.input-fields > div > input, +select, +#display-selected-roles { + min-height: 20px; + min-width: 160px; + background: #f4f4f9; + outline: none; + border: 1px solid #aaa; + border-radius: 4px; + padding: .3rem; +} + +#access-options { + min-height: 20px; + box-sizing: content-box; + background-color: #f4f4f9; + overflow: auto; + width: 100%; + position: absolute; +} + +.input-fields input::placeholder, +#display-selected-roles { + line-height: 1.25rem; + font-size: .9rem; + font-weight: 600; + color: #aaaaaaf8; +} + +.input-fields select, +select > option { + font-weight: 600; + cursor: pointer; + color: #333; +} + +.select-content select:focus { + z-index: 1; +} + +/* Toggle multi select */ +.access-options-inactive { + display: none; + height: 150px; +} + +.access-options-active { + display: flex; +} + +.drop-select { + position: absolute; + right: -15px; + bottom: 6px; + cursor: pointer; + user-select: none; +} + +.actions { + align-items: center; +} + +.create { + height: 32px; +} + +.overflow-multi-role { + overflow-x: auto; + max-width: 400px; +} + +.multi-role { + padding: .4rem; + background-color: #ccc; + border-radius: 4px; + margin-right: 2px; + color: #333; + font-weight: 100; +} + +/* Style to scroll */ +::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +#display-selected-roles::-webkit-scrollbar { + height: 3px; +} + +::-webkit-scrollbar-thumb { + background: #aaa; + border-radius: 10px; +} diff --git a/static/css/nav.css b/static/css/nav.css index 1fb216f..bcdc91f 100644 --- a/static/css/nav.css +++ b/static/css/nav.css @@ -49,6 +49,20 @@ nav { .nav-links { display: flex; flex: 1; + margin: 0; + padding: 0; + list-style-type: none; + color: #333333a6; +} + +.nav-links a { + text-decoration: none; + padding: .5rem; + color: inherit; +} + +.nav-links li:hover { + transform: scale(1.03); } .nav-logout { diff --git a/static/js/access.js b/static/js/access.js new file mode 100644 index 0000000..f100123 --- /dev/null +++ b/static/js/access.js @@ -0,0 +1,363 @@ + +// Event handlers +document.querySelectorAll('.actions button').forEach(button => { + button.addEventListener('click', async (e) => { + e.preventDefault(); + const row = button.closest('tr, form'); + + if (button.classList.contains('edit')) { + startEditing(row); + } else if (button.classList.contains('save')) { + await saveChanges(row); + } else if (button.classList.contains('cancel')) { + cancelEditing(row); + } else if (button.classList.contains('delete')) { + await deleteRecord(row); + } else if (button.classList.contains('create')) { + await createRecord(row); + } + }); +}); + +function startEditing(row) { + row.querySelectorAll('.editable').forEach(cell => { + let originalText = undefined + if (cell.childElementCount) { + originalText = Array.from(cell.querySelectorAll("span")).map(role => role.textContent).join(); + } else { + originalText = cell.textContent.trim(); + } + + cell.setAttribute('data-original', originalText); + + if (cell.getAttribute('data-field') === 'group') { + // Copy select dropdown for groups (user tab) + const select = document.querySelector(".select-content select") + const newSelect = select.cloneNode(true); + const selectedIndex = Array.from(newSelect.options).findIndex(selected => selected.textContent === originalText) + selectedIndex > 0 && (newSelect.selectedIndex = selectedIndex) + + newSelect.style.position = "relative" + newSelect.style.width = 0 + newSelect.style.cursor = "pointer" + + cell.textContent = ''; + cell.appendChild(newSelect); + + } else if (cell.getAttribute('data-field') === 'role') { + // Copy select dropdown for roles (group tab) + const selectContent = document.querySelector(".select-content") + const newSelect = selectContent.cloneNode(true) + const displaySelectedRoles = newSelect.querySelector("#display-selected-roles") + + newSelect.firstChild.remove() + + for (const option of newSelect.querySelectorAll("option")) { + option.selected = originalText.split(",").includes(option.textContent) + } + + newSelect.querySelector(".drop-select").setAttribute("onclick", "document.querySelector('tbody #access-options').classList.toggle('access-options-active')") + + cell.style.overflow = "visible" + cell.textContent = ''; + + const select = newSelect.querySelector("select") + select && select.addEventListener("change", (event) => changeMultiSelect(event)) + + cell.appendChild(newSelect); + + originalText && displayRoles(displaySelectedRoles, originalText.split(",")) + } else { + // For non-data cells, handle as before + const input = document.createElement('input'); + input.type = 'text'; + input.value = originalText; + cell.textContent = ''; + cell.appendChild(input); + } + }); + + addEnterKeyHandler(row, saveChanges); + setButtonsDisplay(row, true); +} + +function cancelEditing(row) { + // Restore original text content for non-role fields + row.querySelectorAll('.editable:not([data-field="role"])').forEach(cell => { + cell.textContent = cell.getAttribute('data-original'); + }); + + // Restore original role entries (group tab: list from roles) + row.querySelectorAll('.editable[data-field="role"]').forEach(cell => { + const originalText = cell.getAttribute('data-original'); + displayRoles(cell, originalText.split(",").filter(value => value)) + cell.style.overflow = "auto" + }); + + setButtonsDisplay(row, false); +} + +async function deleteRecord(row) { + if (!confirm('Are you sure you want to delete this record?')) { + return; + } + + try { + const name = row.querySelector("[data-name-url]") + const deleteRecord = { + tag: name.textContent, + url: name.dataset.nameUrl, + method: "DELETE", + } + + const response = await fetchWithBaseConfig(deleteRecord) + handleError(response) + + alert(`${response.tag}: ${response.message}`) + + row.remove(); + } catch (error) { + console.error('Error:', error); + alert(error.message? `${error.tag}: ${error.message}`: `HTTP error! status: ${error.status}`); + } +} + +async function saveChanges(row) { + try { + const bodyNameKey = row.dataset.category == "users"? "username": "name" + const name = row.querySelector('.editable[data-field="name"]') + const update = [ + { + tag: name.firstChild.value, + url: name.dataset.nameUrl, + method: row.dataset.category == "users"? "PATCH": "PUT", + options: { + body: JSON.stringify({ + [bodyNameKey]: row.querySelector("[data-field='name'] input").value + }) + } + }, + ] + const select = row.querySelector("[data-field='group'] select, [data-field='role'] select") + const selectedOptions = Array.from((select && select.selectedOptions) || []).filter(option => option.value) + + const group = row.querySelector('.editable[data-field="group"]') + if (row.dataset.category == "users" && selectedOptions.length) { + update.push({ + tag: selectedOptions[0].textContent, + url: group.dataset.selectUrl.replace(/0$/, selectedOptions[0].value), + method: group.dataset.original == "None"? "POST": "PUT", + }) + } + + const roles = row.querySelector('.editable[data-field="role"]') + if (row.dataset.category == "groups") { + for (const option of select) { + const previousRoles = roles.dataset.original.split(",") + const selectedText = Array.from(selectedOptions).map(option => option.textContent) + + // Validate roles to delete, create or not make any action if already exist + if (previousRoles.includes(option.textContent) && !selectedText.includes(option.textContent)) { + update.push({ + tag: option.textContent, + url: row.querySelector("[data-select-url]").dataset.selectUrl.replace(/0$/, option.value), + method: "DELETE", + }) + } else if (!previousRoles.includes(option.textContent) && selectedText.includes(option.textContent)) { + update.push({ + tag: option.textContent, + url: row.querySelector("[data-select-url]").dataset.selectUrl.replace(/0$/, option.value), + method: "POST", + }) + } + } + } + + const response = [] + + for (let request of update) { + response.push(await fetchWithBaseConfig(request)) + } + + response.forEach(res => { + handleError(res, response) + }) + + alert(response.map(res => `${res.tag}: ${res.message}\n`).join("")) + + // Update the row with the new values + name.textContent = name.querySelector('input').value + + group && (group.textContent = select.selectedIndex? select.selectedOptions[0].textContent: group.dataset.original) + + if(roles) { + roles.textContent = "" + displayRoles(roles, Array.from(selectedOptions).map(role => role.textContent)) + roles.style.overflow = "auto" + } + + setButtonsDisplay(row, false); + } catch (error) { + console.error('Error:', error); + if (error.details) { + alert(error.details.map(res => `${res.tag}: ${res.message}\n`).join("")); + window.location.reload(); + } else { + alert(error.message || `HTTP error! status: ${error.status}`) + } + } +} + +async function createRecord(row) { + try { + const bodyNameKey = row.dataset.category == "users"? "username": "name" + const passwordElement = row.querySelector("[data-field='password']") + const password = {password: (passwordElement && passwordElement.value) || "123456"} + const name = row.querySelector("[data-field='name']").value + const create = [ + { + tag: name, + url: row.dataset.url, + method: "POST", + options: { + body: JSON.stringify({ + [bodyNameKey]: name, + ...(row.dataset.category == "users" && password) + }) + } + } + ] + + const response = [] + + // Fetch to create resource id to assign group or role and validate creation + response.push(await fetchWithBaseConfig(create[0])) + handleError(response[0], response) + + const select = row.querySelector("#access-options") + const selectOptions = (select && select.selectedOptions) || [] + for (const option of Array.from(selectOptions).filter(option => option.value)) { + create.push({ + tag: option.textContent, + url: option.dataset.optionUrl.replace("0", response[0].id).replace("0", option.value), + method: "POST", + }) + } + + for (let request of create.filter((_, index) => index > 0 )) { + response.push(await fetchWithBaseConfig(request)) + } + + response.forEach(res => { + handleError(res, response) + }) + + const reload = confirm( + response.map((res, index) => { + if(index == 0) { + return`${res.tag}: ${res.message}\nPassword: ${password.password}\n` + } else { + return `${res.tag}: ${res.message}\n` + } + }).join("") + + "\nReload to load new record?" + ) + + reload && window.location.reload(); + + } catch (error) { + console.error('Error:', error); + if(error.details && error.details[0].ok) { + alert(error.details.map(res => `${res.tag}: ${res.message}\n`).join("")); + window.location.reload(); + } else if (error.details) { + alert(error.details.map(res => `${res.tag}: ${res.message}\n`).join("")); + } else { + alert(error.message || `HTTP error! status: ${error.status}`) + } + } +} + +// Display selected roles(groups tab) +const selectMultiOptions = document.querySelector("form .access-options-inactive") +selectMultiOptions && selectMultiOptions.addEventListener("change", (event) => changeMultiSelect(event)) +function changeMultiSelect(event) { + const displaySelectedRoles = event.target.closest(".select-content").querySelector("#display-selected-roles") + const selectedOptions = Array.from(event.target.selectedOptions).map(role => role.textContent) + + const currentRolesAndSelected = Array.from(displaySelectedRoles.children).map(inView => inView.textContent).filter(item => selectedOptions.includes(item)) + + displayRoles(displaySelectedRoles, [...selectedOptions.filter(item => !currentRolesAndSelected.includes(item)), ...currentRolesAndSelected]) +} + +function displayRoles(displaySelectedRoles, rolesToDisplay) { + displaySelectedRoles.innerHTML = "" + rolesToDisplay.forEach(newSelected => { + displaySelectedRoles.insertAdjacentHTML("beforeend", `${newSelected}`) + }) +} + +// Change between system generate/user generate password(users tab) +const allowGeneratePassword = document.querySelector("#allow-generate-password") +allowGeneratePassword && allowGeneratePassword.addEventListener("change", () => { + const generatePassword = document.querySelector("#generate-password") + + if(allowGeneratePassword.checked) { + generatePassword.toggleAttribute("disabled") + generatePassword.style.cursor = "text" + } else { + generatePassword.toggleAttribute("disabled") + generatePassword.style.cursor = "not-allowed" + generatePassword.value = "" + } +}) + +// Button display helper +function setButtonsDisplay(row, isEditing) { + row.querySelector('.edit').style.display = isEditing ? 'none' : 'block'; + row.querySelector('.delete').style.display = isEditing ? 'none' : 'block'; + row.querySelector('.save').style.display = isEditing ? 'block' : 'none'; + row.querySelector('.cancel').style.display = isEditing ? 'block' : 'none'; +} + +// helper function to add an enter key handler to a row +function addEnterKeyHandler(row, handler) { + row.querySelectorAll('input, select').forEach(input => { + input.addEventListener('keypress', async (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + await handler(row); + } + }); + }); +} + +// Allow Submitting a new record on Enter key true +document.querySelectorAll('tr.new-record').forEach(row => { + addEnterKeyHandler(row, createRecord); +}); + +// Fetch helper +async function fetchWithBaseConfig({url, method, options = {}, tag}) { + const baseConfig = { + method, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + ...options, + } + + const res = await fetch(url, baseConfig) + + return {tag, ...(await res.json()), statusCode: res.status, ok: res.ok} +} + +// Error helper +function handleError(res, response) { + if(!res.ok) { + const error = new Error(res.message || `HTTP error! status: ${res.status}`); + error.details = response + throw error + } +} diff --git a/templates/access.html.j2 b/templates/access.html.j2 new file mode 100644 index 0000000..8455404 --- /dev/null +++ b/templates/access.html.j2 @@ -0,0 +1,177 @@ +{% extends 'layout.html.j2' %} +{% block css %} + + + +{% endblock %} +{% set up_icon = "▴"%} +{% set down_icon = "▾"%} +{% set singular_name = access_name[:-1] %} +{% set name_field = singular_name + '_name' %} + + +{% block content %} + {% macro sort(column_name='None', sort='None') -%} + + {{ column_name }} + {% if record_sort == sort %} + + {% if record_sort_order == "asc" %} + {{ up_icon }} + {% elif record_sort_order == "desc"%} + {{ down_icon }} + {% endif %} + + {% endif %} + + {%- endmacro %} + +
+
+
+

{{ access_name.capitalize() }} Management

+
+ +
+
+
+ +
+
+ + {% if access_name == 'users' %} + {% set new_record_url = url_for("idm_user_resource") %} + {% else %} + {% set new_record_url = url_for("rbac_{}_resource".format(singular_name)) %} + {% endif %} + +
+
+
+
+ + +
+ + {% if access_name == 'users' %} +
+ + +
+ + +
+
+ +
+ +
+ +
+
+ {% elif access_name == "groups" %} +
+ +
+ Select roles +
+ +
+ +
+ +
+ {{ down_icon }} +
+
+ {% endif %} +
+
+ +
+
+
+ + + + + + {{ sort('Name', name_field) }} + + {% if access_name == 'users' %} + {{ sort('Group', 'group') }} + {% endif %} + + {% if access_name == 'groups' %} + {{ sort('Role', 'role_name') }} + {% endif %} + + + + + {% for record in access[access_name]|reverse()|sort(attribute=record_sort, reverse=(record_sort_order=='asc')) %} + + + + {% if access_name == 'users' %} + {% set group_assign_url_path = url_for("rbac_user_assign_group_resource", **{"user_id": record.id|string, "group_id": "0"}) %} + {% set idm_url_path = url_for("idm_specific_user_resource", **{"user_id": record.id|string}) %} + + + + {% elif access_name == 'groups' %} + {% set role_assign_url_path = url_for("rbac_role_assign_group_resource", **{"group_id": record.id|string, "role_id": "0"}) %} + {% set group_url_path = url_for("rbac_specific_group_resource", **{"group_id": record.id|string}) %} + + + + {% else %} + {% set role_url_path = url_for("rbac_specific_role_resource", **{"role_id": record.id|string}) %} + + + {% endif %} + + + {% endfor %} + +
+ Select + + Manage +
{{ record[name_field] }}{{ record.group }}{{ record[name_field] }} + {% for role in record.roles|reverse()|sort(attribute=record_sort, reverse=(record_sort_order=='asc')) %} + {{ role.role_name }} + {% endfor %} + {{ record[name_field] }} +
+ + + + +
+
+
+ +{% endblock %} diff --git a/templates/nav.html.j2 b/templates/nav.html.j2 index 5be19a2..b9474ad 100644 --- a/templates/nav.html.j2 +++ b/templates/nav.html.j2 @@ -6,7 +6,14 @@