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.
414 lines
21 KiB
414 lines
21 KiB
import json
|
|
from django import forms
|
|
from django.utils.html import escape
|
|
from django.utils.safestring import mark_safe
|
|
|
|
from apps.account.models.role import Role, PERMISSION_CATEGORIES, ALL_PERMISSION_FIELDS
|
|
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
class GroupPillSelectMultipleWidget(forms.CheckboxSelectMultiple):
|
|
"""
|
|
Renders Django auth Groups as modern, interactive clickable pill cards.
|
|
Replaces the cumbersome dual-listbox with instant one-click toggle chips.
|
|
"""
|
|
def render(self, name, value, attrs=None, renderer=None):
|
|
if value is None:
|
|
value = []
|
|
elif isinstance(value, str):
|
|
value = [value]
|
|
else:
|
|
value = [str(v) for v in value]
|
|
|
|
attrs = attrs or {}
|
|
output = [
|
|
'<div class="group-pills-wrapper my-2">',
|
|
' <div class="flex flex-wrap gap-2.5 items-center">'
|
|
]
|
|
|
|
has_choices = False
|
|
for option_value, option_label in self.choices:
|
|
if not option_value:
|
|
continue
|
|
has_choices = True
|
|
is_checked = str(option_value) in value
|
|
checked_attr = 'checked' if is_checked else ''
|
|
|
|
card_class = (
|
|
'group-pill-card cursor-pointer select-none inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border text-sm transition-all duration-150 '
|
|
+ ('bg-indigo-50 border-indigo-400 text-indigo-700 shadow-xs dark:bg-indigo-950/60 dark:border-indigo-600 dark:text-indigo-300 font-semibold'
|
|
if is_checked else 'bg-white border-slate-200 text-slate-700 hover:bg-slate-50 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800')
|
|
)
|
|
icon_name = 'check_circle' if is_checked else 'add_circle'
|
|
icon_class = 'material-symbols-outlined text-base pill-icon ' + ('text-indigo-600 dark:text-indigo-400' if is_checked else 'text-slate-400')
|
|
|
|
output.append(
|
|
f'<label class="{card_class}">'
|
|
f'<input type="checkbox" name="{escape(name)}" value="{escape(option_value)}" {checked_attr} class="hidden group-pill-input">'
|
|
f'<span class="{icon_class}">{icon_name}</span>'
|
|
f'<span class="pill-title">{escape(option_label)}</span>'
|
|
f'</label>'
|
|
)
|
|
|
|
if not has_choices:
|
|
output.append('<span class="text-xs text-slate-400 italic">No groups available in system.</span>')
|
|
|
|
output.append('''
|
|
</div>
|
|
<div class="text-xs text-slate-400 dark:text-slate-500 mt-2">
|
|
Click any group chip to instantly assign or revoke membership for this user.
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
document.addEventListener('change', function(e) {
|
|
if (e.target && e.target.classList.contains('group-pill-input')) {
|
|
const label = e.target.closest('.group-pill-card');
|
|
if (!label) return;
|
|
const icon = label.querySelector('.pill-icon');
|
|
if (e.target.checked) {
|
|
label.className = 'group-pill-card cursor-pointer select-none inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border text-sm transition-all duration-150 bg-indigo-50 border-indigo-400 text-indigo-700 shadow-xs dark:bg-indigo-950/60 dark:border-indigo-600 dark:text-indigo-300 font-semibold';
|
|
if (icon) {
|
|
icon.textContent = 'check_circle';
|
|
icon.className = 'material-symbols-outlined text-base text-indigo-600 dark:text-indigo-400 pill-icon';
|
|
}
|
|
} else {
|
|
label.className = 'group-pill-card cursor-pointer select-none inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border text-sm transition-all duration-150 bg-white border-slate-200 text-slate-700 hover:bg-slate-50 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800';
|
|
if (icon) {
|
|
icon.textContent = 'add_circle';
|
|
icon.className = 'material-symbols-outlined text-base text-slate-400 pill-icon';
|
|
}
|
|
}
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
''')
|
|
return mark_safe('\n'.join(output))
|
|
|
|
|
|
class RolePermissionsMatrixWidget(forms.Widget):
|
|
"""
|
|
Renders an interactive, categorized visual permissions matrix.
|
|
Allows point-and-click permission overriding (Inherit / Allow / Deny)
|
|
with real-time inheritance tracking from the user's Assigned Role.
|
|
"""
|
|
def render(self, name, value, attrs=None, renderer=None):
|
|
if isinstance(value, str):
|
|
try:
|
|
overrides = json.loads(value) if value.strip() else {}
|
|
except Exception:
|
|
overrides = {}
|
|
elif isinstance(value, dict):
|
|
overrides = value
|
|
else:
|
|
overrides = {}
|
|
|
|
# Fetch all roles and their permissions dictionary
|
|
roles_data = {}
|
|
for r in Role.objects.all():
|
|
roles_data[str(r.id)] = r.get_permissions_dict()
|
|
|
|
categories_data = []
|
|
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)
|
|
except Exception:
|
|
vname = field_name.replace("_", " ").title()
|
|
perms_list.append({
|
|
"name": field_name,
|
|
"label": vname,
|
|
})
|
|
categories_data.append({
|
|
"title": cat_name_str,
|
|
"icon": icon,
|
|
"perms": perms_list,
|
|
})
|
|
|
|
roles_json_str = json.dumps(roles_data)
|
|
overrides_json_str = json.dumps(overrides)
|
|
|
|
html_parts = [
|
|
'<div id="role-perm-matrix-root" class="w-full my-3 font-sans">',
|
|
f'<textarea name="{escape(name)}" id="id_{escape(name)}" class="hidden">{escape(overrides_json_str)}</textarea>',
|
|
f'<script id="matrix-roles-data" type="application/json">{roles_json_str}</script>',
|
|
f'<script id="matrix-overrides-data" type="application/json">{overrides_json_str}</script>',
|
|
'''
|
|
<!-- Header Info & Search Toolbar -->
|
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-3 p-4 bg-slate-50 dark:bg-slate-900/60 rounded-xl border border-slate-200 dark:border-slate-800 mb-4 shadow-xs">
|
|
<div>
|
|
<h3 class="font-bold text-base text-slate-800 dark:text-slate-100 flex items-center gap-2">
|
|
<span class="material-symbols-outlined text-indigo-600 dark:text-indigo-400">tune</span>
|
|
<span>Application Permissions Matrix</span>
|
|
</h3>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
|
Permissions are automatically inherited from the user's <strong>Assigned Role</strong>. You can selectively override any permission below.
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<div class="relative w-full md:w-64">
|
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">search</span>
|
|
<input type="text" id="matrix-search-input" placeholder="Filter permissions..." class="w-full pl-9 pr-3 py-1.5 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-200 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500">
|
|
</div>
|
|
<button type="button" id="matrix-collapse-all-btn" class="px-2.5 py-1.5 text-xs font-medium rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors whitespace-nowrap">
|
|
Toggle All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Categories Container -->
|
|
<div class="flex flex-col gap-3" id="matrix-categories-list">
|
|
'''
|
|
]
|
|
|
|
for cat in categories_data:
|
|
cat_title = cat["title"]
|
|
cat_icon = cat["icon"]
|
|
cat_perms = cat["perms"]
|
|
|
|
html_parts.append(f'''
|
|
<div class="matrix-category-card border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden bg-white dark:bg-slate-900/50 transition-all shadow-xs" data-category="{escape(cat_title)}">
|
|
<!-- Category Header -->
|
|
<div class="matrix-cat-header flex items-center justify-between px-4 py-3 bg-slate-50/80 dark:bg-slate-800/40 cursor-pointer hover:bg-slate-100/70 dark:hover:bg-slate-800/80 select-none transition-colors border-b border-slate-100 dark:border-slate-800/60">
|
|
<div class="flex items-center gap-2.5">
|
|
<span class="material-symbols-outlined text-indigo-600 dark:text-indigo-400 text-lg">{cat_icon}</span>
|
|
<span class="font-semibold text-sm text-slate-800 dark:text-slate-200">{escape(cat_title)}</span>
|
|
<span class="text-xs px-2 py-0.5 rounded-full font-medium bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 cat-count-badge">
|
|
{len(cat_perms)} permissions
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs text-indigo-600 dark:text-indigo-400 font-medium cat-override-badge hidden">0 custom</span>
|
|
<span class="material-symbols-outlined text-slate-400 text-base transition-transform cat-chevron rotate-180">expand_more</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Category Items Grid -->
|
|
<div class="matrix-cat-body p-3.5 grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
|
''')
|
|
|
|
for p in cat_perms:
|
|
p_name = p["name"]
|
|
p_label = p["label"]
|
|
|
|
html_parts.append(f'''
|
|
<div class="matrix-perm-row flex items-center justify-between p-2.5 rounded-lg border border-slate-100 dark:border-slate-800/80 bg-slate-50/40 dark:bg-slate-800/20 hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors" data-perm-name="{escape(p_name)}">
|
|
<div class="flex flex-col pr-2 min-w-0">
|
|
<span class="text-xs font-semibold text-slate-800 dark:text-slate-200 truncate" title="{escape(p_label)}">
|
|
{escape(p_label)}
|
|
</span>
|
|
<span class="text-[11px] text-slate-400 mt-0.5 perm-status-label">
|
|
Checking role...
|
|
</span>
|
|
</div>
|
|
<!-- 3-Way Segmented Control -->
|
|
<div class="inline-flex rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 p-0.5 shrink-0 text-xs perm-btn-group">
|
|
<button type="button" data-val="inherit" class="px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn perm-btn-inherit bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-xs">
|
|
Inherit
|
|
</button>
|
|
<button type="button" data-val="allow" class="px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn perm-btn-allow text-slate-600 dark:text-slate-400 hover:text-green-600">
|
|
Allow
|
|
</button>
|
|
<button type="button" data-val="deny" class="px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn perm-btn-deny text-slate-600 dark:text-slate-400 hover:text-red-600">
|
|
Deny
|
|
</button>
|
|
</div>
|
|
</div>
|
|
''')
|
|
|
|
html_parts.append('''
|
|
</div>
|
|
</div>
|
|
''')
|
|
|
|
html_parts.append(f'''
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {{
|
|
const rolesEl = document.getElementById('matrix-roles-data');
|
|
const overridesEl = document.getElementById('matrix-overrides-data');
|
|
const rolesData = rolesEl ? JSON.parse(rolesEl.textContent || '{{}}') : {{}};
|
|
let currentOverrides = overridesEl ? JSON.parse(overridesEl.textContent || '{{}}') : {{}};
|
|
|
|
function getSelectedRoleId() {{
|
|
const roleSelect = document.querySelector('select[name="role"]');
|
|
return roleSelect ? roleSelect.value : null;
|
|
}}
|
|
|
|
function updateUI() {{
|
|
const roleId = getSelectedRoleId();
|
|
const activeRolePerms = (roleId && rolesData[roleId]) ? rolesData[roleId] : {{}};
|
|
const hiddenInput = document.getElementById('id_{escape(name)}');
|
|
|
|
document.querySelectorAll('.matrix-perm-row').forEach(row => {{
|
|
const permName = row.dataset.permName;
|
|
const statusLabel = row.querySelector('.perm-status-label');
|
|
const btnGroup = row.querySelector('.perm-btn-group');
|
|
if (!btnGroup) return;
|
|
const inheritBtn = btnGroup.querySelector('.perm-btn-inherit');
|
|
const allowBtn = btnGroup.querySelector('.perm-btn-allow');
|
|
const denyBtn = btnGroup.querySelector('.perm-btn-deny');
|
|
|
|
[inheritBtn, allowBtn, denyBtn].forEach(b => {{
|
|
if (b) b.className = 'px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn text-slate-600 dark:text-slate-400';
|
|
}});
|
|
|
|
const roleDefault = !!activeRolePerms[permName];
|
|
const hasOverride = currentOverrides.hasOwnProperty(permName);
|
|
const overrideVal = hasOverride ? currentOverrides[permName] : null;
|
|
|
|
if (overrideVal === true) {{
|
|
if (allowBtn) allowBtn.className = 'px-2 py-1 rounded-md text-[11px] font-bold transition-all perm-btn bg-green-600 text-white shadow-xs';
|
|
if (statusLabel) statusLabel.innerHTML = '<span class="text-green-600 dark:text-green-400 font-medium">✓ Force Allowed (Override)</span>';
|
|
}} else if (overrideVal === false) {{
|
|
if (denyBtn) denyBtn.className = 'px-2 py-1 rounded-md text-[11px] font-bold transition-all perm-btn bg-red-600 text-white shadow-xs';
|
|
if (statusLabel) statusLabel.innerHTML = '<span class="text-red-600 dark:text-red-400 font-medium">✕ Force Denied (Override)</span>';
|
|
}} else {{
|
|
if (inheritBtn) inheritBtn.className = 'px-2 py-1 rounded-md text-[11px] font-medium transition-all perm-btn bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-xs';
|
|
if (statusLabel) {{
|
|
if (roleDefault) {{
|
|
statusLabel.innerHTML = '<span class="text-indigo-600 dark:text-indigo-400 font-medium">● Inherited: Granted by Role</span>';
|
|
}} else {{
|
|
statusLabel.innerHTML = '<span class="text-slate-400">○ Inherited: Denied by Role</span>';
|
|
}}
|
|
}}
|
|
}}
|
|
}});
|
|
|
|
// Update category custom override counts
|
|
document.querySelectorAll('.matrix-category-card').forEach(card => {{
|
|
let catOverrides = 0;
|
|
card.querySelectorAll('.matrix-perm-row').forEach(r => {{
|
|
const p = r.dataset.permName;
|
|
if (currentOverrides.hasOwnProperty(p) && currentOverrides[p] !== null) {{
|
|
catOverrides++;
|
|
}}
|
|
}});
|
|
const badge = card.querySelector('.cat-override-badge');
|
|
if (badge) {{
|
|
if (catOverrides > 0) {{
|
|
badge.textContent = catOverrides + ' custom';
|
|
badge.classList.remove('hidden');
|
|
}} else {{
|
|
badge.classList.add('hidden');
|
|
}}
|
|
}}
|
|
}});
|
|
|
|
if (hiddenInput) {{
|
|
hiddenInput.value = JSON.stringify(currentOverrides);
|
|
}}
|
|
}}
|
|
|
|
document.addEventListener('click', function(e) {{
|
|
const btn = e.target.closest('.perm-btn');
|
|
if (btn) {{
|
|
const row = btn.closest('.matrix-perm-row');
|
|
if (!row) return;
|
|
const permName = row.dataset.permName;
|
|
const val = btn.dataset.val;
|
|
|
|
if (val === 'inherit') {{
|
|
delete currentOverrides[permName];
|
|
}} else if (val === 'allow') {{
|
|
currentOverrides[permName] = true;
|
|
}} else if (val === 'deny') {{
|
|
currentOverrides[permName] = false;
|
|
}}
|
|
|
|
updateUI();
|
|
return;
|
|
}}
|
|
|
|
const catHeader = e.target.closest('.matrix-cat-header');
|
|
if (catHeader) {{
|
|
const card = catHeader.closest('.matrix-category-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(0deg)' : 'rotate(180deg)';
|
|
}}
|
|
}}
|
|
return;
|
|
}}
|
|
|
|
if (e.target && e.target.id === 'matrix-collapse-all-btn') {{
|
|
const bodies = document.querySelectorAll('.matrix-cat-body');
|
|
const chevrons = document.querySelectorAll('.cat-chevron');
|
|
const firstHidden = bodies.length > 0 && bodies[0].classList.contains('hidden');
|
|
bodies.forEach(b => {{
|
|
if (firstHidden) {{
|
|
b.classList.remove('hidden');
|
|
}} else {{
|
|
b.classList.add('hidden');
|
|
}}
|
|
}});
|
|
chevrons.forEach(ch => {{
|
|
ch.style.transform = firstHidden ? 'rotate(180deg)' : 'rotate(0deg)';
|
|
}});
|
|
return;
|
|
}}
|
|
}});
|
|
|
|
const searchInput = document.getElementById('matrix-search-input');
|
|
if (searchInput) {{
|
|
searchInput.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-category-card').forEach(card => {{
|
|
const visibleRows = card.querySelectorAll('.matrix-perm-row:not([style*="display: none"])');
|
|
card.style.display = visibleRows.length > 0 ? 'block' : 'none';
|
|
const body = card.querySelector('.matrix-cat-body');
|
|
if (q && body) {{
|
|
body.classList.remove('hidden');
|
|
}}
|
|
}});
|
|
}});
|
|
}}
|
|
|
|
const roleSelect = document.querySelector('select[name="role"]');
|
|
if (roleSelect) {{
|
|
roleSelect.addEventListener('change', updateUI);
|
|
}}
|
|
|
|
if (document.readyState === 'loading') {{
|
|
document.addEventListener('DOMContentLoaded', updateUI);
|
|
}} else {{
|
|
updateUI();
|
|
}}
|
|
window.addEventListener('load', function() {{
|
|
setTimeout(updateUI, 100);
|
|
}});
|
|
}})();
|
|
</script>
|
|
''')
|
|
|
|
return mark_safe('\n'.join(html_parts))
|