Browse Source
feat(account): introduce interactive Role & Permissions Matrix and Group Pill chips for UserAdmin
main
feat(account): introduce interactive Role & Permissions Matrix and Group Pill chips for UserAdmin
main
2 changed files with 446 additions and 11 deletions
@ -0,0 +1,414 @@ |
|||
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)) |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue