You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

2090 lines
124 KiB

import json
from django import forms
from django.contrib.auth.models import Group
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import get_language, get_language_bidi, gettext as _
from apps.account.models.role import Role, PERMISSION_CATEGORIES, ALL_PERMISSION_FIELDS
from apps.profiles.models import Region
CATEGORY_ICONS = {
"Identity & Profiles": "badge",
"Chat & Communications": "forum",
"CMS & Articles": "article",
"LMS & Academics": "school",
"Meetings": "video_camera_front",
"Events": "event",
"Community Projects": "task_alt",
"Donations & Charity": "volunteer_activism",
"Support & Tickets": "confirmation_number",
"Dynamic Forms": "dynamic_form",
"Diplomacy & Institutional": "public",
"Administration & Security": "admin_panel_settings",
}
CATEGORY_TITLES_FA = {
"Identity & Profiles": "هویت و پروفایل‌ها",
"Chat & Communications": "پیام‌رسان و ارتباطات",
"CMS & Articles": "مدیریت محتوا و مقالات",
"LMS & Academics": "آموزش و امور علمی",
"Meetings": "جلسات و کنفرانس‌ها",
"Events": "رویدادها و همایش‌ها",
"Community Projects": "پروژه‌های مشارکتی",
"Donations & Charity": "امور خیریه و هدایا",
"Support & Tickets": "پشتیبانی و تیکت‌ها",
"Dynamic Forms": "فرم‌های پویا و نظرسنجی",
"Diplomacy & Institutional": "روابط نهادی و بین‌الملل",
"Administration & Security": "مدیریت و امنیت سیستم",
}
class UserAccessManagementWidget(forms.Widget):
"""
State-of-the-art Unfold Admin Access Management Widget.
Supports seamless bilingual localization (English / Persian) with automatic LTR/RTL direction.
"""
def __init__(self, attrs=None):
super().__init__(attrs)
self.instance = None
def render(self, name, value, attrs=None, renderer=None):
instance = getattr(self, "instance", None)
current_lang = (get_language() or "en").lower()
is_fa = current_lang.startswith("fa")
is_bidi = get_language_bidi()
layout_dir = "rtl" if is_bidi else "ltr"
t = {
# Overview Card
"overview_title": "خلاصه سطح دسترسی و اختیارات کاربر" if is_fa else "Role & Permissions Overview",
"overview_sub": "تنظیمات یکپارچه نقش سازمانی، دسترسی‌های اختصاصی، نهاد وابسته و مناطق جغرافیایی" if is_fa else "Unified management of user role, custom permissions, affiliated institution, and managed regions",
"btn_add_new_perm": "+ افزودن دسترسی جدید" if is_fa else "+ Add Permission",
"btn_open_modal": "تنظیم نقش و دسترسی‌ها (مودال کامل)" if is_fa else "Configure Role & Access",
"base_role_label": "نقش سازمانی / Base Role" if is_fa else "Base Role",
"btn_change_role": "تغییر نقش" if is_fa else "Change Role",
"unassigned_role": "بدون نقش سازمانی" if is_fa else "Unassigned / No Role",
"all_perms_match": "تمامی اختیارات منطبق بر نقش پایه" if is_fa else "All permissions match base role",
"custom_perms_suffix": "دسترسی اختصاصی فراتر از نقش" if is_fa else "custom permissions beyond role",
"revoked_perms_suffix": "اختیار سلب‌شده" if is_fa else "permissions revoked",
"additional_perms_label": "دسترسی‌های فراتر از نقش / Additional" if is_fa else "Additional Permissions",
"btn_add_perm": "افزودن دسترسی" if is_fa else "Add Permission",
"no_custom_perms": "فاقد مجوز اختصاصی (دسترسی استاندارد)" if is_fa else "No custom permissions (Standard role access)",
"more_suffix": "دیگر" if is_fa else "more",
"add_perm_hint": "روی دکمه «افزودن دسترسی» کلیک کنید تا اختیارات اختصاصی اضافه شود." if is_fa else "Click 'Add Permission' to grant additional custom permissions.",
"institution_label": "نهاد وابسته / Institution" if is_fa else "Affiliated Institution",
"no_institution": "فاقد نهاد وابسته (شخصی/مستقل)" if is_fa else "No affiliated institution (Independent/Personal)",
"institution_hint": "اختیارات اداری درون‌نهادی در این مرکز اعمال می‌شود." if is_fa else "Administrative authority within this institution applies here.",
"regions_label": "قلمرو جغرافیایی / Managed Regions" if is_fa else "Geographic Jurisdictions (Managed Regions)",
"regions_unlimited": "دسترسی عمومی / نامحدود" if is_fa else "Global / Unlimited access",
"regions_hint": "برای مدیران منطقه‌ای به استان‌ها یا کشورهای خاص محدود می‌شود." if is_fa else "For regional administrators, authority is restricted to assigned regions.",
"loading": "در حال بارگذاری..." if is_fa else "Loading...",
# Modal Dialog
"modal_title": "پیکربندی جامع دسترسی و اختیارات" if is_fa else "Configure Role & Permissions",
"modal_sub": "تخصیص نقش سازمانی، مدیریت اختیارات اختصاصی، تعیین نهاد و حوزه‌های منطقه‌ای" if is_fa else "Assign organizational role, configure custom permissions, set affiliated institution and geographic regions",
"tab_roles": "نقش‌ها و نوع کاربر" if is_fa else "Roles & User Type",
"tab_permissions": "مجوزها و اختیارات" if is_fa else "Permissions & Matrix",
"tab_institutions": "دسترسی به نهادها" if is_fa else "Institution Access",
"tab_regions": "دسترسی به مناطق" if is_fa else "Region Access",
# Tab 1
"select_role_title": "انتخاب نقش پایه سازمانی (Assigned Role)" if is_fa else "Select Base Organizational Role",
"select_role_sub": "نقش پایه شامل دسته‌ای از اختیارات پیش‌فرض تاییدشده است" if is_fa else "Base role includes a bundle of verified default permissions",
"default_perms_label": "مجوزهای پیش‌فرض موجود در نقش انتخابی:" if is_fa else "Default permissions granted by selected role:",
"active_perms_count": "مجوز فعال" if is_fa else "active permissions",
"no_role_selected": "هیچ نقشی انتخاب نشده است." if is_fa else "No role selected.",
"role_hint": "برای اعطای مجوزهای بیشتر به کاربر (فراتر از این نقش) یا سلب برخی اختیارات، به تب «مجوزها و اختیارات» مراجعه کنید." if is_fa else "To grant additional permissions (beyond this role) or revoke specific ones, navigate to the 'Permissions & Matrix' tab.",
"default_role_desc": "نقش سازمانی با مجوزهای پیش‌فرض" if is_fa else "Organizational role with default permissions",
"default_perms_chip": "مجوز پیش‌فرض" if is_fa else "default permissions",
"user_type_label": "نوع کاربری (User Type)" if is_fa else "User Type",
"groups_label": "گروه‌های سیستمی (Django Groups)" if is_fa else "Django Groups",
# User types
"ut_superadmin": "Superadmin (مدیر ارشد سامانه)" if is_fa else "Superadmin (System Administrator)",
"ut_regional_admin": "Regional Admin (مدیر منطقه‌ای)" if is_fa else "Regional Admin",
"ut_institution_admin": "Institution Admin (مدیر نهاد)" if is_fa else "Institution Admin",
"ut_editor": "Editor (ویرایشگر محتوا)" if is_fa else "Editor (Content Editor)",
"ut_staff": "Staff (کارشناس پشتیبانی و امور اداری)" if is_fa else "Staff (Support & Operations)",
"ut_client": "Client (کاربر عمومی سامانه)" if is_fa else "Client (Standard User)",
# Tab 2
"matrix_title": "ماتریس دسترسی‌ها و شخصی‌سازی مجوزها" if is_fa else "Permissions Matrix & Customization",
"matrix_sub": "مجوزهای نقش پایه به‌صورت خودکار ارث‌بری می‌شوند. می‌توانید مجوز جدید اضافه کنید یا اختیاری را سلب فرمایید." if is_fa else "Permissions from the base role are automatically inherited. You can grant custom permissions or revoke existing ones.",
"search_placeholder": "جستجو در مجوزها..." if is_fa else "Search permissions...",
"reset_btn": "پاکسازی" if is_fa else "Reset Overrides",
"add_perm_panel_title": "افزودن دسترسی جدید به کاربر (Add New Permission)" if is_fa else "Add New Permission to User",
"add_perm_panel_sub": "یک مجوز سیستمی را انتخاب یا شناسه اختصاصی وارد کنید" if is_fa else "Select a system permission or enter a custom key",
"select_system_perm_default": "-- انتخاب از تمام مجوزهای سیستم --" if is_fa else "-- Select from all system permissions --",
"in_role_tag": " (در نقش موجود است)" if is_fa else " (in role)",
"active_custom_tag": " (فعال اختصاصی)" if is_fa else " (active custom)",
"btn_grant": "اعطای مجوز" if is_fa else "Grant Permission",
"custom_key_placeholder": "یا وارد کردن شناسه مجوز دلخواه (مثال: can_export_reports)..." if is_fa else "Or enter custom permission key (e.g. can_export_reports)...",
"btn_add_key": "افزودن کلید" if is_fa else "Add Key",
"custom_flags_title": "مجوزهای سفارشی افزوده‌شده (Custom Granted Flags)" if is_fa else "Custom Granted Flags",
"delete_perm_title": "حذف این مجوز" if is_fa else "Delete this permission",
"status_custom_allow": "✓ مجوز اختصاصی (Allow)" if is_fa else "✓ Custom Granted (Allow)",
"status_custom_deny": "✕ سلب اختیار (Deny)" if is_fa else "✕ Revoked (Deny)",
"status_inherited_active": "● ارث‌بری: فعال از نقش" if is_fa else "● Inherited: Active from role",
"status_inherited_inactive": "○ ارث‌بری: غیرفعال" if is_fa else "○ Inherited: Inactive",
"status_custom_flag_allow": "✓ مجوز فعال سفارشی" if is_fa else "✓ Custom active permission",
"status_custom_flag_deny": "✕ سلب اختیار سفارشی" if is_fa else "✕ Custom revoked permission",
"btn_inherit": "ارث‌بری" if is_fa else "Inherit",
"btn_allow": "مجوز" if is_fa else "Allow",
"btn_deny": "سلب" if is_fa else "Deny",
# Tab 3
"inst_title": "نهاد وابسته به کاربر (Affiliated Institution)" if is_fa else "Affiliated Institution",
"inst_sub": "نام مرکز، موسسه، دانشگاه یا حوزه علمیه‌ای که کاربر به نمایندگی از آن فعالیت می‌کند." if is_fa else "The organization, center, university, or seminary represented by this user.",
"inst_input_label": "عنوان رسمی نهاد" if is_fa else "Official Institution Name",
"inst_input_placeholder": "مثال: جامعة المصطفی العالمیة / دانشگاه بین‌المللی اهل‌بیت (ع)" if is_fa else "e.g., Al-Mustafa International University",
"inst_guidelines_title": "راهنمای اختیارات درون‌نهادی:" if is_fa else "Internal Institution Guidelines:",
"inst_guideline_1": "کاربران دارای نوع کاربری Institution Admin به‌طور خودکار مدیر صفحه و پروژه‌های این نهاد خواهند بود." if is_fa else "Users with Institution Admin user type automatically manage this institution's profile and projects.",
"inst_guideline_2": "برای عضویت چندگانه در نهادها، می‌توانید از بخش پایین صفحه (Institution Memberships Inline) استفاده کنید." if is_fa else "For multiple institution memberships, use the Institution Memberships inline below.",
# Tab 4
"regions_title": "تخصیص حوزه‌های صلاحیت جغرافیایی (Managed Regions)" if is_fa else "Assign Geographic Jurisdictions (Managed Regions)",
"regions_sub": "اختیارات نظارتی مدیران منطقه‌ای صرفاً بر روی نهادها، کاربران و رویدادهای واقع در مناطق انتخاب‌شده اعمال می‌گردد." if is_fa else "Oversight authority of regional admins applies only to institutions, users, and events within selected regions.",
"regions_selected_count": "منطقه انتخاب‌شده" if is_fa else "regions selected",
# Footer
"footer_role": "نقش:" if is_fa else "Role:",
"footer_no_role": "بدون نقش" if is_fa else "No Role",
"footer_extra": "دسترسی اختصاصی" if is_fa else "custom permissions",
"footer_regions": "حوزه منطقه‌ای" if is_fa else "geographic regions",
"btn_cancel": "انصراف" if is_fa else "Cancel",
"btn_apply": "ثبت و اعمال تغییرات" if is_fa else "Save & Apply Changes",
}
if isinstance(value, str):
try:
overrides = json.loads(value) if value.strip() else {}
except Exception:
overrides = {}
elif isinstance(value, dict):
overrides = value
else:
overrides = {}
# 1. Fetch Roles Data
roles_data = {}
roles_list = []
for r in Role.objects.all().order_by("name"):
r_dict = {
"id": str(r.id),
"name": r.name,
"slug": r.slug or "",
"description": r.description or "",
"is_default": r.is_default,
"is_system": r.is_system,
"permissions": r.get_permissions_dict(),
}
roles_data[str(r.id)] = r_dict
roles_list.append(r_dict)
# 2. Fetch Categories & Permissions Data
categories_data = []
all_perms_map = {}
for cat_label, field_names in PERMISSION_CATEGORIES.items():
cat_name_str = str(cat_label)
icon = CATEGORY_ICONS.get(cat_name_str, "admin_panel_settings")
perms_list = []
for field_name in field_names:
try:
f = Role._meta.get_field(field_name)
vname = str(f.verbose_name)
help_txt = str(f.help_text) if hasattr(f, "help_text") else ""
except Exception:
vname = field_name.replace("_", " ").title()
help_txt = ""
p_item = {
"name": field_name,
"label": vname,
"help": help_txt,
"category": cat_name_str,
}
perms_list.append(p_item)
all_perms_map[field_name] = p_item
display_title = CATEGORY_TITLES_FA.get(cat_name_str, cat_name_str) if is_fa else cat_name_str
categories_data.append({
"title": display_title,
"icon": icon,
"perms": perms_list,
})
# 3. Fetch Regions Data
regions_data = []
try:
for reg in Region.objects.filter(is_active=True).order_by("name"):
regions_data.append({
"id": str(reg.id),
"name": reg.name,
"code": getattr(reg, "code", "") or "",
})
except Exception:
pass
# 4. Fetch Groups Data
groups_data = []
try:
for grp in Group.objects.all().order_by("name"):
groups_data.append({
"id": str(grp.id),
"name": grp.name,
})
except Exception:
pass
# 5. Extract Current User State
current_role_id = str(instance.role_id) if instance and instance.role_id else ""
current_user_type = instance.user_type if instance and hasattr(instance, "user_type") else "client"
current_institution = instance.institution_name or "" if instance and hasattr(instance, "institution_name") else ""
current_groups = [str(g.id) for g in instance.groups.all()] if instance and instance.pk else []
current_regions = [str(r.id) for r in instance.managed_regions.all()] if instance and instance.pk else []
current_role = roles_data.get(current_role_id)
current_role_display = current_role["name"] if current_role else t["unassigned_role"]
current_inst_display = current_institution if current_institution else t["no_institution"]
initial_state = {
"roleId": current_role_id,
"userType": current_user_type,
"institution": current_institution,
"groups": current_groups,
"regions": current_regions,
"overrides": overrides,
}
roles_json_str = json.dumps(roles_data)
categories_json_str = json.dumps(categories_data)
regions_json_str = json.dumps(regions_data)
groups_json_str = json.dumps(groups_data)
initial_json_str = json.dumps(initial_state)
perms_map_json_str = json.dumps(all_perms_map)
t_json_str = json.dumps(t)
# ─── Render HTML Structure ──────────────────────────────────────────
html = [
f'''
<div id="user-access-manager-root" class="w-full my-2 font-sans select-none" dir="{layout_dir}">
<!-- Hidden storage input -->
<textarea name="{escape(name)}" id="id_{escape(name)}" class="hidden">{escape(json.dumps(overrides))}</textarea>
<!-- Embedded JSON Data -->
<script id="access-data-roles" type="application/json">{roles_json_str}</script>
<script id="access-data-categories" type="application/json">{categories_json_str}</script>
<script id="access-data-regions" type="application/json">{regions_json_str}</script>
<script id="access-data-groups" type="application/json">{groups_json_str}</script>
<script id="access-data-initial" type="application/json">{initial_json_str}</script>
<script id="access-data-perms-map" type="application/json">{perms_map_json_str}</script>
<script id="access-data-i18n" type="application/json">{t_json_str}</script>
<!-- =================================================================== -->
<!-- 1. COMPACT ACCESS OVERVIEW CARD (Unfold Design System Aligned) -->
<!-- =================================================================== -->
<div class="unfold-access-card select-none" id="access-overview-card">
<!-- Card Header -->
<div class="overview-card-header">
<div class="overview-header-left">
<div class="overview-header-icon-box">
<span class="material-symbols-outlined" style="font-size: 24px;">shield_person</span>
</div>
<div>
<h3 class="overview-header-title">{t["overview_title"]}</h3>
<p class="overview-header-sub">{t["overview_sub"]}</p>
</div>
</div>
<!-- Top Action Buttons -->
<div class="flex items-center gap-2 flex-wrap">
<!-- Direct Add Permission Button -->
<button type="button" id="btn-header-add-perm" class="unfold-btn-secondary">
<span class="material-symbols-outlined" style="font-size: 16px;">add_circle</span>
<span>{t["btn_add_new_perm"]}</span>
</button>
<!-- Open Modal Trigger Button -->
<button type="button" id="btn-open-access-modal" class="unfold-btn-primary">
<span class="material-symbols-outlined" style="font-size: 16px;">tune</span>
<span>{t["btn_open_modal"]}</span>
</button>
</div>
</div>
<!-- Overview Details Grid: 4 Metric Cards -->
<div class="overview-metric-grid">
<!-- Box 1: Role & Permissions Status -->
<div class="overview-metric-box">
<div class="flex items-center justify-between">
<span class="overview-metric-label">{t["base_role_label"]}</span>
<button type="button" id="btn-change-role-card" class="overview-small-btn">{t["btn_change_role"]}</button>
</div>
<div class="overview-metric-content" id="overview-role-badge-container">
<span class="overview-badge overview-badge-role">
<span class="material-symbols-outlined" style="font-size: 15px;">badge</span>
<span id="overview-role-name">{escape(current_role_display)}</span>
</span>
</div>
<div id="overview-perm-status" class="overview-metric-hint">
<!-- Filled by JS -->
</div>
</div>
<!-- Box 2: Extra Permissions Pills & ADD BUTTON -->
<div class="overview-metric-box">
<div class="flex items-center justify-between">
<span class="overview-metric-label">{t["additional_perms_label"]}</span>
<button type="button" id="btn-add-perm-card" class="overview-small-btn">
<span class="material-symbols-outlined" style="font-size: 13px;">add</span>
<span>{t["btn_add_perm"]}</span>
</button>
</div>
<div class="overview-metric-content" id="overview-extra-perms-container">
<span class="overview-metric-hint italic">{t["loading"]}</span>
</div>
<span class="overview-metric-hint" id="overview-extra-perms-sub">
{t["add_perm_hint"]}
</span>
</div>
<!-- Box 3: Affiliated Institution -->
<div class="overview-metric-box">
<span class="overview-metric-label">{t["institution_label"]}</span>
<div class="overview-metric-content" id="overview-institution-name">
<span class="material-symbols-outlined" style="font-size: 17px; color: var(--color-font-subtle-light, #64748b);">apartment</span>
<span class="truncate font-semibold" style="font-size: 0.75rem;">{escape(current_inst_display)}</span>
</div>
<span class="overview-metric-hint">
{t["institution_hint"]}
</span>
</div>
<!-- Box 4: Managed Regions -->
<div class="overview-metric-box">
<span class="overview-metric-label">{t["regions_label"]}</span>
<div class="overview-metric-content" id="overview-regions-container">
<span class="overview-metric-hint italic">{t["loading"]}</span>
</div>
<span class="overview-metric-hint" id="overview-regions-sub">
{t["regions_hint"]}
</span>
</div>
</div>
</div>
<!-- =================================================================== -->
<!-- 2. FULL MODAL DIALOG (Attached directly to <body> via JS) -->
<!-- Fixed Height: 85vh, Internal Scrolling on Body, Top z-index -->
<!-- =================================================================== -->
<style>
#access-overview-card,
.unfold-access-card {{
background-color: var(--color-base-0, #ffffff) !important;
border: 1px solid var(--color-base-200, #e2e8f0) !important;
border-radius: var(--border-radius, 0.5rem) !important;
padding: 1.5rem !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05) !important;
margin-top: 0.5rem !important;
margin-bottom: 1.25rem !important;
box-sizing: border-box !important;
transition: border-color 0.15s ease-in-out !important;
}}
.dark #access-overview-card,
.dark .unfold-access-card {{
background-color: var(--color-base-900, #0f172a) !important;
border-color: var(--color-base-800, #1e293b) !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4) !important;
}}
.field-custom_permissions > div:first-child:has(label[for="id_custom_permissions"]),
.field-custom_permissions label[for="id_custom_permissions"] {{
display: none !important;
}}
.overview-card-header {{
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
flex-wrap: wrap !important;
gap: 1rem !important;
padding-bottom: 1.25rem !important;
margin-bottom: 1.25rem !important;
border-bottom: 1px solid var(--color-base-200, #e2e8f0) !important;
}}
.dark .overview-card-header {{
border-bottom-color: var(--color-base-800, #1e293b) !important;
}}
.overview-header-left {{
display: flex !important;
align-items: center !important;
gap: 0.875rem !important;
}}
.overview-header-icon-box {{
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 2.75rem !important;
height: 2.75rem !important;
border-radius: var(--border-radius, 0.5rem) !important;
background-color: rgba(99, 102, 241, 0.1) !important;
color: var(--color-primary-600, #4f46e5) !important;
flex-shrink: 0 !important;
}}
.dark .overview-header-icon-box {{
background-color: rgba(99, 102, 241, 0.2) !important;
color: var(--color-primary-400, #818cf8) !important;
}}
.overview-header-title {{
font-size: 1rem !important;
font-weight: 700 !important;
color: var(--color-font-important-light, #0f172a) !important;
margin: 0 !important;
line-height: 1.3 !important;
}}
.dark .overview-header-title {{
color: var(--color-font-important-dark, #f8fafc) !important;
}}
.overview-header-sub {{
font-size: 0.75rem !important;
color: var(--color-font-subtle-light, #64748b) !important;
margin-top: 0.25rem !important;
margin-bottom: 0 !important;
}}
.dark .overview-header-sub {{
color: var(--color-font-subtle-dark, #94a3b8) !important;
}}
.unfold-btn-primary {{
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 0.5rem !important;
padding: 0.5rem 1rem !important;
border-radius: var(--border-radius, 0.375rem) !important;
background-color: var(--color-primary-600, #4f46e5) !important;
color: #ffffff !important;
font-size: 0.75rem !important;
font-weight: 600 !important;
border: 1px solid transparent !important;
cursor: pointer !important;
transition: all 0.15s ease-in-out !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
white-space: nowrap !important;
text-decoration: none !important;
}}
.unfold-btn-primary:hover {{
background-color: var(--color-primary-700, #4338ca) !important;
color: #ffffff !important;
}}
.unfold-btn-secondary {{
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 0.5rem !important;
padding: 0.5rem 0.875rem !important;
border-radius: var(--border-radius, 0.375rem) !important;
background-color: var(--color-base-50, #f8fafc) !important;
color: var(--color-font-important-light, #0f172a) !important;
border: 1px solid var(--color-base-300, #cbd5e1) !important;
font-size: 0.75rem !important;
font-weight: 600 !important;
cursor: pointer !important;
transition: all 0.15s ease-in-out !important;
white-space: nowrap !important;
text-decoration: none !important;
}}
.unfold-btn-secondary:hover {{
background-color: var(--color-base-100, #f1f5f9) !important;
border-color: var(--color-base-400, #94a3b8) !important;
color: var(--color-font-important-light, #0f172a) !important;
}}
.dark .unfold-btn-secondary {{
background-color: var(--color-base-800, #1e293b) !important;
color: var(--color-font-important-dark, #f8fafc) !important;
border-color: var(--color-base-700, #334155) !important;
}}
.dark .unfold-btn-secondary:hover {{
background-color: var(--color-base-700, #334155) !important;
color: var(--color-font-important-dark, #f8fafc) !important;
}}
.overview-metric-grid {{
display: grid !important;
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
gap: 1rem !important;
}}
@media (min-width: 640px) {{
.overview-metric-grid {{
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}}
}}
@media (min-width: 1024px) {{
.overview-metric-grid {{
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
}}
}}
.overview-metric-box {{
display: flex !important;
flex-direction: column !important;
justify-content: space-between !important;
padding: 1.125rem !important;
border-radius: var(--border-radius, 0.5rem) !important;
border: 1px solid var(--color-base-200, #e2e8f0) !important;
background-color: var(--color-base-50, #f8fafc) !important;
min-height: 124px !important;
box-sizing: border-box !important;
transition: all 0.15s ease-in-out !important;
}}
.dark .overview-metric-box {{
border-color: var(--color-base-800, #1e293b) !important;
background-color: rgba(30, 41, 59, 0.4) !important;
}}
.overview-metric-box:hover {{
border-color: var(--color-base-300, #cbd5e1) !important;
background-color: #ffffff !important;
}}
.dark .overview-metric-box:hover {{
border-color: var(--color-base-700, #334155) !important;
background-color: rgba(30, 41, 59, 0.7) !important;
}}
.overview-metric-label {{
font-size: 0.6875rem !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
color: var(--color-font-subtle-light, #64748b) !important;
}}
.dark .overview-metric-label {{
color: var(--color-font-subtle-dark, #94a3b8) !important;
}}
.overview-metric-content {{
margin: 0.5rem 0 !important;
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 0.375rem !important;
}}
.overview-metric-hint {{
font-size: 0.6875rem !important;
color: var(--color-font-subtle-light, #64748b) !important;
line-height: 1.4 !important;
}}
.dark .overview-metric-hint {{
color: var(--color-font-subtle-dark, #94a3b8) !important;
}}
.overview-badge {{
display: inline-flex !important;
align-items: center !important;
gap: 0.375rem !important;
padding: 0.25rem 0.625rem !important;
border-radius: var(--border-radius, 0.375rem) !important;
font-size: 0.75rem !important;
font-weight: 600 !important;
line-height: 1.25 !important;
box-sizing: border-box !important;
}}
.overview-badge-role {{
background-color: rgba(99, 102, 241, 0.1) !important;
border: 1px solid rgba(99, 102, 241, 0.25) !important;
color: var(--color-primary-700, #4338ca) !important;
}}
.dark .overview-badge-role {{
background-color: rgba(99, 102, 241, 0.2) !important;
border-color: rgba(99, 102, 241, 0.4) !important;
color: var(--color-primary-300, #a5b4fc) !important;
}}
.overview-badge-success {{
background-color: rgba(16, 185, 129, 0.1) !important;
border: 1px solid rgba(16, 185, 129, 0.25) !important;
color: #047857 !important;
}}
.dark .overview-badge-success {{
background-color: rgba(16, 185, 129, 0.2) !important;
border-color: rgba(16, 185, 129, 0.4) !important;
color: #6ee7b7 !important;
}}
.overview-badge-extra {{
background-color: #ffffff !important;
border: 1px solid var(--color-base-300, #cbd5e1) !important;
color: var(--color-font-important-light, #0f172a) !important;
padding: 0.2rem 0.5rem !important;
border-radius: var(--border-radius, 0.25rem) !important;
font-size: 0.6875rem !important;
font-weight: 600 !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03) !important;
}}
.dark .overview-badge-extra {{
background-color: var(--color-base-800, #1e293b) !important;
border-color: var(--color-base-700, #334155) !important;
color: var(--color-font-important-dark, #f8fafc) !important;
}}
.overview-badge-region {{
background-color: rgba(16, 185, 129, 0.08) !important;
border: 1px solid rgba(16, 185, 129, 0.2) !important;
color: #065f46 !important;
padding: 0.2rem 0.5rem !important;
border-radius: var(--border-radius, 0.25rem) !important;
font-size: 0.6875rem !important;
font-weight: 600 !important;
}}
.dark .overview-badge-region {{
background-color: rgba(16, 185, 129, 0.15) !important;
border-color: rgba(16, 185, 129, 0.3) !important;
color: #a7f3d0 !important;
}}
.overview-small-btn {{
display: inline-flex !important;
align-items: center !important;
gap: 0.25rem !important;
padding: 0.2rem 0.5rem !important;
border-radius: var(--border-radius, 0.25rem) !important;
font-size: 0.6875rem !important;
font-weight: 600 !important;
background-color: var(--color-base-100, #f1f5f9) !important;
border: 1px solid var(--color-base-300, #cbd5e1) !important;
color: var(--color-font-important-light, #0f172a) !important;
cursor: pointer !important;
transition: all 0.15s ease-in-out !important;
}}
.overview-small-btn:hover {{
background-color: var(--color-base-200, #e2e8f0) !important;
}}
.dark .overview-small-btn {{
background-color: var(--color-base-800, #1e293b) !important;
border-color: var(--color-base-700, #334155) !important;
color: var(--color-font-important-dark, #f8fafc) !important;
}}
.dark .overview-small-btn:hover {{
background-color: var(--color-base-700, #334155) !important;
}}
#access-modal-overlay.access-modal-backdrop {{
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 2147483647 !important;
background-color: rgba(15, 23, 42, 0.75) !important;
backdrop-filter: blur(6px) !important;
-webkit-backdrop-filter: blur(6px) !important;
display: none;
align-items: center !important;
justify-content: center !important;
padding: 1.5rem !important;
box-sizing: border-box !important;
overflow: hidden !important;
}}
#access-modal-overlay.access-modal-backdrop.is-open {{
display: flex !important;
}}
#access-modal-dialog.access-modal-window {{
width: 95vw !important;
max-width: 980px !important;
height: 85vh !important;
max-height: 85vh !important;
min-height: 480px !important;
display: flex !important;
flex-direction: column !important;
background-color: var(--color-base-0, #ffffff) !important;
border-radius: var(--border-radius, 0.75rem) !important;
border: 1px solid var(--color-base-200, #e2e8f0) !important;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.08) !important;
overflow: hidden !important;
position: relative !important;
box-sizing: border-box !important;
margin: auto !important;
}}
.dark #access-modal-dialog.access-modal-window {{
background-color: var(--color-base-900, #0f172a) !important;
border-color: var(--color-base-800, #1e293b) !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7) !important;
}}
.access-modal-header {{
flex-shrink: 0 !important;
padding: 1.125rem 1.5rem !important;
border-bottom: 1px solid var(--color-base-200, #e2e8f0) !important;
background-color: var(--color-base-50, #f8fafc) !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
user-select: none !important;
}}
.dark .access-modal-header {{
border-bottom-color: var(--color-base-800, #1e293b) !important;
background-color: rgba(30, 41, 59, 0.7) !important;
}}
.access-modal-tabs-bar {{
flex-shrink: 0 !important;
padding: 0.625rem 1.5rem 0.375rem 1.5rem !important;
border-bottom: 1px solid var(--color-base-200, #e2e8f0) !important;
background-color: var(--color-base-0, #ffffff) !important;
display: flex !important;
align-items: center !important;
gap: 0.5rem !important;
overflow-x: auto !important;
user-select: none !important;
}}
.dark .access-modal-tabs-bar {{
border-bottom-color: var(--color-base-800, #1e293b) !important;
background-color: var(--color-base-900, #0f172a) !important;
}}
.access-tab-btn {{
display: inline-flex !important;
align-items: center !important;
gap: 0.5rem !important;
padding: 0.5rem 1rem !important;
border-radius: var(--border-radius, 0.375rem) !important;
font-size: 0.75rem !important;
font-weight: 500 !important;
border: 1px solid transparent !important;
cursor: pointer !important;
transition: all 0.15s ease-in-out !important;
background: transparent !important;
color: var(--color-font-default-light, #475569) !important;
white-space: nowrap !important;
}}
.dark .access-tab-btn {{
color: var(--color-font-default-dark, #94a3b8) !important;
}}
.access-tab-btn:hover {{
background-color: var(--color-base-100, #f1f5f9) !important;
color: var(--color-font-important-light, #0f172a) !important;
}}
.dark .access-tab-btn:hover {{
background-color: var(--color-base-800, #1e293b) !important;
color: var(--color-font-important-dark, #f8fafc) !important;
}}
.access-tab-btn.active {{
background-color: rgba(99, 102, 241, 0.08) !important;
border-color: rgba(99, 102, 241, 0.3) !important;
color: var(--color-primary-600, #4f46e5) !important;
font-weight: 700 !important;
}}
.dark .access-tab-btn.active {{
background-color: rgba(99, 102, 241, 0.2) !important;
border-color: rgba(99, 102, 241, 0.5) !important;
color: var(--color-primary-300, #a5b4fc) !important;
}}
.access-modal-body {{
flex: 1 1 0% !important;
min-height: 0 !important;
height: auto !important;
max-height: 100% !important;
overflow-y: auto !important;
overflow-x: hidden !important;
overscroll-behavior: contain !important;
-webkit-overflow-scrolling: touch !important;
padding: 1.5rem !important;
box-sizing: border-box !important;
}}
.access-modal-body::-webkit-scrollbar {{
width: 7px;
}}
.access-modal-body::-webkit-scrollbar-track {{
background: transparent;
}}
.access-modal-body::-webkit-scrollbar-thumb {{
background: rgba(148, 163, 184, 0.5);
border-radius: 9999px;
}}
.access-modal-body::-webkit-scrollbar-thumb:hover {{
background: rgba(100, 116, 139, 0.8);
}}
.dark .access-modal-body::-webkit-scrollbar-thumb {{
background: rgba(71, 85, 105, 0.6);
}}
.dark .access-modal-body::-webkit-scrollbar-thumb:hover {{
background: rgba(100, 116, 139, 0.9);
}}
.access-modal-footer {{
flex-shrink: 0 !important;
padding: 1rem 1.5rem !important;
border-top: 1px solid var(--color-base-200, #e2e8f0) !important;
background-color: var(--color-base-50, #f8fafc) !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 1rem !important;
user-select: none !important;
flex-wrap: wrap !important;
}}
.dark .access-modal-footer {{
border-top-color: var(--color-base-800, #1e293b) !important;
background-color: rgba(30, 41, 59, 0.7) !important;
}}
.modal-role-card {{
border-radius: var(--border-radius, 0.5rem) !important;
padding: 1rem !important;
border: 1px solid var(--color-base-200, #e2e8f0) !important;
background-color: var(--color-base-0, #ffffff) !important;
cursor: pointer !important;
transition: all 0.15s ease-in-out !important;
box-sizing: border-box !important;
}}
.dark .modal-role-card {{
border-color: var(--color-base-800, #1e293b) !important;
background-color: rgba(30, 41, 59, 0.3) !important;
}}
.modal-role-card:hover {{
border-color: var(--color-base-300, #cbd5e1) !important;
background-color: var(--color-base-50, #f8fafc) !important;
}}
.dark .modal-role-card:hover {{
border-color: var(--color-base-700, #334155) !important;
background-color: rgba(30, 41, 59, 0.6) !important;
}}
.modal-role-card.is-selected {{
border-color: var(--color-primary-600, #4f46e5) !important;
background-color: rgba(99, 102, 241, 0.05) !important;
box-shadow: 0 0 0 1px var(--color-primary-600, #4f46e5) !important;
}}
.dark .modal-role-card.is-selected {{
border-color: var(--color-primary-500, #6366f1) !important;
background-color: rgba(99, 102, 241, 0.15) !important;
box-shadow: 0 0 0 1px var(--color-primary-500, #6366f1) !important;
}}
.modal-region-card {{
border-radius: var(--border-radius, 0.5rem) !important;
padding: 0.875rem 1rem !important;
border: 1px solid var(--color-base-200, #e2e8f0) !important;
background-color: var(--color-base-0, #ffffff) !important;
cursor: pointer !important;
transition: all 0.15s ease-in-out !important;
box-sizing: border-box !important;
}}
.dark .modal-region-card {{
border-color: var(--color-base-800, #1e293b) !important;
background-color: rgba(30, 41, 59, 0.3) !important;
}}
.modal-region-card:hover {{
border-color: var(--color-base-300, #cbd5e1) !important;
}}
.modal-region-card.is-selected {{
border-color: #10b981 !important;
background-color: rgba(16, 185, 129, 0.08) !important;
box-shadow: 0 0 0 1px #10b981 !important;
}}
.dark .modal-region-card.is-selected {{
border-color: #10b981 !important;
background-color: rgba(16, 185, 129, 0.2) !important;
box-shadow: 0 0 0 1px #10b981 !important;
}}
.modal-group-chip {{
display: inline-flex !important;
align-items: center !important;
gap: 0.375rem !important;
padding: 0.375rem 0.75rem !important;
border-radius: var(--border-radius, 0.375rem) !important;
border: 1px solid var(--color-base-300, #cbd5e1) !important;
background-color: #ffffff !important;
color: var(--color-font-important-light, #0f172a) !important;
font-size: 0.75rem !important;
font-weight: 500 !important;
cursor: pointer !important;
transition: all 0.15s ease-in-out !important;
}}
.dark .modal-group-chip {{
border-color: var(--color-base-700, #334155) !important;
background-color: var(--color-base-800, #1e293b) !important;
color: var(--color-font-important-dark, #f8fafc) !important;
}}
.modal-group-chip.is-checked {{
background-color: rgba(99, 102, 241, 0.08) !important;
border-color: var(--color-primary-600, #4f46e5) !important;
color: var(--color-primary-700, #4338ca) !important;
font-weight: 700 !important;
}}
.dark .modal-group-chip.is-checked {{
background-color: rgba(99, 102, 241, 0.2) !important;
border-color: var(--color-primary-500, #6366f1) !important;
color: var(--color-primary-300, #a5b4fc) !important;
}}
.matrix-cat-card {{
border-radius: var(--border-radius, 0.5rem) !important;
border: 1px solid var(--color-base-200, #e2e8f0) !important;
background-color: var(--color-base-0, #ffffff) !important;
overflow: hidden !important;
margin-bottom: 0.75rem !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03) !important;
}}
.dark .matrix-cat-card {{
border-color: var(--color-base-800, #1e293b) !important;
background-color: var(--color-base-900, #0f172a) !important;
}}
.matrix-cat-header {{
padding: 0.75rem 1.125rem !important;
background-color: var(--color-base-50, #f8fafc) !important;
border-bottom: 1px solid var(--color-base-200, #e2e8f0) !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
cursor: pointer !important;
user-select: none !important;
}}
.dark .matrix-cat-header {{
background-color: rgba(30, 41, 59, 0.5) !important;
border-bottom-color: var(--color-base-800, #1e293b) !important;
}}
.matrix-cat-body {{
padding: 0.875rem !important;
display: grid !important;
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
gap: 0.5rem !important;
}}
@media (min-width: 768px) {{
.matrix-cat-body {{
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}}
}}
.matrix-perm-row {{
padding: 0.5rem 0.75rem !important;
border-radius: var(--border-radius, 0.375rem) !important;
border: 1px solid var(--color-base-200, #e2e8f0) !important;
background-color: var(--color-base-50, #f8fafc) !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
transition: all 0.15s ease-in-out !important;
}}
.dark .matrix-perm-row {{
border-color: var(--color-base-800, #1e293b) !important;
background-color: rgba(30, 41, 59, 0.25) !important;
}}
.matrix-perm-row:hover {{
border-color: var(--color-base-300, #cbd5e1) !important;
background-color: #ffffff !important;
}}
.dark .matrix-perm-row:hover {{
border-color: var(--color-base-700, #334155) !important;
background-color: rgba(30, 41, 59, 0.5) !important;
}}
.perm-btn-group {{
display: inline-flex !important;
border-radius: var(--border-radius, 0.375rem) !important;
padding: 2px !important;
background-color: var(--color-base-200, #e2e8f0) !important;
border: 1px solid var(--color-base-300, #cbd5e1) !important;
flex-shrink: 0 !important;
}}
.dark .perm-btn-group {{
background-color: var(--color-base-800, #1e293b) !important;
border-color: var(--color-base-700, #334155) !important;
}}
.perm-btn {{
padding: 0.25rem 0.5rem !important;
border-radius: var(--border-radius, 0.25rem) !important;
font-size: 0.6875rem !important;
font-weight: 500 !important;
border: none !important;
cursor: pointer !important;
transition: all 0.15s ease-in-out !important;
background: transparent !important;
color: var(--color-font-default-light, #64748b) !important;
}}
.dark .perm-btn {{
color: var(--color-font-default-dark, #94a3b8) !important;
}}
.perm-btn.is-active-inherit {{
background-color: #ffffff !important;
color: var(--color-font-important-light, #0f172a) !important;
font-weight: 700 !important;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important;
}}
.dark .perm-btn.is-active-inherit {{
background-color: var(--color-base-700, #334155) !important;
color: #ffffff !important;
}}
.perm-btn.is-active-allow {{
background-color: #10b981 !important;
color: #ffffff !important;
font-weight: 700 !important;
box-shadow: 0 1px 2px 0 rgba(16, 185, 129, 0.3) !important;
}}
.perm-btn.is-active-deny {{
background-color: #ef4444 !important;
color: #ffffff !important;
font-weight: 700 !important;
box-shadow: 0 1px 2px 0 rgba(239, 68, 68, 0.3) !important;
}}
.unfold-panel-box {{
border-radius: var(--border-radius, 0.5rem) !important;
padding: 1.125rem !important;
border: 1px solid var(--color-base-200, #e2e8f0) !important;
background-color: var(--color-base-50, #f8fafc) !important;
box-sizing: border-box !important;
}}
.dark .unfold-panel-box {{
border-color: var(--color-base-800, #1e293b) !important;
background-color: rgba(30, 41, 59, 0.35) !important;
}}
.unfold-input-field,
#modal-perm-search,
#modal-user-type-select,
#modal-institution-input,
#select-system-perm-to-add,
#input-custom-perm-key {{
border-radius: var(--border-radius, 0.375rem) !important;
padding: 0.5rem 0.75rem !important;
border: 1px solid var(--color-base-300, #cbd5e1) !important;
background-color: var(--color-base-0, #ffffff) !important;
color: var(--color-font-important-light, #0f172a) !important;
font-size: 0.75rem !important;
outline: none !important;
box-sizing: border-box !important;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out !important;
}}
.dark .unfold-input-field,
.dark #modal-perm-search,
.dark #modal-user-type-select,
.dark #modal-institution-input,
.dark #select-system-perm-to-add,
.dark #input-custom-perm-key {{
border-color: var(--color-base-700, #334155) !important;
background-color: var(--color-base-800, #1e293b) !important;
color: var(--color-font-important-dark, #f8fafc) !important;
}}
.unfold-input-field:focus,
#modal-perm-search:focus,
#modal-user-type-select:focus,
#modal-institution-input:focus,
#select-system-perm-to-add:focus,
#input-custom-perm-key:focus {{
border-color: var(--color-primary-600, #4f46e5) !important;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2) !important;
}}
body.access-modal-open,
html.access-modal-open {{
overflow: hidden !important;
height: 100% !important;
}}
body.access-modal-open #page,
body.access-modal-open #main,
body.access-modal-open #content {{
overflow: hidden !important;
}}
</style>
<div id="access-modal-overlay" class="access-modal-backdrop" dir="{layout_dir}">
<div id="access-modal-dialog" class="access-modal-window">
<!-- Modal Header -->
<div id="access-modal-header" class="access-modal-header">
<div class="flex items-center gap-3">
<div class="overview-header-icon-box" style="width: 2.25rem; height: 2.25rem;">
<span class="material-symbols-outlined" style="font-size: 20px;">admin_panel_settings</span>
</div>
<div>
<h3 class="overview-header-title" style="font-size: 0.9375rem;">{t["modal_title"]}</h3>
<p class="overview-header-sub">{t["modal_sub"]}</p>
</div>
</div>
<button type="button" id="btn-close-access-modal-x" class="overview-small-btn" style="padding: 0.35rem 0.6rem;">
<span class="material-symbols-outlined" style="font-size: 18px;">close</span>
</button>
</div>
<!-- 4 Navigation Tabs -->
<div id="access-modal-tabs-nav" class="access-modal-tabs-bar">
<div class="flex items-center gap-1.5 overflow-x-auto" id="access-modal-tabs-container">
<button type="button" class="access-tab-btn active" data-tab="tab-roles">
<span class="material-symbols-outlined" style="font-size: 16px;">badge</span>
<span>{t["tab_roles"]}</span>
</button>
<button type="button" class="access-tab-btn" data-tab="tab-permissions">
<span class="material-symbols-outlined" style="font-size: 16px;">tune</span>
<span>{t["tab_permissions"]}</span>
<span id="tab-extra-count-badge" class="hidden overview-badge overview-badge-role" style="padding: 0.1rem 0.4rem; font-size: 0.625rem;">+0</span>
</button>
<button type="button" class="access-tab-btn" data-tab="tab-institutions">
<span class="material-symbols-outlined" style="font-size: 16px;">apartment</span>
<span>{t["tab_institutions"]}</span>
</button>
<button type="button" class="access-tab-btn" data-tab="tab-regions">
<span class="material-symbols-outlined" style="font-size: 16px;">public</span>
<span>{t["tab_regions"]}</span>
<span id="tab-regions-count-badge" class="hidden overview-badge overview-badge-success" style="padding: 0.1rem 0.4rem; font-size: 0.625rem;">0</span>
</button>
</div>
</div>
<!-- Modal Body (SCROLLABLE CONTAINER) -->
<div class="access-modal-body space-y-6" id="modal-tabs-body">
<!-- ========================================== -->
<!-- TAB 1: ROLES & USER TYPES & GROUPS -->
<!-- ========================================== -->
<div id="tab-roles" class="access-tab-content space-y-5">
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-xs font-bold text-font-important-light dark:text-font-important-dark">
{t["select_role_title"]}
</label>
<span class="text-[11px] text-font-subtle-light dark:text-font-subtle-dark">
{t["select_role_sub"]}
</span>
</div>
<!-- Role Cards Selector Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3" id="modal-role-cards-container">
<!-- Rendered by JS from roles_data -->
</div>
</div>
<!-- Role Inherited Permissions Preview Box -->
<div class="unfold-panel-box space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-xs font-bold text-font-important-light dark:text-font-important-dark">
<span class="material-symbols-outlined text-base text-emerald-600">verified</span>
<span>{t["default_perms_label"]}</span>
</div>
<span class="text-xs font-semibold text-primary-600 dark:text-primary-400" id="role-preview-count">0 {t["active_perms_count"]}</span>
</div>
<div class="flex flex-wrap gap-1.5 pt-1" id="role-preview-badges">
<!-- Filled by JS -->
</div>
<p class="text-[11px] text-font-subtle-light dark:text-font-subtle-dark pt-1">
{t["role_hint"]}
</p>
</div>
<!-- User Type & Groups Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<!-- User Type -->
<div class="space-y-1.5">
<label class="text-xs font-bold text-font-important-light dark:text-font-important-dark">{t["user_type_label"]}</label>
<select id="modal-user-type-select" class="unfold-input-field w-full">
<option value="superadmin">{t["ut_superadmin"]}</option>
<option value="regional_admin">{t["ut_regional_admin"]}</option>
<option value="institution_admin">{t["ut_institution_admin"]}</option>
<option value="editor">{t["ut_editor"]}</option>
<option value="staff">{t["ut_staff"]}</option>
<option value="client">{t["ut_client"]}</option>
</select>
</div>
<!-- Django Groups Pills -->
<div class="space-y-1.5">
<label class="text-xs font-bold text-font-important-light dark:text-font-important-dark">{t["groups_label"]}</label>
<div class="flex flex-wrap gap-2 pt-1" id="modal-groups-container">
<!-- Rendered by JS -->
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- TAB 2: PERMISSIONS MATRIX & ADD PERMISSION -->
<!-- ========================================== -->
<div id="tab-permissions" class="access-tab-content space-y-4 hidden">
<!-- Top Filter & Action Bar -->
<div class="unfold-panel-box flex flex-col md:flex-row md:items-center justify-between gap-3">
<div>
<h4 class="text-xs font-bold text-font-important-light dark:text-font-important-dark flex items-center gap-1.5">
<span class="material-symbols-outlined text-base text-primary-600 dark:text-primary-400">tune</span>
<span>{t["matrix_title"]}</span>
</h4>
<p class="text-[11px] text-font-subtle-light dark:text-font-subtle-dark mt-0.5">
{t["matrix_sub"]}
</p>
</div>
<div class="flex items-center gap-2 flex-wrap">
<!-- Search input -->
<div class="relative w-full md:w-56">
<input type="text" id="modal-perm-search" placeholder="{t['search_placeholder']}" class="unfold-input-field w-full">
</div>
<button type="button" id="modal-reset-overrides-btn" class="unfold-btn-secondary">
{t["reset_btn"]}
</button>
</div>
</div>
<!-- DEDICATED ADD NEW PERMISSION PANEL -->
<div class="unfold-panel-box space-y-3" id="panel-add-new-permission" style="border-color: rgba(99, 102, 241, 0.3); background-color: rgba(99, 102, 241, 0.03);">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="overview-header-icon-box" style="width: 1.75rem; height: 1.75rem; border-radius: var(--border-radius, 0.25rem);">
<span class="material-symbols-outlined" style="font-size: 16px;">add</span>
</span>
<span class="text-xs font-bold text-font-important-light dark:text-font-important-dark">{t["add_perm_panel_title"]}</span>
</div>
<span class="text-[11px] text-font-subtle-light dark:text-font-subtle-dark">{t["add_perm_panel_sub"]}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
<!-- Select from all system permissions -->
<div class="flex items-center gap-2">
<select id="select-system-perm-to-add" class="unfold-input-field flex-1">
<option value="">{t["select_system_perm_default"]}</option>
<!-- Populated by JS -->
</select>
<button type="button" id="btn-add-system-perm" class="unfold-btn-primary">
{t["btn_grant"]}
</button>
</div>
<!-- Add custom permission code -->
<div class="flex items-center gap-2">
<input type="text" id="input-custom-perm-key" placeholder="{t['custom_key_placeholder']}" class="unfold-input-field flex-1">
<button type="button" id="btn-add-custom-perm" class="unfold-btn-secondary">
{t["btn_add_key"]}
</button>
</div>
</div>
</div>
<!-- Categories List -->
<div class="flex flex-col gap-2" id="modal-categories-list">
<!-- Populated by JS -->
</div>
</div>
<!-- ========================================== -->
<!-- TAB 3: INSTITUTIONS ACCESS -->
<!-- ========================================== -->
<div id="tab-institutions" class="access-tab-content space-y-5 hidden">
<div class="unfold-panel-box space-y-3">
<div class="flex items-center gap-2.5">
<div class="overview-header-icon-box" style="width: 2.25rem; height: 2.25rem;">
<span class="material-symbols-outlined" style="font-size: 20px;">apartment</span>
</div>
<div>
<h4 class="text-xs font-bold text-font-important-light dark:text-font-important-dark">{t["inst_title"]}</h4>
<p class="text-[11px] text-font-subtle-light dark:text-font-subtle-dark">{t["inst_sub"]}</p>
</div>
</div>
<div class="space-y-1.5 pt-2">
<label class="text-xs font-semibold text-font-subtle-light dark:text-font-subtle-dark">{t["inst_input_label"]}</label>
<input type="text" id="modal-institution-input" placeholder="{t['inst_input_placeholder']}" class="unfold-input-field w-full">
</div>
</div>
<div class="unfold-panel-box space-y-2">
<h5 class="text-xs font-bold text-font-important-light dark:text-font-important-dark">{t["inst_guidelines_title"]}</h5>
<ul class="text-xs text-font-subtle-light dark:text-font-subtle-dark space-y-1 list-disc list-inside">
<li>{t["inst_guideline_1"]}</li>
<li>{t["inst_guideline_2"]}</li>
</ul>
</div>
</div>
<!-- ========================================== -->
<!-- TAB 4: REGIONS ACCESS (Managed Regions) -->
<!-- ========================================== -->
<div id="tab-regions" class="access-tab-content space-y-5 hidden">
<div class="flex items-center justify-between">
<div>
<h4 class="text-xs font-bold text-font-important-light dark:text-font-important-dark flex items-center gap-1.5">
<span class="material-symbols-outlined text-base text-emerald-600">public</span>
<span>{t["regions_title"]}</span>
</h4>
<p class="text-[11px] text-font-subtle-light dark:text-font-subtle-dark mt-0.5">
{t["regions_sub"]}
</p>
</div>
<span class="text-xs font-semibold text-emerald-600" id="modal-regions-selected-counter">0 {t["regions_selected_count"]}</span>
</div>
<!-- Regions Cards Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2.5" id="modal-regions-cards-container">
<!-- Rendered by JS -->
</div>
</div>
</div>
<!-- Modal Footer -->
<div id="access-modal-footer" class="access-modal-footer">
<div class="text-xs text-font-subtle-light dark:text-font-subtle-dark flex items-center gap-2 flex-wrap" id="modal-footer-summary">
<span>{t["footer_role"]} <strong id="footer-role-name" class="text-font-important-light dark:text-font-important-dark">{t["footer_no_role"]}</strong></span>
<span id="footer-extra-badge" class="hidden font-semibold text-primary-600 dark:text-primary-400">• 0 {t["footer_extra"]}</span>
<span id="footer-regions-badge" class="hidden font-semibold text-emerald-600 dark:text-emerald-400">• 0 {t["footer_regions"]}</span>
</div>
<div class="flex items-center gap-2.5 self-end sm:self-auto">
<button type="button" id="btn-cancel-access-modal" class="unfold-btn-secondary">
{t["btn_cancel"]}
</button>
<button type="button" id="btn-apply-access-modal" class="unfold-btn-primary">
<span class="material-symbols-outlined" style="font-size: 16px;">check</span>
<span>{t["btn_apply"]}</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- =================================================================== -->
<!-- JAVASCRIPT CONTROLLER -->
<!-- =================================================================== -->
<script>
(function() {{
const rolesEl = document.getElementById('access-data-roles');
const categoriesEl = document.getElementById('access-data-categories');
const regionsEl = document.getElementById('access-data-regions');
const groupsEl = document.getElementById('access-data-groups');
const initialEl = document.getElementById('access-data-initial');
const permsMapEl = document.getElementById('access-data-perms-map');
const i18nEl = document.getElementById('access-data-i18n');
const rolesData = rolesEl ? JSON.parse(rolesEl.textContent || '{{}}') : {{}};
const categoriesData = categoriesEl ? JSON.parse(categoriesEl.textContent || '[]') : [];
const regionsData = regionsEl ? JSON.parse(regionsEl.textContent || '[]') : [];
const groupsData = groupsEl ? JSON.parse(groupsEl.textContent || '[]') : [];
const initialData = initialEl ? JSON.parse(initialEl.textContent || '{{}}') : {{}};
const permsMap = permsMapEl ? JSON.parse(permsMapEl.textContent || '{{}}') : {{}};
const i18n = i18nEl ? JSON.parse(i18nEl.textContent || '{{}}') : {{}};
// Working State
let state = {{
roleId: initialData.roleId || "",
userType: initialData.userType || "client",
institution: initialData.institution || "",
groups: Array.isArray(initialData.groups) ? [...initialData.groups] : [],
regions: Array.isArray(initialData.regions) ? [...initialData.regions] : [],
overrides: initialData.overrides ? Object.assign({{}}, initialData.overrides) : {{}},
}};
const hiddenInput = document.getElementById('id_{escape(name)}');
// ─── DOM Elements ───────────────────────────────────────────
const modalOverlay = document.getElementById('access-modal-overlay');
const modalDialog = document.getElementById('access-modal-dialog');
const btnOpenModal = document.getElementById('btn-open-access-modal');
const btnHeaderAddPerm = document.getElementById('btn-header-add-perm');
const btnAddPermCard = document.getElementById('btn-add-perm-card');
const btnChangeRoleCard = document.getElementById('btn-change-role-card');
const btnCloseX = document.getElementById('btn-close-access-modal-x');
const btnCancel = document.getElementById('btn-cancel-access-modal');
const btnApply = document.getElementById('btn-apply-access-modal');
// Overview elements
const ovRoleName = document.getElementById('overview-role-name');
const ovPermStatus = document.getElementById('overview-perm-status');
const ovExtraPerms = document.getElementById('overview-extra-perms-container');
const ovInstitution = document.getElementById('overview-institution-name');
const ovRegions = document.getElementById('overview-regions-container');
// Modal elements
const roleCardsContainer = document.getElementById('modal-role-cards-container');
const rolePreviewCount = document.getElementById('role-preview-count');
const rolePreviewBadges = document.getElementById('role-preview-badges');
const userTypeSelect = document.getElementById('modal-user-type-select');
const groupsContainer = document.getElementById('modal-groups-container');
const permSearchInput = document.getElementById('modal-perm-search');
const resetOverridesBtn = document.getElementById('modal-reset-overrides-btn');
const categoriesListContainer = document.getElementById('modal-categories-list');
const institutionInput = document.getElementById('modal-institution-input');
const regionsContainer = document.getElementById('modal-regions-cards-container');
const regionsSelectedCounter = document.getElementById('modal-regions-selected-counter');
// Add New Permission elements
const selectSystemPerm = document.getElementById('select-system-perm-to-add');
const btnAddSystemPerm = document.getElementById('btn-add-system-perm');
const inputCustomPermKey = document.getElementById('input-custom-perm-key');
const btnAddCustomPerm = document.getElementById('btn-add-custom-perm');
// Footer & Tab badges
const tabExtraCountBadge = document.getElementById('tab-extra-count-badge');
const tabRegionsCountBadge = document.getElementById('tab-regions-count-badge');
const footerRoleName = document.getElementById('footer-role-name');
const footerExtraBadge = document.getElementById('footer-extra-badge');
const footerRegionsBadge = document.getElementById('footer-regions-badge');
// ─── Teleport Modal to <body> ───────────────────────────────
function ensureModalAttachedToBody() {{
const overlay = document.getElementById('access-modal-overlay');
if (overlay && document.body && overlay.parentElement !== document.body) {{
document.body.appendChild(overlay);
}}
}}
// ─── Helper: Get Active Role Permissions ────────────────────
function getActiveRolePerms() {{
if (state.roleId && rolesData[state.roleId]) {{
return rolesData[state.roleId].permissions || {{}};
}}
return {{}};
}}
// ─── Helper: Get Extra & Denied Permissions ─────────────────
function getExtraAndDeniedPerms() {{
const rolePerms = getActiveRolePerms();
const extra = [];
const denied = [];
for (const [key, val] of Object.entries(state.overrides)) {{
if (val === true && !rolePerms[key]) {{
extra.push(key);
}} else if (val === false && rolePerms[key]) {{
denied.push(key);
}}
}}
return {{ extra, denied }};
}}
// ─── Tab Switching Helper ───────────────────────────────────
function switchTab(targetTabId) {{
document.querySelectorAll('.access-tab-btn').forEach(b => {{
b.classList.remove('active');
}});
const activeBtn = document.querySelector(`.access-tab-btn[data-tab="${{targetTabId}}"]`);
if (activeBtn) {{
activeBtn.classList.add('active');
}}
document.querySelectorAll('.access-tab-content').forEach(c => {{
c.style.display = 'none';
c.classList.add('hidden');
}});
const targetContent = document.getElementById(targetTabId);
if (targetContent) {{
targetContent.style.display = 'block';
targetContent.classList.remove('hidden');
}}
// Reset scroll inside modal body
const modalBody = document.getElementById('modal-tabs-body');
if (modalBody) modalBody.scrollTop = 0;
}}
function openModalToTab(targetTabId) {{
ensureModalAttachedToBody();
renderModal();
const overlay = document.getElementById('access-modal-overlay');
if (overlay) {{
overlay.classList.add('is-open');
overlay.style.display = 'flex';
document.body.classList.add('access-modal-open');
document.documentElement.classList.add('access-modal-open');
}}
switchTab(targetTabId);
}}
function closeModal() {{
const overlay = document.getElementById('access-modal-overlay');
if (overlay) {{
overlay.classList.remove('is-open');
overlay.style.display = 'none';
document.body.classList.remove('access-modal-open');
document.documentElement.classList.remove('access-modal-open');
}}
}}
// ─── Render: Update Overview Card on Page ───────────────────
function renderOverviewCard() {{
const role = rolesData[state.roleId];
const roleName = role ? role.name : (i18n.unassigned_role || "Unassigned / No Role");
if (ovRoleName) ovRoleName.textContent = roleName;
const {{ extra, denied }} = getExtraAndDeniedPerms();
if (ovPermStatus) {{
if (extra.length === 0 && denied.length === 0) {{
ovPermStatus.innerHTML = `
<span class="overview-badge overview-badge-success">
<span class="material-symbols-outlined text-xs">verified</span>
<span>${{escape(i18n.all_perms_match || "All permissions match base role")}}</span>
</span>
`;
}} else if (extra.length > 0) {{
ovPermStatus.innerHTML = `
<span class="overview-badge overview-badge-role">
<span class="material-symbols-outlined text-xs">auto_awesome</span>
<span>+ ${{extra.length}} ${{escape(i18n.custom_perms_suffix || "custom permissions beyond role")}}</span>
</span>
`;
}} else {{
ovPermStatus.innerHTML = `
<span class="overview-badge" style="background-color: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.25); color: #b45309;">
<span>${{denied.length}} ${{escape(i18n.revoked_perms_suffix || "permissions revoked")}}</span>
</span>
`;
}}
}}
// Extra perms chips container
if (ovExtraPerms) {{
if (extra.length === 0) {{
ovExtraPerms.innerHTML = `<span class="text-xs text-slate-400 italic">${{escape(i18n.no_custom_perms || "No custom permissions (Standard role access)")}}</span>`;
}} else {{
const chips = extra.slice(0, 3).map(k => {{
const p = permsMap[k];
const label = p ? p.label : k;
return `<span class="overview-badge overview-badge-extra">${{escape(label)}}</span>`;
}}).join(' ');
const more = extra.length > 3 ? `<span class="text-[10px] text-slate-400 font-semibold">+ ${{extra.length - 3}} ${{escape(i18n.more_suffix || "more")}}</span>` : '';
ovExtraPerms.innerHTML = `<div class="flex flex-wrap items-center gap-1.5">${{chips}} ${{more}}</div>`;
}}
}}
// Institution name
if (ovInstitution) {{
ovInstitution.innerHTML = `
<span class="material-symbols-outlined text-base text-slate-400">apartment</span>
<span class="truncate">${{escape(state.institution || (i18n.no_institution || "No affiliated institution"))}}</span>
`;
}}
// Managed regions
if (ovRegions) {{
if (state.regions.length === 0) {{
ovRegions.innerHTML = `<span class="text-xs text-slate-400 italic">${{escape(i18n.regions_unlimited || "Global / Unlimited access")}}</span>`;
}} else {{
const rChips = state.regions.slice(0, 3).map(id => {{
const reg = regionsData.find(r => r.id === id);
return `<span class="overview-badge overview-badge-region">${{escape(reg ? reg.name : id)}}</span>`;
}}).join(' ');
const rMore = state.regions.length > 3 ? `<span class="text-[10px] text-slate-400 font-semibold">+ ${{state.regions.length - 3}} ${{escape(i18n.more_suffix || "more")}}</span>` : '';
ovRegions.innerHTML = `<div class="flex flex-wrap items-center gap-1.5">${{rChips}} ${{rMore}}</div>`;
}}
}}
}}
// ─── Render: Modal Content ──────────────────────────────────
function renderModal() {{
// 1. Roles Cards in Tab 1
if (roleCardsContainer) {{
let rolesHtml = '';
for (const [rId, r] of Object.entries(rolesData)) {{
const isSelected = (state.roleId === rId);
const permsCount = Object.values(r.permissions || {{}}).filter(Boolean).length;
rolesHtml += `
<div class="modal-role-card ${{isSelected ? 'is-selected' : ''}}" data-role-id="${{escape(rId)}}">
<div class="flex items-center justify-between mb-1.5">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-base ${{isSelected ? 'text-indigo-600 dark:text-indigo-400' : 'text-slate-400'}}">badge</span>
<span class="text-xs font-bold text-slate-800 dark:text-slate-100">${{escape(r.name)}}</span>
</div>
${{isSelected ? '<span class="material-symbols-outlined text-indigo-600 dark:text-indigo-400 text-base">check_circle</span>' : ''}}
</div>
<p class="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-2 leading-relaxed">${{escape(r.description || (i18n.default_role_desc || "Organizational role with default permissions"))}}</p>
<span class="inline-block mt-2 text-[10px] font-semibold text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700/60 px-2 py-0.5 rounded-full">${{permsCount}} ${{escape(i18n.default_perms_chip || "default permissions")}}</span>
</div>
`;
}}
roleCardsContainer.innerHTML = rolesHtml;
}}
// 2. Role Preview in Tab 1
const role = rolesData[state.roleId];
if (role) {{
const grantedPerms = Object.entries(role.permissions || {{}}).filter(([_, v]) => v).map(([k]) => k);
if (rolePreviewCount) rolePreviewCount.textContent = `${{grantedPerms.length}} ${{i18n.active_perms_count || "active permissions"}}`;
if (rolePreviewBadges) {{
rolePreviewBadges.innerHTML = grantedPerms.map(k => {{
const p = permsMap[k];
return `<span class="overview-badge overview-badge-extra"><span class="material-symbols-outlined text-xs text-emerald-600">check</span>${{escape(p ? p.label : k)}}</span>`;
}}).join(' ');
}}
}} else {{
if (rolePreviewCount) rolePreviewCount.textContent = `0 ${{i18n.active_perms_count || "active permissions"}}`;
if (rolePreviewBadges) rolePreviewBadges.innerHTML = `<span class="text-xs text-slate-400 italic">${{escape(i18n.no_role_selected || "No role selected.")}}</span>`;
}}
// 3. User Type Select
if (userTypeSelect) userTypeSelect.value = state.userType;
// 4. Groups in Tab 1
if (groupsContainer) {{
groupsContainer.innerHTML = groupsData.map(g => {{
const isChecked = state.groups.includes(g.id);
return `
<label class="modal-group-chip ${{isChecked ? 'is-checked' : ''}}" data-group-id="${{escape(g.id)}}">
<input type="checkbox" class="hidden modal-group-input" value="${{escape(g.id)}}" ${{isChecked ? 'checked' : ''}}>
<span class="material-symbols-outlined text-sm ${{isChecked ? 'text-indigo-600 dark:text-indigo-400' : 'text-slate-400'}}">${{isChecked ? 'check_circle' : 'add_circle'}}</span>
<span>${{escape(g.name)}}</span>
</label>
`;
}}).join('');
}}
// 5. Populate System Permissions Dropdown for Quick Add
if (selectSystemPerm) {{
const rolePerms = getActiveRolePerms();
let optionsHtml = `<option value="">${{escape(i18n.select_system_perm_default || "-- Select from all system permissions --")}}</option>`;
for (const cat of categoriesData) {{
optionsHtml += `<optgroup label="${{escape(cat.title)}}">`;
for (const p of cat.perms) {{
const isInherited = !!rolePerms[p.name];
const hasAllow = state.overrides[p.name] === true;
const extraLabel = isInherited ? (i18n.in_role_tag || " (in role)") : (hasAllow ? (i18n.active_custom_tag || " (active custom)") : "");
optionsHtml += `<option value="${{escape(p.name)}}">${{escape(p.label)}}${{extraLabel}}</option>`;
}}
optionsHtml += `</optgroup>`;
}}
selectSystemPerm.innerHTML = optionsHtml;
}}
// 6. Permissions Matrix in Tab 2
if (categoriesListContainer) {{
const rolePerms = getActiveRolePerms();
let catHtml = '';
// Render Standard Categories
for (const cat of categoriesData) {{
catHtml += `
<div class="matrix-cat-card">
<div class="matrix-cat-header">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-indigo-600 dark:text-indigo-400 text-base">${{escape(cat.icon)}}</span>
<span class="font-bold text-xs text-slate-800 dark:text-slate-200">${{escape(cat.title)}}</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-slate-200/60 dark:bg-slate-700 text-slate-600 dark:text-slate-400 font-medium">${{cat.perms.length}}</span>
</div>
<span class="material-symbols-outlined text-slate-400 text-base transition-transform cat-chevron">expand_more</span>
</div>
<div class="matrix-cat-body">
`;
for (const p of cat.perms) {{
const pName = p.name;
const pLabel = p.label;
const roleVal = !!rolePerms[pName];
const hasOverride = state.overrides.hasOwnProperty(pName);
const overrideVal = hasOverride ? state.overrides[pName] : null;
let statusText = '';
if (overrideVal === true) {{
statusText = `<span class="text-emerald-600 dark:text-emerald-400 font-bold">${{escape(i18n.status_custom_allow || "✓ Custom Granted (Allow)")}}</span>`;
}} else if (overrideVal === false) {{
statusText = `<span class="text-red-600 dark:text-red-400 font-bold">${{escape(i18n.status_custom_deny || "✕ Revoked (Deny)")}}</span>`;
}} else {{
statusText = roleVal
? `<span class="text-indigo-600 dark:text-indigo-400 font-semibold">${{escape(i18n.status_inherited_active || "● Inherited: Active from role")}}</span>`
: `<span class="text-slate-400">${{escape(i18n.status_inherited_inactive || "○ Inherited: Inactive")}}</span>`;
}}
catHtml += `
<div class="matrix-perm-row" data-perm-name="${{escape(pName)}}">
<div class="flex flex-col pr-1 min-w-0">
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200 truncate" title="${{escape(pLabel)}}">${{escape(pLabel)}}</span>
<span class="text-[10px] mt-0.5 perm-status-label">${{statusText}}</span>
</div>
<div class="perm-btn-group">
<button type="button" data-val="inherit" class="perm-btn ${{overrideVal === null ? 'is-active-inherit' : ''}}">${{escape(i18n.btn_inherit || "Inherit")}}</button>
<button type="button" data-val="allow" class="perm-btn ${{overrideVal === true ? 'is-active-allow' : ''}}">${{escape(i18n.btn_allow || "Allow")}}</button>
<button type="button" data-val="deny" class="perm-btn ${{overrideVal === false ? 'is-active-deny' : ''}}">${{escape(i18n.btn_deny || "Deny")}}</button>
</div>
</div>
`;
}}
catHtml += `
</div>
</div>
`;
}}
// Render Custom Extra Permission Flags (Keys not in standard categories)
const standardKeys = new Set(Object.keys(permsMap));
const customFlags = Object.keys(state.overrides).filter(k => !standardKeys.has(k));
if (customFlags.length > 0) {{
catHtml += `
<div class="matrix-cat-card" style="border-color: rgba(99, 102, 241, 0.3);">
<div class="matrix-cat-header" style="background-color: rgba(99, 102, 241, 0.08);">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-indigo-600 dark:text-indigo-400 text-base">auto_awesome</span>
<span class="font-bold text-xs text-indigo-900 dark:text-indigo-200">${{escape(i18n.custom_flags_title || "Custom Granted Flags")}}</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-indigo-600 text-white font-bold">${{customFlags.length}}</span>
</div>
<span class="material-symbols-outlined text-indigo-400 text-base transition-transform cat-chevron">expand_more</span>
</div>
<div class="matrix-cat-body">
`;
for (const cKey of customFlags) {{
const isAllow = state.overrides[cKey] === true;
catHtml += `
<div class="matrix-perm-row" style="background-color: rgba(99, 102, 241, 0.04); border-color: rgba(99, 102, 241, 0.2);">
<div class="flex flex-col pr-1 min-w-0">
<span class="font-mono font-bold text-slate-800 dark:text-slate-100 truncate">${{escape(cKey)}}</span>
<span class="text-[10px] text-emerald-600 font-semibold">${{isAllow ? escape(i18n.status_custom_flag_allow || "✓ Custom active permission") : escape(i18n.status_custom_flag_deny || "✕ Custom revoked permission")}}</span>
</div>
<button type="button" class="btn-remove-custom-perm overview-small-btn" data-perm-key="${{escape(cKey)}}" title="${{escape(i18n.delete_perm_title || "Delete this permission")}}">
<span class="material-symbols-outlined text-xs">delete</span>
</button>
</div>
`;
}}
catHtml += `
</div>
</div>
`;
}}
categoriesListContainer.innerHTML = catHtml;
}}
// 7. Institution Input in Tab 3
if (institutionInput) institutionInput.value = state.institution;
// 8. Regions in Tab 4
if (regionsContainer) {{
regionsContainer.innerHTML = regionsData.map(reg => {{
const isChecked = state.regions.includes(reg.id);
return `
<div class="modal-region-card ${{isChecked ? 'is-selected' : ''}}" data-region-id="${{escape(reg.id)}}">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-base ${{isChecked ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'}}">public</span>
<span class="text-xs font-semibold">${{escape(reg.name)}}</span>
</div>
<span class="material-symbols-outlined text-sm ${{isChecked ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-300'}}">${{isChecked ? 'check_circle' : 'circle'}}</span>
</div>
${{reg.code ? `<span class="inline-block text-[10px] font-normal text-slate-400 mt-1 uppercase">${{escape(reg.code)}}</span>` : ''}}
</div>
`;
}}).join('');
}}
if (regionsSelectedCounter) regionsSelectedCounter.textContent = `${{state.regions.length}} ${{i18n.regions_selected_count || "regions selected"}}`;
// 9. Footer & Badges Summary
const {{ extra, denied }} = getExtraAndDeniedPerms();
if (tabExtraCountBadge) {{
if (extra.length > 0) {{
tabExtraCountBadge.textContent = `+${{extra.length}}`;
tabExtraCountBadge.classList.remove('hidden');
}} else {{
tabExtraCountBadge.classList.add('hidden');
}}
}}
if (tabRegionsCountBadge) {{
if (state.regions.length > 0) {{
tabRegionsCountBadge.textContent = `${{state.regions.length}}`;
tabRegionsCountBadge.classList.remove('hidden');
}} else {{
tabRegionsCountBadge.classList.add('hidden');
}}
}}
if (footerRoleName) {{
footerRoleName.textContent = role ? role.name : (i18n.footer_no_role || "No Role");
}}
if (footerExtraBadge) {{
if (extra.length > 0) {{
footerExtraBadge.textContent = `• ${{extra.length}} ${{i18n.footer_extra || "custom permissions"}}`;
footerExtraBadge.classList.remove('hidden');
}} else {{
footerExtraBadge.classList.add('hidden');
}}
}}
if (footerRegionsBadge) {{
if (state.regions.length > 0) {{
footerRegionsBadge.textContent = `• ${{state.regions.length}} ${{i18n.footer_regions || "geographic regions"}}`;
footerRegionsBadge.classList.remove('hidden');
}} else {{
footerRegionsBadge.classList.add('hidden');
}}
}}
}}
// ─── Event Handlers ─────────────────────────────────────────
// Open Modal (General)
if (btnOpenModal) {{
btnOpenModal.addEventListener('click', function(e) {{
e.preventDefault();
openModalToTab('tab-roles');
}});
}}
// Open Modal directly to Permissions tab from Overview Card buttons
if (btnHeaderAddPerm) {{
btnHeaderAddPerm.addEventListener('click', function(e) {{
e.preventDefault();
openModalToTab('tab-permissions');
}});
}}
if (btnAddPermCard) {{
btnAddPermCard.addEventListener('click', function(e) {{
e.preventDefault();
openModalToTab('tab-permissions');
}});
}}
// Open Modal directly to Roles tab from Overview Card
if (btnChangeRoleCard) {{
btnChangeRoleCard.addEventListener('click', function(e) {{
e.preventDefault();
openModalToTab('tab-roles');
}});
}}
// Close Modal
if (btnCloseX) btnCloseX.addEventListener('click', closeModal);
if (btnCancel) btnCancel.addEventListener('click', closeModal);
// Backdrop click to close
if (modalOverlay) {{
modalOverlay.addEventListener('click', function(e) {{
if (e.target === modalOverlay) {{
closeModal();
}}
}});
}}
// Wheel isolation to guarantee background never scrolls while interacting with modal
if (modalDialog) {{
modalDialog.addEventListener('wheel', function(e) {{
e.stopPropagation();
}}, {{ passive: false }});
}}
if (modalOverlay) {{
modalOverlay.addEventListener('wheel', function(e) {{
if (e.target === modalOverlay) {{
e.preventDefault();
e.stopPropagation();
}}
}}, {{ passive: false }});
}}
// Escape key to close
document.addEventListener('keydown', function(e) {{
if (e.key === 'Escape') {{
const overlay = document.getElementById('access-modal-overlay');
if (overlay && (overlay.classList.contains('is-open') || overlay.style.display === 'flex')) {{
closeModal();
}}
}}
}});
// Add System Permission from Dropdown in Tab 2
if (btnAddSystemPerm) {{
btnAddSystemPerm.addEventListener('click', function() {{
if (!selectSystemPerm) return;
const permKey = selectSystemPerm.value;
if (permKey) {{
state.overrides[permKey] = true; // Grant explicitly!
renderModal();
selectSystemPerm.value = "";
}}
}});
}}
// Add Custom Permission Flag Key in Tab 2
if (btnAddCustomPerm) {{
btnAddCustomPerm.addEventListener('click', function() {{
if (!inputCustomPermKey) return;
const rawKey = inputCustomPermKey.value.trim().toLowerCase();
const safeKey = rawKey.replace(/[^a-z0-9_]/g, '_');
if (safeKey) {{
state.overrides[safeKey] = true; // Grant explicitly!
renderModal();
inputCustomPermKey.value = "";
}}
}});
}}
// Tab Switching & Delegated Actions
document.addEventListener('click', function(e) {{
const tabBtn = e.target.closest('.access-tab-btn');
if (tabBtn) {{
const targetId = tabBtn.dataset.tab;
switchTab(targetId);
return;
}}
// Remove custom flag button
const btnRemoveCustom = e.target.closest('.btn-remove-custom-perm');
if (btnRemoveCustom) {{
const cKey = btnRemoveCustom.dataset.permKey;
if (cKey) {{
delete state.overrides[cKey];
renderModal();
}}
return;
}}
// Role Card Selection in Tab 1
const roleCard = e.target.closest('.modal-role-card');
if (roleCard) {{
state.roleId = roleCard.dataset.roleId;
renderModal();
return;
}}
// Group Pill in Tab 1
const groupChip = e.target.closest('.modal-group-chip');
if (groupChip) {{
const gId = groupChip.dataset.groupId;
if (state.groups.includes(gId)) {{
state.groups = state.groups.filter(id => id !== gId);
}} else {{
state.groups.push(gId);
}}
renderModal();
return;
}}
// Region Card in Tab 4
const regionCard = e.target.closest('.modal-region-card');
if (regionCard) {{
const rId = regionCard.dataset.regionId;
if (state.regions.includes(rId)) {{
state.regions = state.regions.filter(id => id !== rId);
}} else {{
state.regions.push(rId);
}}
renderModal();
return;
}}
// Permission 3-Way Button in Tab 2
const permBtn = e.target.closest('.perm-btn');
if (permBtn) {{
const row = permBtn.closest('.matrix-perm-row');
if (!row) return;
const pName = row.dataset.permName;
const val = permBtn.dataset.val;
if (val === 'inherit') {{
delete state.overrides[pName];
}} else if (val === 'allow') {{
state.overrides[pName] = true;
}} else if (val === 'deny') {{
state.overrides[pName] = false;
}}
renderModal();
return;
}}
// Category Accordion
const catHeader = e.target.closest('.matrix-cat-header');
if (catHeader) {{
const card = catHeader.closest('.matrix-cat-card');
if (!card) return;
const body = card.querySelector('.matrix-cat-body');
const chevron = catHeader.querySelector('.cat-chevron');
if (body) {{
const isHidden = body.classList.toggle('hidden');
if (chevron) chevron.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)';
}}
return;
}}
}});
// Permission Search
if (permSearchInput) {{
permSearchInput.addEventListener('input', function() {{
const q = this.value.toLowerCase().trim();
document.querySelectorAll('.matrix-perm-row').forEach(row => {{
const text = row.textContent.toLowerCase();
row.style.display = text.includes(q) ? 'flex' : 'none';
}});
document.querySelectorAll('.matrix-cat-card').forEach(card => {{
const visible = card.querySelectorAll('.matrix-perm-row:not([style*="display: none"])');
card.style.display = visible.length > 0 ? 'block' : 'none';
}});
}});
}}
// Reset Overrides
if (resetOverridesBtn) {{
resetOverridesBtn.addEventListener('click', function() {{
state.overrides = {{}};
renderModal();
}});
}}
// Institution Input
if (institutionInput) {{
institutionInput.addEventListener('input', function() {{
state.institution = this.value;
}});
}}
// User Type Select
if (userTypeSelect) {{
userTypeSelect.addEventListener('change', function() {{
state.userType = this.value;
}});
}}
// Apply Changes (Sync to Django Form Inputs)
if (btnApply) {{
btnApply.addEventListener('click', function() {{
// 1. Sync overrides to hidden textarea
if (hiddenInput) {{
hiddenInput.value = JSON.stringify(state.overrides);
}}
// 2. Sync Role dropdown
const formRoleSelect = document.querySelector('select[name="role"]');
if (formRoleSelect) {{
formRoleSelect.value = state.roleId;
formRoleSelect.dispatchEvent(new Event('change', {{ bubbles: true }}));
}}
// 3. Sync User Type
const formUserTypeSelect = document.querySelector('select[name="user_type"]');
if (formUserTypeSelect) {{
formUserTypeSelect.value = state.userType;
formUserTypeSelect.dispatchEvent(new Event('change', {{ bubbles: true }}));
}}
// 4. Sync Institution
const formInstInput = document.querySelector('input[name="institution_name"]');
if (formInstInput) {{
formInstInput.value = state.institution;
formInstInput.dispatchEvent(new Event('change', {{ bubbles: true }}));
}}
// 5. Sync Groups checkboxes
document.querySelectorAll('input[name="groups"]').forEach(cb => {{
cb.checked = state.groups.includes(cb.value);
cb.dispatchEvent(new Event('change', {{ bubbles: true }}));
}});
// 6. Sync Regions checkboxes
document.querySelectorAll('input[name="managed_regions"]').forEach(cb => {{
cb.checked = state.regions.includes(cb.value);
cb.dispatchEvent(new Event('change', {{ bubbles: true }}));
}});
// 7. Update Overview Card on Page
renderOverviewCard();
// 8. Close Modal
closeModal();
}});
}}
// Initial render on load
function init() {{
ensureModalAttachedToBody();
renderOverviewCard();
// Hide the redundant, crowded raw rows from Unfold's change form
// while keeping them alive in the DOM for Django POST submission!
const crowdedSelectors = [
'.field-role',
'.field-user_type',
'.field-groups',
'.field-managed_regions'
];
crowdedSelectors.forEach(sel => {{
document.querySelectorAll(sel).forEach(el => {{
if (el.closest('.tab') || el.classList.contains('form-row')) {{
el.style.display = 'none';
}}
}});
}});
}}
// Eagerly teleport to body if document.body exists immediately
if (document.body) {{
ensureModalAttachedToBody();
}}
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', init);
}} else {{
init();
}}
window.addEventListener('load', function() {{
setTimeout(init, 50);
}});
}})();
</script>
'''
]
return mark_safe("\n".join(html))