1235 lines
90 KiB
PHP
1235 lines
90 KiB
PHP
<x-app-layout>
|
||
<div class="container-fluid" id="main-content">
|
||
<div class="row gx-3 gx-lg-4">
|
||
<div class="col-12">
|
||
<div class="card adminuiux-card">
|
||
<div class="card-body p-0">
|
||
|
||
{{-- roles management page header --}}
|
||
<div class="p-4 pb-0">
|
||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||
<div>
|
||
<h5 class="mb-0 fw-bold">{{ __('Roles Management') }}</h5>
|
||
<small class="text-muted">
|
||
{{ __('Manage roles, assign permissions, and control access levels within the system.') }}
|
||
</small>
|
||
</div>
|
||
<div class="d-flex gap-2 mt-3">
|
||
<button type="button"
|
||
class="btn btn-outline-dark px-3 rounded-pill d-flex align-items-center gap-2"
|
||
data-bs-toggle="modal" data-bs-target="#rbacDocsModal">
|
||
<i class="bi bi-book"></i> {{ __('Documentation') }}
|
||
</button>
|
||
@can('manage access rights')
|
||
<button class="btn btn-dark px-3 rounded-pill" data-bs-toggle="modal"
|
||
data-bs-target="#addRoleModal">
|
||
 + {{ __('Add Role') }} 
|
||
</button>
|
||
@endcan
|
||
</div>
|
||
</div>
|
||
<div class="d-flex gap-2 mb-4">
|
||
<button type="button" class="btn btn-sm btn-dark rounded-pill px-3 filter-trashed-role" data-value="active">
|
||
<i class="bi bi-shield-check me-1"></i> {{ __('Active Roles') }}
|
||
</button>
|
||
<button type="button" class="btn btn-sm btn-outline-dark border border-dark rounded-pill px-3 filter-trashed-role" data-value="archived">
|
||
<i class="bi bi-archive me-1"></i> {{ __('Arsip') }}
|
||
</button>
|
||
</div>
|
||
<input type="hidden" id="filter-trashed-role-val" name="trashed" class="filter-extra" value="active">
|
||
</div>
|
||
|
||
{{-- roles table --}}
|
||
<div class="p-4">
|
||
<div class="table-responsive overflow-hidden">
|
||
<table id="datatables" class="table table-hover table-bordered w-100 mb-0"
|
||
data-server-side="true" data-ajax-url="{{ route('roles') }}"
|
||
data-order='@json([[5, "desc"]])'>
|
||
<thead>
|
||
<tr>
|
||
@can('manage access rights')
|
||
<th style="width: 20px;" data-orderable="false" data-searchable="false">
|
||
<input type="checkbox" class="form-check-input check-all">
|
||
</th>
|
||
@endcan
|
||
<th>{{ __('Status') }}</th>
|
||
<th>{{ __('Role Name') }}</th>
|
||
<th>{{ __('Guard') }}</th>
|
||
<th class="text-wrap">{{ __('Assigned Permissions') }}</th>
|
||
<th data-hide="audit">{{ __('Created At') }}</th>
|
||
<th data-hide="audit">{{ __('Created By') }}</th>
|
||
<th data-hide="audit">{{ __('Last Updated At') }}</th>
|
||
<th data-hide="audit">{{ __('Last Updated By') }}</th>
|
||
@can('manage access rights')
|
||
<th class="text-end" width="10%" data-orderable="false"
|
||
data-searchable="false">{{ __('Action') }}</th>
|
||
@endcan
|
||
</tr>
|
||
|
||
{{-- filter bar --}}
|
||
<tr class="filter-row">
|
||
@can('manage access rights')
|
||
<th></th>
|
||
@endcan
|
||
<th>
|
||
<select class="form-select form-select-sm">
|
||
<option value="">{{ __('All') }}</option>
|
||
<option value="active">{{ __('Active') }}</option>
|
||
<option value="inactive">{{ __('Inactive') }}</option>
|
||
</select>
|
||
</th>
|
||
<th><input class="form-control form-control-sm"
|
||
placeholder="{{ __('Search Role Name') }}"></th>
|
||
<th>
|
||
<select class="form-select form-select-sm">
|
||
<option value="">{{ __('All') }}</option>
|
||
<option value="web">web</option>
|
||
<option value="api">api</option>
|
||
</select>
|
||
</th>
|
||
<th><input class="form-control form-control-sm"
|
||
placeholder="{{ __('Search Permissions') }}"></th>
|
||
<th><input type="date" class="form-control form-control-sm"></th>
|
||
<th><input class="form-control form-control-sm"
|
||
placeholder="{{ __('Search User') }}"></th>
|
||
<th><input type="date" class="form-control form-control-sm"></th>
|
||
<th><input class="form-control form-control-sm"
|
||
placeholder="{{ __('Search User') }}"></th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- add role modal --}}
|
||
<div class="modal fade" id="addRoleModal" tabindex="-1">
|
||
<div class="modal-dialog modal-dialog-centered modal-permission">
|
||
<div class="modal-content rounded-4 border-0 shadow">
|
||
<div class="modal-header border-0 pb-0">
|
||
<h5 class="modal-title fw-bold">{{ __('Add Role') }}</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<form method="POST" action="{{ route('roles.store') }}" autocomplete="off"
|
||
class="ajax-form" data-reset="true">
|
||
|
||
@csrf
|
||
|
||
{{-- anti autofill trap --}}
|
||
<input type="text" name="fakeuser" style="display:none">
|
||
<input type="password" name="fakepass" style="display:none">
|
||
|
||
<div class="modal-body pt-3">
|
||
<div class="row gx-4">
|
||
<div class="col-lg-3 border-end">
|
||
{{-- Role Name --}}
|
||
<label class="form-label fw-semibold text-secondary small text-uppercase">
|
||
{{ __('Role Information') }}
|
||
</label>
|
||
<div class="mb-3">
|
||
<label class="form-label small mb-1">{{ __('Role Name') }} <span class="text-danger">*</span></label>
|
||
<input type="text" name="name" class="form-control"
|
||
placeholder="ex: administrator" required minlength="3"
|
||
maxlength="50" pattern="^[a-zA-Z0-9_\-]+$"
|
||
title="Minimum 3 characters (alphanumeric, dash, underscore)">
|
||
</div>
|
||
|
||
{{-- Guard --}}
|
||
<div class="mb-4">
|
||
<label class="form-label small mb-1">{{ __('Guard Name') }} <span class="text-danger">*</span></label>
|
||
<select name="guard_name" class="form-select" required>
|
||
<option value="web" selected>web (Frontend/Admin)</option>
|
||
<option value="api">api (Mobile/External)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="alert alert-info py-2 small border-0 bg-light-info">
|
||
<i class="bi bi-info-circle me-1"></i>
|
||
{{ __('Permissions assigned here will grant access to specific features.') }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-lg-9">
|
||
<label class="form-label fw-semibold text-secondary small text-uppercase mb-2">
|
||
{{ __('Assign Permissions') }}
|
||
</label>
|
||
@include('pages.access_control.partials.permission_dual_panel', [
|
||
'panelId' => 'add',
|
||
'rolePermIds' => [],
|
||
])
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer border-0 pt-0">
|
||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||
data-bs-dismiss="modal">
|
||
 {{ __('Cancel') }} 
|
||
</button>
|
||
<button type="submit" class="btn btn-dark rounded-pill shadow-sm"
|
||
id="btn-save-role">
|
||
 {{ __('Save Changes') }} 
|
||
</button>
|
||
</div>
|
||
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- edit role modal --}}
|
||
<div class="modal fade" id="editRoleModal" tabindex="-1">
|
||
<div class="modal-dialog modal-dialog-centered modal-permission">
|
||
<div class="modal-content rounded-4 border-0 shadow">
|
||
<div class="modal-header border-0 pb-0">
|
||
<h5 class="modal-title fw-bold">{{ __('Update Role') }}</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<form id="editRoleForm" method="POST" autocomplete="off" class="ajax-form">
|
||
|
||
@csrf
|
||
@method('PUT')
|
||
|
||
<div class="modal-body pt-3">
|
||
<div class="row gx-4">
|
||
<div class="col-lg-3 border-end">
|
||
{{-- Role ID (hidden) --}}
|
||
<input type="hidden" id="edit-role-id" name="id">
|
||
|
||
<label class="form-label fw-semibold text-secondary small text-uppercase">
|
||
{{ __('Role Information') }}
|
||
</label>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label small mb-1">{{ __('Role Name') }} <span class="text-danger">*</span></label>
|
||
<input id="edit-role-name" name="name" type="text"
|
||
class="form-control"
|
||
placeholder="{{ __('ex: administrator') }}" required
|
||
minlength="3" maxlength="50" pattern="^[a-zA-Z0-9_\-]+$">
|
||
</div>
|
||
|
||
<div class="mb-4">
|
||
<label class="form-label small mb-1">{{ __('Guard Name') }} <span class="text-danger">*</span></label>
|
||
<select id="edit-role-guard" name="guard_name" class="form-select" required>
|
||
<option value="web">web (Frontend/Admin)</option>
|
||
<option value="api">api (Mobile/External)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-lg-9">
|
||
<label class="form-label fw-semibold text-secondary small text-uppercase mb-2">
|
||
{{ __('Assign Permissions') }}
|
||
</label>
|
||
@include('pages.access_control.partials.permission_dual_panel', [
|
||
'panelId' => 'edit',
|
||
'rolePermIds' => [],
|
||
])
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer border-0 pt-0">
|
||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||
data-bs-dismiss="modal">
|
||
 {{ __('Close') }} 
|
||
</button>
|
||
<button type="submit" class="btn btn-dark rounded-pill shadow-sm">
|
||
 {{ __('Update Role') }} 
|
||
</button>
|
||
</div>
|
||
|
||
</form>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- script handler (status, delete, edit data fill) --}}
|
||
<script>
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
|
||
// ── Dual-panel permission picker ────────────────────────────────
|
||
function dpPanel(panelEl) {
|
||
const availList = panelEl.querySelector('.dp-available-list');
|
||
const assignedList = panelEl.querySelector('.dp-assigned-list');
|
||
const availCount = panelEl.querySelector('.dp-available-count');
|
||
const assignCount = panelEl.querySelector('.dp-assigned-count');
|
||
let lastClicked = null; // for shift-click range
|
||
|
||
function refreshCounts() {
|
||
const av = availList.querySelectorAll('.dp-avail-item').length;
|
||
const as = assignedList.querySelectorAll('.dp-assigned-item').length;
|
||
if (availCount) availCount.textContent = av;
|
||
if (assignCount) {
|
||
assignCount.textContent = as;
|
||
assignCount.className = `badge dp-assigned-count rounded-pill ${as > 0 ? 'text-bg-success' : 'text-bg-secondary'}`;
|
||
assignCount.style.fontSize = '0.6rem';
|
||
}
|
||
const hint = assignedList.querySelector('.dp-empty-hint');
|
||
if (hint) hint.style.display = as > 0 ? 'none' : '';
|
||
}
|
||
|
||
// Ensure a category header exists in the Assigned panel for the given cat slug/label
|
||
function ensureAssignedCatHeader(cat, label) {
|
||
let h = assignedList.querySelector(`.dp-cat-header[data-cat="${cat}"]`);
|
||
if (!h) {
|
||
h = document.createElement('div');
|
||
h.className = 'dp-group-header dp-cat-header';
|
||
h.dataset.cat = cat;
|
||
h.textContent = label;
|
||
// Insert in same order as Available panel
|
||
const availHeader = availList.querySelector(`.dp-cat-header[data-cat="${cat}"]`);
|
||
const allAvailHeaders = [...availList.querySelectorAll('.dp-cat-header')];
|
||
const catIdx = allAvailHeaders.indexOf(availHeader);
|
||
const existingAssignedHeaders = [...assignedList.querySelectorAll('.dp-cat-header')];
|
||
let inserted = false;
|
||
for (const eh of existingAssignedHeaders) {
|
||
const ehIdx = allAvailHeaders.indexOf(availList.querySelector(`.dp-cat-header[data-cat="${eh.dataset.cat}"]`));
|
||
if (catIdx < ehIdx) {
|
||
assignedList.insertBefore(h, eh);
|
||
inserted = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!inserted) assignedList.appendChild(h);
|
||
}
|
||
return h;
|
||
}
|
||
|
||
// Remove assigned cat header if no items remain in that category
|
||
function pruneAssignedCatHeader(cat) {
|
||
const remaining = assignedList.querySelectorAll(`.dp-assigned-item[data-cat="${cat}"]`).length;
|
||
if (remaining === 0) {
|
||
assignedList.querySelector(`.dp-cat-header[data-cat="${cat}"]`)?.remove();
|
||
}
|
||
}
|
||
|
||
function moveToAssigned(item) {
|
||
item.classList.remove('dp-avail-item', 'selected');
|
||
item.classList.add('dp-assigned-item');
|
||
item.querySelectorAll('.dp-hidden-input').forEach(i => i.remove());
|
||
const inp = Object.assign(document.createElement('input'), {
|
||
type: 'hidden', name: 'permissions[]',
|
||
value: item.dataset.id, className: 'dp-hidden-input'
|
||
});
|
||
item.appendChild(inp);
|
||
// Insert under the correct category header in Assigned
|
||
const cat = item.dataset.cat;
|
||
const availHeader = availList.querySelector(`.dp-cat-header[data-cat="${cat}"]`);
|
||
const label = availHeader ? availHeader.textContent.trim() : cat;
|
||
const catHeader = ensureAssignedCatHeader(cat, label);
|
||
// Append after last item of this cat, or right after header
|
||
const siblings = [...assignedList.querySelectorAll(`.dp-assigned-item[data-cat="${cat}"]`)];
|
||
if (siblings.length > 0) {
|
||
siblings[siblings.length - 1].after(item);
|
||
} else {
|
||
catHeader.after(item);
|
||
}
|
||
refreshCounts();
|
||
}
|
||
|
||
function moveToAvailable(item) {
|
||
const cat = item.dataset.cat;
|
||
item.classList.remove('dp-assigned-item', 'selected');
|
||
item.classList.add('dp-avail-item');
|
||
item.querySelectorAll('.dp-hidden-input').forEach(i => i.remove());
|
||
// Re-insert after the last sibling in its category group
|
||
const header = availList.querySelector(`.dp-cat-header[data-cat="${cat}"]`);
|
||
if (header) {
|
||
let last = header, next = header.nextElementSibling;
|
||
while (next && !next.classList.contains('dp-cat-header')) { last = next; next = next.nextElementSibling; }
|
||
last.after(item);
|
||
} else {
|
||
availList.appendChild(item);
|
||
}
|
||
pruneAssignedCatHeader(cat);
|
||
refreshCounts();
|
||
}
|
||
|
||
// Multi-select: click / Ctrl+click / Shift+click
|
||
panelEl.addEventListener('click', e => {
|
||
const item = e.target.closest('.dp-avail-item, .dp-assigned-item');
|
||
if (!item) return;
|
||
e.preventDefault();
|
||
const listEl = item.closest('.dp-panel-body');
|
||
const allItems = [...listEl.querySelectorAll('.dp-avail-item, .dp-assigned-item')].filter(i => !i.classList.contains('d-none'));
|
||
|
||
if (e.shiftKey && lastClicked && listEl.contains(lastClicked)) {
|
||
// Range select
|
||
const a = allItems.indexOf(lastClicked);
|
||
const b = allItems.indexOf(item);
|
||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||
allItems.forEach((it, idx) => {
|
||
if (idx >= lo && idx <= hi) it.classList.add('selected');
|
||
});
|
||
} else if (e.ctrlKey || e.metaKey) {
|
||
// Toggle individual
|
||
item.classList.toggle('selected');
|
||
} else {
|
||
// Single select
|
||
listEl.querySelectorAll('.dp-item').forEach(i => i.classList.remove('selected'));
|
||
item.classList.add('selected');
|
||
}
|
||
lastClicked = item;
|
||
});
|
||
|
||
// Double-click to move immediately
|
||
panelEl.addEventListener('dblclick', e => {
|
||
const avail = e.target.closest('.dp-avail-item');
|
||
const assigned = e.target.closest('.dp-assigned-item');
|
||
if (avail) { avail.classList.add('selected'); moveToAssigned(avail); }
|
||
if (assigned) { assigned.classList.add('selected'); moveToAvailable(assigned); }
|
||
});
|
||
|
||
// Buttons
|
||
panelEl.querySelector('.dp-btn-add-selected')?.addEventListener('click', () => {
|
||
[...availList.querySelectorAll('.dp-avail-item.selected')].forEach(moveToAssigned);
|
||
});
|
||
panelEl.querySelector('.dp-btn-add-all')?.addEventListener('click', () => {
|
||
[...availList.querySelectorAll('.dp-avail-item:not(.d-none)')].forEach(moveToAssigned);
|
||
});
|
||
panelEl.querySelector('.dp-btn-remove-selected')?.addEventListener('click', () => {
|
||
[...assignedList.querySelectorAll('.dp-assigned-item.selected')].forEach(moveToAvailable);
|
||
});
|
||
panelEl.querySelector('.dp-btn-remove-all')?.addEventListener('click', () => {
|
||
[...assignedList.querySelectorAll('.dp-assigned-item:not(.d-none)')].forEach(moveToAvailable);
|
||
});
|
||
|
||
// Search available
|
||
panelEl.querySelector('.dp-search-available')?.addEventListener('input', e => {
|
||
const term = e.target.value.toLowerCase().trim();
|
||
availList.querySelectorAll('.dp-avail-item').forEach(it => {
|
||
it.classList.toggle('d-none', !!term && !it.dataset.name.includes(term));
|
||
});
|
||
availList.querySelectorAll('.dp-cat-header').forEach(h => {
|
||
const vis = availList.querySelectorAll(`.dp-avail-item[data-cat="${h.dataset.cat}"]:not(.d-none)`).length;
|
||
h.classList.toggle('d-none', vis === 0);
|
||
});
|
||
});
|
||
|
||
// Search assigned
|
||
panelEl.querySelector('.dp-search-assigned')?.addEventListener('input', e => {
|
||
const term = e.target.value.toLowerCase().trim();
|
||
assignedList.querySelectorAll('.dp-assigned-item').forEach(it => {
|
||
it.classList.toggle('d-none', !!term && !it.dataset.name.includes(term));
|
||
});
|
||
assignedList.querySelectorAll('.dp-cat-header').forEach(h => {
|
||
const vis = assignedList.querySelectorAll(`.dp-assigned-item[data-cat="${h.dataset.cat}"]:not(.d-none)`).length;
|
||
h.classList.toggle('d-none', vis === 0);
|
||
});
|
||
});
|
||
|
||
// Init: move pre-selected items to assigned panel
|
||
availList.querySelectorAll('.dp-avail-item[data-preselected="1"]').forEach(moveToAssigned);
|
||
|
||
refreshCounts();
|
||
return { moveToAssigned, moveToAvailable, refreshCounts };
|
||
}
|
||
|
||
// Init both panels
|
||
document.querySelectorAll('[id^="dp-"]').forEach(el => dpPanel(el));
|
||
|
||
document.addEventListener("change", e => {
|
||
const toggle = e.target.closest(".role-toggle");
|
||
|
||
if (!toggle) {
|
||
return;
|
||
}
|
||
|
||
const id = toggle.dataset.id;
|
||
const name = toggle.dataset.name;
|
||
const status = toggle.checked ? "activate" : "deactivate";
|
||
|
||
StandardSwal.fire({
|
||
title: `${status === "activate" ? "Activate" : "Deactivate"} Role?`,
|
||
text: `You are about to change the access status for global role "${name}".`,
|
||
icon: "warning",
|
||
showCancelButton: true,
|
||
confirmButtonText: "Yes, Continue",
|
||
cancelButtonText: "Cancel",
|
||
}).then(result => {
|
||
if (!result.isConfirmed) {
|
||
toggle.checked = !toggle.checked;
|
||
return;
|
||
}
|
||
|
||
fetch("{{ route('roles.toggle-status') }}", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRF-TOKEN": "{{ csrf_token() }}"
|
||
},
|
||
body: JSON.stringify({
|
||
id,
|
||
status
|
||
}),
|
||
})
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
window.reloadDataTable?.();
|
||
|
||
StandardSwal.fire({
|
||
title: "{{ __('Success!') }}",
|
||
text: data.message || "{{ __('The role status has been updated successfully.') }}",
|
||
icon: "success",
|
||
timer: 2000,
|
||
showConfirmButton: false,
|
||
timerProgressBar: true
|
||
});
|
||
} else {
|
||
throw new Error();
|
||
}
|
||
})
|
||
.catch(() => {
|
||
toggle.checked = !toggle.checked;
|
||
StandardSwal.fire({
|
||
title: "{{ __('Error!') }}",
|
||
text: "{{ __('An unexpected error occurred while communicating with the server.') }}",
|
||
icon: "error"
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
document.addEventListener("click", e => {
|
||
// Filter Active/Archived
|
||
const filterBtn = e.target.closest(".filter-trashed-role");
|
||
if (filterBtn) {
|
||
document.querySelectorAll(".filter-trashed-role").forEach(b => {
|
||
b.classList.remove("btn-dark");
|
||
b.classList.add("btn-outline-dark", "border", "border-dark");
|
||
});
|
||
filterBtn.classList.add("btn-dark");
|
||
filterBtn.classList.remove("btn-outline-dark", "border", "border-dark");
|
||
|
||
document.getElementById("filter-trashed-role-val").value = filterBtn.dataset.value;
|
||
window.reloadDataTable?.();
|
||
return;
|
||
}
|
||
|
||
const editButton = e.target.closest(".btn-edit");
|
||
|
||
if (editButton) {
|
||
const url = `{{ route('roles.update', 'ID') }}`.replace("ID", editButton.dataset.id);
|
||
document.getElementById("editRoleForm").action = url;
|
||
document.getElementById("edit-role-id").value = editButton.dataset.id;
|
||
document.getElementById("edit-role-name").value = editButton.dataset.name;
|
||
document.getElementById("edit-role-guard").value = editButton.dataset.guard;
|
||
|
||
// Reset + reload dual panel for edit modal
|
||
const modal = document.getElementById('editRoleModal');
|
||
const editPanel = modal.querySelector('#dp-edit');
|
||
if (editPanel) {
|
||
const availList = editPanel.querySelector('.dp-available-list');
|
||
const assignedList = editPanel.querySelector('.dp-assigned-list');
|
||
const selected = JSON.parse(editButton.dataset.permissions || "[]");
|
||
|
||
// 1. Move all currently assigned items back to available, remove cat headers
|
||
[...assignedList.querySelectorAll('.dp-assigned-item')].forEach(item => {
|
||
item.classList.remove('dp-assigned-item', 'selected');
|
||
item.classList.add('dp-avail-item');
|
||
item.querySelectorAll('.dp-hidden-input').forEach(i => i.remove());
|
||
const cat = item.dataset.cat;
|
||
const header = availList.querySelector(`.dp-cat-header[data-cat="${cat}"]`);
|
||
if (header) {
|
||
let last = header, next = header.nextElementSibling;
|
||
while (next && !next.classList.contains('dp-cat-header')) { last = next; next = next.nextElementSibling; }
|
||
last.after(item);
|
||
} else {
|
||
availList.appendChild(item);
|
||
}
|
||
});
|
||
// Remove all assigned cat headers after reset
|
||
assignedList.querySelectorAll('.dp-cat-header').forEach(h => h.remove());
|
||
|
||
// 2. Move this role's permissions to assigned (with cat headers)
|
||
selected.forEach(permId => {
|
||
const item = availList.querySelector(`.dp-avail-item[data-id="${permId}"]`);
|
||
if (!item) return;
|
||
item.classList.remove('dp-avail-item', 'selected');
|
||
item.classList.add('dp-assigned-item');
|
||
item.querySelectorAll('.dp-hidden-input').forEach(i => i.remove());
|
||
const inp = Object.assign(document.createElement('input'), {
|
||
type: 'hidden', name: 'permissions[]',
|
||
value: permId, className: 'dp-hidden-input'
|
||
});
|
||
item.appendChild(inp);
|
||
// Insert under category header in assigned panel
|
||
const cat = item.dataset.cat;
|
||
const availHeader = availList.querySelector(`.dp-cat-header[data-cat="${cat}"]`);
|
||
const label = availHeader ? availHeader.textContent.trim() : cat;
|
||
let catHeader = assignedList.querySelector(`.dp-cat-header[data-cat="${cat}"]`);
|
||
if (!catHeader) {
|
||
catHeader = document.createElement('div');
|
||
catHeader.className = 'dp-group-header dp-cat-header';
|
||
catHeader.dataset.cat = cat;
|
||
catHeader.textContent = label;
|
||
// Order by Available panel order
|
||
const allAvailHeaders = [...availList.querySelectorAll('.dp-cat-header')];
|
||
const catIdx = allAvailHeaders.indexOf(availHeader);
|
||
const existingHeaders = [...assignedList.querySelectorAll('.dp-cat-header')];
|
||
let inserted = false;
|
||
for (const eh of existingHeaders) {
|
||
const ehIdx = allAvailHeaders.indexOf(availList.querySelector(`.dp-cat-header[data-cat="${eh.dataset.cat}"]`));
|
||
if (catIdx < ehIdx) { assignedList.insertBefore(catHeader, eh); inserted = true; break; }
|
||
}
|
||
if (!inserted) assignedList.appendChild(catHeader);
|
||
}
|
||
const siblings = [...assignedList.querySelectorAll(`.dp-assigned-item[data-cat="${cat}"]`)];
|
||
if (siblings.length > 0) siblings[siblings.length - 1].after(item);
|
||
else catHeader.after(item);
|
||
});
|
||
|
||
// 3. Refresh counts & empty hint
|
||
const av = availList.querySelectorAll('.dp-avail-item').length;
|
||
const as = assignedList.querySelectorAll('.dp-assigned-item').length;
|
||
const ac = editPanel.querySelector('.dp-available-count');
|
||
const sc = editPanel.querySelector('.dp-assigned-count');
|
||
if (ac) ac.textContent = av;
|
||
if (sc) {
|
||
sc.textContent = as;
|
||
sc.className = `badge dp-assigned-count rounded-pill ${as > 0 ? 'text-bg-success' : 'text-bg-secondary'}`;
|
||
sc.style.fontSize = '0.6rem';
|
||
}
|
||
const hint = assignedList.querySelector('.dp-empty-hint');
|
||
if (hint) hint.style.display = as > 0 ? 'none' : '';
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
const deleteButton = e.target.closest(".btn-delete");
|
||
|
||
if (deleteButton) {
|
||
e.preventDefault();
|
||
const id = deleteButton.dataset.id;
|
||
const name = deleteButton.dataset.name;
|
||
|
||
StandardSwal.fire({
|
||
title: "Archive Role?",
|
||
text: `"${name}" will be moved to the system archives.`,
|
||
icon: "warning",
|
||
showCancelButton: true,
|
||
confirmButtonText: "Yes, Archive",
|
||
cancelButtonText: "Cancel",
|
||
customClass: {
|
||
confirmButton: 'btn-pill-danger',
|
||
cancelButton: 'btn-pill-cancel'
|
||
}
|
||
}).then(result => {
|
||
if (!result.isConfirmed) return;
|
||
|
||
const url = "{{ route('roles.destroy', 'ID') }}".replace("ID", id);
|
||
|
||
fetch(url, {
|
||
method: "DELETE",
|
||
headers: {
|
||
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content,
|
||
"Accept": "application/json",
|
||
}
|
||
})
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (!data.success) {
|
||
StandardSwal.fire({
|
||
title: "{{ __('Action Denied') }}",
|
||
text: data.message,
|
||
icon: "error"
|
||
});
|
||
return;
|
||
}
|
||
|
||
window.reloadDataTable?.();
|
||
StandardSwal.fire({
|
||
icon: "success",
|
||
title: "Archived Successfully!",
|
||
text: data.message || "The role has been added to the archives.",
|
||
timer: 2000,
|
||
showConfirmButton: false,
|
||
timerProgressBar: true
|
||
});
|
||
})
|
||
.catch(err => StandardSwal.fire({
|
||
title: "Error!",
|
||
text: "An error occurred during archiving. Please try again.",
|
||
icon: "error"
|
||
}));
|
||
});
|
||
return;
|
||
}
|
||
|
||
// RESTORE HANDLER
|
||
const restoreBtn = e.target.closest(".btn-restore");
|
||
if (restoreBtn) {
|
||
e.preventDefault();
|
||
const id = restoreBtn.dataset.id;
|
||
const name = restoreBtn.dataset.name;
|
||
|
||
StandardSwal.fire({
|
||
title: "{{ __('Restore Role?') }}",
|
||
text: `{{ __('Do you want to restore access for') }} "${name}"?`,
|
||
icon: "info",
|
||
showCancelButton: true,
|
||
confirmButtonText: "{{ __('Yes, Restore') }}",
|
||
cancelButtonText: "{{ __('Cancel') }}",
|
||
}).then(result => {
|
||
if (!result.isConfirmed) return;
|
||
|
||
const url = `{{ route('roles.restore', 'ID') }}`.replace("ID", id);
|
||
fetch(url, {
|
||
method: "POST",
|
||
headers: {
|
||
"X-CSRF-TOKEN": "{{ csrf_token() }}",
|
||
"Accept": "application/json",
|
||
}
|
||
}).then(res => res.json())
|
||
.then(data => {
|
||
window.reloadDataTable?.();
|
||
StandardSwal.fire({
|
||
icon: "success",
|
||
title: "{{ __('Restored!') }}",
|
||
text: data.message,
|
||
timer: 2000,
|
||
showConfirmButton: false,
|
||
timerProgressBar: true
|
||
});
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
|
||
// FORCE DELETE HANDLER
|
||
const forceBtn = e.target.closest(".btn-force-delete");
|
||
if (forceBtn) {
|
||
e.preventDefault();
|
||
const id = forceBtn.dataset.id;
|
||
const name = forceBtn.dataset.name;
|
||
|
||
StandardSwal.fire({
|
||
title: "{{ __('Terminate Role?') }}",
|
||
text: `{{ __('This will PERMANENTLY delete') }} "${name}". {{ __('This action cannot be undone.') }}`,
|
||
icon: "error",
|
||
showCancelButton: true,
|
||
confirmButtonText: "{{ __('Yes, Terminate') }}",
|
||
cancelButtonText: "{{ __('Cancel') }}",
|
||
confirmButtonColor: "#dc3545",
|
||
}).then(result => {
|
||
if (!result.isConfirmed) return;
|
||
|
||
const url = `{{ route('roles.force-delete', 'ID') }}`.replace("ID", id);
|
||
fetch(url, {
|
||
method: "DELETE",
|
||
headers: {
|
||
"X-CSRF-TOKEN": "{{ csrf_token() }}",
|
||
"Accept": "application/json",
|
||
}
|
||
}).then(res => res.json())
|
||
.then(data => {
|
||
if (!data.success) {
|
||
StandardSwal.fire("{{ __('Action Denied') }}", data.message, "error");
|
||
return;
|
||
}
|
||
window.reloadDataTable?.();
|
||
StandardSwal.fire({
|
||
icon: "success",
|
||
title: "{{ __('Terminated!') }}",
|
||
text: data.message,
|
||
timer: 2000,
|
||
showConfirmButton: false,
|
||
timerProgressBar: true
|
||
});
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
|
||
// BULK ACTION LOGIC
|
||
const bulkBtn = e.target.closest(".bulk-btn");
|
||
if (bulkBtn) {
|
||
e.preventDefault();
|
||
const action = bulkBtn.dataset.action;
|
||
const selectedIds = Array.from(document.querySelectorAll(".role-checkbox:checked")).map(cb => cb.value);
|
||
|
||
if (selectedIds.length === 0) return;
|
||
|
||
let config = {
|
||
title: "{{ __('Are you sure?') }}",
|
||
text: `{{ __('You are about to perform action on') }} ${selectedIds.length} {{ __('roles.') }}`,
|
||
icon: "warning",
|
||
url: "",
|
||
method: "POST",
|
||
body: { ids: selectedIds }
|
||
};
|
||
|
||
switch(action) {
|
||
case 'activate':
|
||
config.url = "{{ route('roles.bulk-toggle-status') }}";
|
||
config.body.status = 'activate';
|
||
break;
|
||
case 'deactivate':
|
||
config.url = "{{ route('roles.bulk-toggle-status') }}";
|
||
config.body.status = 'deactivate';
|
||
break;
|
||
case 'archive':
|
||
config.url = "{{ route('roles.bulk-delete') }}";
|
||
config.icon = "error";
|
||
break;
|
||
case 'restore':
|
||
config.url = "{{ route('roles.bulk-restore') }}";
|
||
config.icon = "info";
|
||
break;
|
||
case 'terminate':
|
||
config.url = "{{ route('roles.bulk-force-delete') }}";
|
||
config.icon = "error";
|
||
config.text += " THIS ACTION IS PERMANENT!";
|
||
break;
|
||
}
|
||
|
||
StandardSwal.fire({
|
||
title: config.title,
|
||
text: config.text,
|
||
icon: config.icon,
|
||
showCancelButton: true,
|
||
confirmButtonText: "{{ __('Yes, Proceed') }}",
|
||
}).then(result => {
|
||
if (!result.isConfirmed) return;
|
||
|
||
fetch(config.url, {
|
||
method: config.method,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRF-TOKEN": "{{ csrf_token() }}",
|
||
"Accept": "application/json"
|
||
},
|
||
body: JSON.stringify(config.body)
|
||
}).then(res => res.json())
|
||
.then(data => {
|
||
window.reloadDataTable?.();
|
||
document.querySelector(".check-all").checked = false;
|
||
StandardSwal.fire("{{ __('Success') }}", data.message, "success");
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
// Check All Handler
|
||
document.addEventListener("change", e => {
|
||
if (e.target.classList.contains("check-all")) {
|
||
document.querySelectorAll(".role-checkbox").forEach(cb => cb.checked = e.target.checked);
|
||
updateBulkBar();
|
||
}
|
||
|
||
if (e.target.classList.contains("role-checkbox")) {
|
||
updateBulkBar();
|
||
}
|
||
});
|
||
|
||
document.getElementById("clear-selection")?.addEventListener("click", () => {
|
||
document.querySelectorAll(".role-checkbox, .check-all").forEach(cb => cb.checked = false);
|
||
updateBulkBar();
|
||
});
|
||
|
||
function updateBulkBar() {
|
||
const checked = document.querySelectorAll(".role-checkbox:checked");
|
||
const bar = document.getElementById("bulk-action-bar");
|
||
const count = document.getElementById("selected-count");
|
||
const filterEl = document.getElementById("filter-trashed-role-val");
|
||
|
||
if (!bar || !count) return;
|
||
|
||
const filterType = filterEl ? filterEl.value : 'active';
|
||
|
||
if (checked.length > 0) {
|
||
bar.classList.remove("d-none");
|
||
count.textContent = checked.length;
|
||
|
||
if (filterType === 'archived') {
|
||
document.querySelectorAll(".show-if-active").forEach(el => el.classList.add("d-none"));
|
||
document.querySelectorAll(".show-if-archived").forEach(el => el.classList.remove("d-none"));
|
||
} else {
|
||
document.querySelectorAll(".show-if-active").forEach(el => el.classList.remove("d-none"));
|
||
document.querySelectorAll(".show-if-archived").forEach(el => el.classList.add("d-none"));
|
||
}
|
||
} else {
|
||
bar.classList.add("d-none");
|
||
}
|
||
}
|
||
|
||
// Reset selection on dataTable reload
|
||
window.addEventListener('dataTableReloaded', () => {
|
||
const checkAll = document.querySelector(".check-all");
|
||
if (checkAll) checkAll.checked = false;
|
||
updateBulkBar();
|
||
});
|
||
});
|
||
</script>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- ====================================================================
|
||
RBAC DOCUMENTATION (MODAL)
|
||
==================================================================== --}}
|
||
<div class="modal fade" id="rbacDocsModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-xl modal-fullscreen-lg-down modal-dialog-scrollable modal-dialog-centered">
|
||
<div class="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||
<div class="modal-header p-0 border-0" style="background:#0f172a;">
|
||
<div class="d-flex w-100 align-items-center justify-content-between p-4 p-lg-5 text-white">
|
||
<div>
|
||
<div class="extra-small opacity-50 fw-bold mb-2" style="letter-spacing:1.5px;">REFERENCE GUIDE</div>
|
||
<h3 class="fw-black mb-2" style="letter-spacing:-1px;">{{ __('Roles & Permissions Guide') }}</h3>
|
||
<p class="mb-0 opacity-75" style="font-size:.9rem;line-height:1.7;max-width:720px;">
|
||
How role-based access control (RBAC) works in this system —
|
||
how roles, permissions, and users fit together,
|
||
and how to grant or revoke access without locking yourself out.
|
||
</p>
|
||
</div>
|
||
<div class="d-flex align-items-start gap-3">
|
||
<i class="bi bi-shield-lock-fill display-3 opacity-25 d-none d-md-inline"></i>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-body p-0">
|
||
<div class="p-4 p-lg-5">
|
||
|
||
{{-- MENTAL MODEL --}}
|
||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||
<i class="bi bi-diagram-3 text-primary me-1"></i>The mental model
|
||
</h6>
|
||
<div class="p-4 rounded-4 mb-5" style="background:#f8fafc;border:1px solid #e2e8f0;">
|
||
<div class="row text-center align-items-center g-3">
|
||
<div class="col-md-3">
|
||
<div class="p-3 rounded-4 bg-white shadow-sm">
|
||
<i class="bi bi-person-fill text-primary display-6"></i>
|
||
<div class="fw-bold mt-2">USER</div>
|
||
<div class="small text-muted">A person who logs in</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-1 d-none d-md-block">
|
||
<i class="bi bi-arrow-right fs-2 text-muted"></i>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="p-3 rounded-4 bg-white shadow-sm">
|
||
<i class="bi bi-people-fill text-warning display-6"></i>
|
||
<div class="fw-bold mt-2">ROLE</div>
|
||
<div class="small text-muted">A named bundle (e.g. <em>Admin</em>)</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-1 d-none d-md-block">
|
||
<i class="bi bi-arrow-right fs-2 text-muted"></i>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="p-3 rounded-4 bg-white shadow-sm">
|
||
<i class="bi bi-key-fill text-success display-6"></i>
|
||
<div class="fw-bold mt-2">PERMISSION</div>
|
||
<div class="small text-muted">One specific action</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="small text-muted text-center mb-0 mt-4" style="line-height:1.7;">
|
||
A <span class="fw-bold">user</span> is given one or more <span class="fw-bold">roles</span>.
|
||
Each <span class="fw-bold">role</span> contains a set of <span class="fw-bold">permissions</span>.
|
||
A user can do an action if <span class="fw-bold">any</span> of their roles grants the matching permission.
|
||
</p>
|
||
</div>
|
||
|
||
{{-- CONCEPTS --}}
|
||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||
<i class="bi bi-bookmark-fill text-info me-1"></i>Core concepts
|
||
</h6>
|
||
<div class="row g-3 mb-5">
|
||
@php
|
||
$concepts = [
|
||
['icon'=>'bi-people-fill','color'=>'#f59e0b','title'=>'Role','desc'=>'A named container — e.g. <code>Super Admin</code>, <code>Manager</code>, <code>Staff</code>. Roles are <span class="fw-bold">human-readable groupings</span> meant to mirror real positions in your organisation.'],
|
||
['icon'=>'bi-key-fill','color'=>'#22c55e','title'=>'Permission','desc'=>'A single capability — e.g. <code>view dashboard</code>, <code>manage users</code>. Always phrased as <span class="fw-bold">verb + object</span> (view / manage / create / delete + noun).'],
|
||
['icon'=>'bi-shield-fill','color'=>'#3b82f6','title'=>'Guard','desc'=>'The authentication context. <code>web</code> = browser session (this UI). <code>api</code> = token-based mobile/API clients. A role / permission belongs to exactly one guard.'],
|
||
['icon'=>'bi-archive-fill','color'=>'#94a3b8','title'=>'Archive','desc'=>'Soft-delete. Archived roles disappear from assignment dropdowns but their history is preserved. Users still holding an archived role lose its permissions until restored.'],
|
||
];
|
||
@endphp
|
||
@foreach($concepts as $c)
|
||
<div class="col-md-6 col-lg-3">
|
||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||
<div class="rounded-3 d-inline-flex align-items-center justify-content-center mb-2"
|
||
style="background:{{ $c['color'] }}1a;color:{{ $c['color'] }};width:42px;height:42px;">
|
||
<i class="{{ $c['icon'] }} fs-5"></i>
|
||
</div>
|
||
<div class="fw-bold text-dark mb-1">{{ $c['title'] }}</div>
|
||
<div class="small text-muted" style="line-height:1.6;">{!! $c['desc'] !!}</div>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
|
||
{{-- VIEW vs MANAGE --}}
|
||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||
<i class="bi bi-eye-fill text-primary me-1"></i><code>view</code> vs <code>manage</code> · the most important convention
|
||
</h6>
|
||
<div class="row g-3 mb-5">
|
||
<div class="col-md-6">
|
||
<div class="p-4 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||
<div class="d-flex align-items-center gap-2 mb-2">
|
||
<i class="bi bi-eye text-primary fs-4"></i>
|
||
<code class="text-primary fw-bold fs-6">view <module></code>
|
||
</div>
|
||
<div class="small text-muted mb-2" style="line-height:1.7;">Lets the user <span class="fw-bold">open the page</span> and read data. Read-only.</div>
|
||
<div class="extra-small text-muted">
|
||
<span class="fw-bold text-dark">Examples:</span>
|
||
<code>view dashboard</code>, <code>view health and logs</code>, <code>view action history</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="p-4 rounded-4 h-100" style="background:#fef2f2;border:1px solid #fecaca;">
|
||
<div class="d-flex align-items-center gap-2 mb-2">
|
||
<i class="bi bi-pencil-square text-danger fs-4"></i>
|
||
<code class="text-danger fw-bold fs-6">manage <module></code>
|
||
</div>
|
||
<div class="small text-muted mb-2" style="line-height:1.7;">Lets the user <span class="fw-bold">create, edit, delete</span> within that module. Implies <code>view</code>.</div>
|
||
<div class="extra-small text-muted">
|
||
<span class="fw-bold text-dark">Examples:</span>
|
||
<code>manage users</code>, <code>manage access rights</code>, <code>manage backup and storage</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-12">
|
||
<div class="d-flex align-items-start gap-2 p-3 rounded-3" style="background:#fffbeb;border:1px solid #fde68a;">
|
||
<i class="bi bi-lightbulb-fill text-warning"></i>
|
||
<div class="small text-muted">
|
||
<span class="fw-bold text-dark">Rule of thumb:</span> grant <code>view</code> liberally, grant <code>manage</code> sparingly.
|
||
For modules a role doesn't touch at all, don't assign either — keeps the audit log clean.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- LIFECYCLE --}}
|
||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||
<i class="bi bi-arrow-repeat text-success me-1"></i>Day-to-day workflow
|
||
</h6>
|
||
<div class="row g-3 mb-5">
|
||
@php
|
||
$flows = [
|
||
['icon'=>'bi-plus-circle','color'=>'#22c55e','title'=>'Create a role','desc'=>'Click <span class="fw-bold">+ Add Role</span>. Give it a name (e.g. <em>Finance Manager</em>), pick a guard (usually <code>web</code>), and tick the permissions it should grant.'],
|
||
['icon'=>'bi-person-plus','color'=>'#3b82f6','title'=>'Assign to user','desc'=>'Go to <span class="fw-bold">User Directory</span> → edit a user → assign one or more roles. Changes take effect immediately on next request.'],
|
||
['icon'=>'bi-sliders','color'=>'#8b5cf6','title'=>'Tune permissions','desc'=>'Edit the role to add/remove permissions. Every user holding that role inherits the change instantly — no need to touch each user.'],
|
||
['icon'=>'bi-archive','color'=>'#f59e0b','title'=>'Archive (soft-delete)','desc'=>'Archive removes the role from assignment dropdowns but keeps history. Use this when phasing out a position. Users keep working but lose the permissions.'],
|
||
['icon'=>'bi-arrow-counterclockwise','color'=>'#06b6d4','title'=>'Restore','desc'=>'Filter to <span class="fw-bold">Arsip</span>, find the role, click restore. Users who used to hold it regain their permissions.'],
|
||
['icon'=>'bi-x-circle','color'=>'#ef4444','title'=>'Hapus Permanen','desc'=>'Only available in the archive view. <span class="fw-bold text-danger">Irreversible</span>. Removes the role record and unassigns every user. Use with caution.'],
|
||
];
|
||
@endphp
|
||
@foreach($flows as $f)
|
||
<div class="col-md-6 col-lg-4">
|
||
<div class="p-3 rounded-4 h-100" style="background:#f8fafc;border:1px solid #e2e8f0;">
|
||
<div class="d-flex align-items-center gap-2 mb-2">
|
||
<div class="rounded-3 d-flex align-items-center justify-content-center flex-shrink-0"
|
||
style="background:{{ $f['color'] }}1a;color:{{ $f['color'] }};width:36px;height:36px;">
|
||
<i class="{{ $f['icon'] }}"></i>
|
||
</div>
|
||
<div class="fw-bold text-dark">{{ $f['title'] }}</div>
|
||
</div>
|
||
<div class="small text-muted" style="line-height:1.6;">{!! $f['desc'] !!}</div>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
|
||
{{-- SAFETY WARNING --}}
|
||
<div class="d-flex align-items-start gap-3 p-4 rounded-4 mb-5" style="background:#fef2f2;border:2px solid #fecaca;">
|
||
<i class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
|
||
<div>
|
||
<div class="fw-black text-danger mb-1">DON'T LOCK YOURSELF OUT</div>
|
||
<div class="small text-dark" style="line-height:1.7;">
|
||
Always keep <span class="fw-bold">at least one active user</span> with the <code>manage access rights</code> permission.
|
||
If you accidentally remove this permission from every active role, <span class="fw-bold text-danger">no one can log in and fix it through the UI</span> —
|
||
recovery requires direct database edit or a CLI artisan command.
|
||
</div>
|
||
<div class="small text-muted mt-2">
|
||
<span class="fw-bold">Tip:</span> create a dedicated <em>Super Admin</em> role with every permission and assign it to at least two trusted accounts. Never archive it.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- COMMON PATTERNS --}}
|
||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||
<i class="bi bi-layers-fill text-info me-1"></i>Recommended role patterns
|
||
</h6>
|
||
<div class="table-responsive mb-5">
|
||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||
<thead style="background:#e2e8f0;">
|
||
<tr>
|
||
<th class="ps-3">ROLE</th>
|
||
<th>WHO</th>
|
||
<th>TYPICAL PERMISSIONS</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark"><i class="bi bi-shield-fill-check text-danger me-1"></i>Super Admin</td>
|
||
<td class="small text-muted">Owner / lead developer</td>
|
||
<td class="small"><span class="badge bg-danger-subtle text-danger rounded-pill">All permissions</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark"><i class="bi bi-shield-fill text-warning me-1"></i>Admin</td>
|
||
<td class="small text-muted">Operations lead, head of IT</td>
|
||
<td class="small text-muted">Everything except <code>manage backup and storage</code>, <code>manage maintenance mode</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark"><i class="bi bi-person-workspace text-primary me-1"></i>Manager</td>
|
||
<td class="small text-muted">Department / team lead</td>
|
||
<td class="small text-muted"><code>view *</code> + <code>manage users</code> (for their team), no infra permissions</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark"><i class="bi bi-person-fill text-success me-1"></i>Staff</td>
|
||
<td class="small text-muted">End user / operator</td>
|
||
<td class="small text-muted"><code>view dashboard</code>, module-specific viewers, nothing destructive</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark"><i class="bi bi-eye-fill text-secondary me-1"></i>Auditor</td>
|
||
<td class="small text-muted">Compliance / external audit</td>
|
||
<td class="small text-muted"><code>view *</code> only — including <code>view action history</code></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{{-- TROUBLESHOOTING --}}
|
||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||
<i class="bi bi-wrench-adjustable-circle text-secondary me-1"></i>Troubleshooting
|
||
</h6>
|
||
<div class="table-responsive mb-5">
|
||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||
<thead style="background:#e2e8f0;">
|
||
<tr>
|
||
<th class="ps-3">SYMPTOM</th>
|
||
<th>LIKELY CAUSE</th>
|
||
<th>FIX</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark">User gets 403 on a menu they should see</td>
|
||
<td class="small text-muted">Missing matching <code>view <module></code> permission</td>
|
||
<td class="small text-muted">Edit role → tick the relevant <code>view</code> permission</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark">User sees menu but action button missing</td>
|
||
<td class="small text-muted">Has <code>view</code> but not <code>manage</code></td>
|
||
<td class="small text-muted">Edit role → add the <code>manage</code> counterpart</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark">Permission change not taking effect</td>
|
||
<td class="small text-muted">Permission cache stale</td>
|
||
<td class="small text-muted">Run <code>php artisan permission:cache-reset</code> or re-login</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark">User has role but still blocked</td>
|
||
<td class="small text-muted">User itself is <span class="fw-bold">inactive / archived</span></td>
|
||
<td class="small text-muted">User Directory → restore / activate the user</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark">"You don't have permission" everywhere</td>
|
||
<td class="small text-muted">All roles got <code>manage access rights</code> stripped</td>
|
||
<td class="small text-muted">CLI rescue: <code>php artisan permission:grant {user} "manage access rights"</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td class="ps-3 fw-bold text-dark">Two users with same role behave differently</td>
|
||
<td class="small text-muted">One has <span class="fw-bold">additional</span> roles attached</td>
|
||
<td class="small text-muted">Permissions are <span class="fw-bold">union</span> across roles — check both users' role lists</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{{-- BEST PRACTICES --}}
|
||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||
<i class="bi bi-stars text-success me-1"></i>Best practices
|
||
</h6>
|
||
<div class="row g-2 mb-4">
|
||
@php
|
||
$tips = [
|
||
['icon'=>'bi-1-circle-fill','title'=>'Least privilege','desc'=>'Start each new role with <span class="fw-bold">nothing</span> ticked, then add only what is truly needed. Easier than starting from "everything" and trying to remember what to remove.'],
|
||
['icon'=>'bi-2-circle-fill','title'=>'One role per real-world position','desc'=>'Mirror your org chart. <em>Finance Manager</em>, <em>Warehouse Staff</em> — not <em>Role A</em>, <em>Custom 1</em>. Future-you will thank you.'],
|
||
['icon'=>'bi-3-circle-fill','title'=>'Keep Super Admin small','desc'=>'Only 1–2 people should have full <code>manage access rights</code>. Everyone else gets scoped roles.'],
|
||
['icon'=>'bi-4-circle-fill','title'=>'Audit periodically','desc'=>'Every quarter, open this page → scroll the role list → ask "does each role still match how the team works?" Archive stale ones.'],
|
||
['icon'=>'bi-5-circle-fill','title'=>'Don\'t reuse a guard','desc'=>'A role with <code>web</code> guard cannot be assigned to API users and vice versa. Decide before creating.'],
|
||
['icon'=>'bi-6-circle-fill','title'=>'Document non-obvious permissions','desc'=>'If a permission name doesn\'t make its scope obvious, write a short note in the description field when adding it.'],
|
||
];
|
||
@endphp
|
||
@foreach($tips as $tip)
|
||
<div class="col-md-6">
|
||
<div class="d-flex align-items-start gap-2 p-3 rounded-3" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||
<i class="{{ $tip['icon'] }} text-success fs-4"></i>
|
||
<div>
|
||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ $tip['title'] }}</div>
|
||
<div class="small text-muted" style="line-height:1.6;">{!! $tip['desc'] !!}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
|
||
{{-- FOOTER NOTE --}}
|
||
<div class="d-flex align-items-center gap-3 p-3 rounded-4" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||
<i class="bi bi-info-circle-fill text-primary fs-3"></i>
|
||
<div>
|
||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">Implementation note</div>
|
||
<div class="small text-muted">
|
||
This system uses <span class="fw-bold">Spatie Laravel Permission</span>. Roles and permissions are stored in the <code>roles</code>, <code>permissions</code>, <code>role_has_permissions</code>, and <code>model_has_roles</code> tables. Permission checks happen on every request via <code>@@can('...')</code> in Blade and <code>$user->can('...')</code> in controllers / middleware.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Floating Bulk Action Bar --}}
|
||
<div id="bulk-action-bar" class="d-none animate__animated animate__fadeIn position-fixed bottom-0 mb-4 start-50 translate-middle-x" style="z-index: 2500;">
|
||
<div class="bg-dark text-white border-0 rounded-pill px-3 py-2 d-flex align-items-center gap-3 shadow-lg" style="backdrop-filter: blur(15px); background-color: rgba(20, 20, 20, 0.98) !important; border: 1px solid rgba(255,255,255,0.1) !important;">
|
||
<div class="d-flex align-items-center gap-2 ps-2">
|
||
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center" style="width: 28px; height: 28px;">
|
||
<span class="small fw-bold text-white" id="selected-count">0</span>
|
||
</div>
|
||
<span class="small fw-bold d-none d-sm-inline">{{ __('Terpilih') }}</span>
|
||
</div>
|
||
|
||
<div class="vr bg-white opacity-25" style="height: 20px;"></div>
|
||
|
||
<div class="d-flex gap-1">
|
||
{{-- Quick Actions --}}
|
||
<button title="{{ __('Aktifkan') }}" class="btn btn-link text-success p-2 bulk-btn" data-action="activate">
|
||
<i class="bi bi-check-circle fs-5"></i>
|
||
</button>
|
||
<button title="{{ __('Nonaktifkan') }}" class="btn btn-link text-white p-2 bulk-btn" data-action="deactivate">
|
||
<i class="bi bi-dash-circle fs-5"></i>
|
||
</button>
|
||
|
||
<div class="vr bg-white opacity-25 mx-1" style="height: 20px; margin-top: 10px;"></div>
|
||
|
||
{{-- Contextual Actions --}}
|
||
<button title="{{ __('Arsipkan') }}" class="btn btn-link p-2 bulk-btn show-if-active" data-action="archive">
|
||
<i class="bi bi-trash fs-5 text-danger" style="color: #dc3545 !important;"></i>
|
||
</button>
|
||
<button title="{{ __('Pulihkan') }}" class="btn btn-link text-info p-2 bulk-btn show-if-archived d-none" data-action="restore">
|
||
<i class="bi bi-arrow-counterclockwise fs-5"></i>
|
||
</button>
|
||
<button title="{{ __('Hapus Permanen') }}" class="btn btn-link text-danger p-2 bulk-btn show-if-archived d-none" data-action="terminate">
|
||
<i class="bi bi-x-circle fs-5"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="vr bg-white opacity-25" style="height: 20px;"></div>
|
||
|
||
<button type="button" class="btn-close btn-close-white btn-sm me-2" id="clear-selection" style="font-size: 0.6rem;"></button>
|
||
</div>
|
||
</div>
|
||
</x-app-layout> |