300 lines
14 KiB
Svelte
300 lines
14 KiB
Svelte
|
|
<script>
|
||
|
|
let { roles = [], permissions = {}, adminPath = 'loom', status = null, errors = [] } = $props();
|
||
|
|
|
||
|
|
let displayRoles = $state([]);
|
||
|
|
$effect(() => {
|
||
|
|
displayRoles = roles;
|
||
|
|
});
|
||
|
|
let savingPermission = $state(false);
|
||
|
|
|
||
|
|
let newRoleName = $state('');
|
||
|
|
let newRoleSlug = $state('');
|
||
|
|
let newRoleDescription = $state('');
|
||
|
|
|
||
|
|
let editingRole = $state(null);
|
||
|
|
let editRoleName = $state('');
|
||
|
|
let editRoleSlug = $state('');
|
||
|
|
let editRoleDescription = $state('');
|
||
|
|
|
||
|
|
// Convert grouped permissions to a flat list for easier processing
|
||
|
|
let allPermissions = $derived(Object.values(permissions).flat());
|
||
|
|
let groupedPermissions = $derived(permissions);
|
||
|
|
|
||
|
|
async function togglePermission(roleId, permId, isActive) {
|
||
|
|
savingPermission = true;
|
||
|
|
try {
|
||
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||
|
|
const baseUrl = window.location.origin.endsWith('/') ? window.location.origin : `${window.location.origin}/`;
|
||
|
|
const path = adminPath.startsWith('/') ? adminPath.substring(1) : adminPath;
|
||
|
|
const url = `${baseUrl}${path}/roles/${roleId}/permissions`;
|
||
|
|
|
||
|
|
const formData = new FormData();
|
||
|
|
formData.append('_token', csrfToken);
|
||
|
|
formData.append('permission_id', permId);
|
||
|
|
if (isActive) {
|
||
|
|
formData.append('active', 'on');
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await fetch(url, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'X-Requested-With': 'XMLHttpRequest',
|
||
|
|
'Accept': 'application/json'
|
||
|
|
},
|
||
|
|
body: formData
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
if (data.success && data.role) {
|
||
|
|
// Update local state
|
||
|
|
const index = displayRoles.findIndex(r => r.id === roleId);
|
||
|
|
if (index !== -1) {
|
||
|
|
displayRoles[index] = data.role;
|
||
|
|
}
|
||
|
|
} else if (data.errors) {
|
||
|
|
alert(data.errors.join('\n'));
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to update permission:', error);
|
||
|
|
alert('Failed to update permission. Please try again.');
|
||
|
|
} finally {
|
||
|
|
savingPermission = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleNameInput(e) {
|
||
|
|
newRoleSlug = e.target.value.toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleEditNameInput(e) {
|
||
|
|
editRoleSlug = e.target.value.toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
function startEditing(role) {
|
||
|
|
editingRole = role;
|
||
|
|
editRoleName = role.name;
|
||
|
|
editRoleSlug = role.slug;
|
||
|
|
editRoleDescription = role.description || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function cancelEditing() {
|
||
|
|
editingRole = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function deleteRole(id, name) {
|
||
|
|
if (confirm(`Are you sure you want to delete the role "${name}"? This cannot be undone.`)) {
|
||
|
|
const form = document.createElement('form');
|
||
|
|
form.method = 'POST';
|
||
|
|
const baseUrl = window.location.origin.endsWith('/') ? window.location.origin : `${window.location.origin}/`;
|
||
|
|
const path = adminPath.startsWith('/') ? adminPath.substring(1) : adminPath;
|
||
|
|
const url = `${baseUrl}${path}/roles/${id}`;
|
||
|
|
form.action = url;
|
||
|
|
|
||
|
|
const methodInput = document.createElement('input');
|
||
|
|
methodInput.type = 'hidden';
|
||
|
|
methodInput.name = '_method';
|
||
|
|
methodInput.value = 'DELETE';
|
||
|
|
|
||
|
|
const tokenInput = document.createElement('input');
|
||
|
|
tokenInput.type = 'hidden';
|
||
|
|
tokenInput.name = '_token';
|
||
|
|
tokenInput.value = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||
|
|
|
||
|
|
form.appendChild(methodInput);
|
||
|
|
form.appendChild(tokenInput);
|
||
|
|
document.body.appendChild(form);
|
||
|
|
form.submit();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function updatePermissions(roleId) {
|
||
|
|
const form = document.getElementById(`perms-form-${roleId}`);
|
||
|
|
form.submit();
|
||
|
|
}
|
||
|
|
|
||
|
|
function checkboxAction(node) {
|
||
|
|
if (typeof jQuery !== 'undefined') {
|
||
|
|
jQuery(node).checkbox({
|
||
|
|
onChange: function() {
|
||
|
|
const input = node.querySelector('input');
|
||
|
|
const [_, roleId, permId] = input.id.split('-');
|
||
|
|
togglePermission(parseInt(roleId), parseInt(permId), input.checked);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<div class="ui container">
|
||
|
|
<div class="ui grid">
|
||
|
|
<div class="sixteen wide column">
|
||
|
|
<h1 class="ui header">
|
||
|
|
<i class="shield alternate icon"></i>
|
||
|
|
<div class="content">
|
||
|
|
Roles & Permissions
|
||
|
|
<div class="sub header">Define roles and assign granular authorizations to them</div>
|
||
|
|
</div>
|
||
|
|
{#if savingPermission}
|
||
|
|
<div class="ui active mini inline loader" style="margin-left: 10px;"></div>
|
||
|
|
{/if}
|
||
|
|
</h1>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="sixteen wide column">
|
||
|
|
{#if status}
|
||
|
|
<div class="ui positive message">
|
||
|
|
<i class="close icon"></i>
|
||
|
|
<p>{status}</p>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
{#if errors && errors.length > 0}
|
||
|
|
<div class="ui negative message">
|
||
|
|
<i class="close icon"></i>
|
||
|
|
<ul class="list">
|
||
|
|
{#each errors as error}
|
||
|
|
<li>{error}</li>
|
||
|
|
{/each}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="six wide column">
|
||
|
|
{#if editingRole}
|
||
|
|
<div class="ui blue segment">
|
||
|
|
<h3 class="ui header">Edit Role: {editingRole.name}</h3>
|
||
|
|
<form class="ui form" method="POST" action="{window.location.origin.endsWith('/') ? window.location.origin : window.location.origin + '/'}{adminPath.startsWith('/') ? adminPath.substring(1) : adminPath}/roles/{editingRole.id}">
|
||
|
|
<input type="hidden" name="_token" value={document.querySelector('meta[name="csrf-token"]').getAttribute('content')}>
|
||
|
|
<input type="hidden" name="_method" value="PUT">
|
||
|
|
<div class="field required">
|
||
|
|
<label for="edit_name">Role Name</label>
|
||
|
|
<input id="edit_name" type="text" name="name" bind:value={editRoleName} oninput={handleEditNameInput} placeholder="Editor" required>
|
||
|
|
</div>
|
||
|
|
<div class="field required">
|
||
|
|
<label for="edit_slug">Slug</label>
|
||
|
|
<input id="edit_slug" type="text" name="slug" bind:value={editRoleSlug} placeholder="editor" required>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label for="edit_description">Description</label>
|
||
|
|
<textarea id="edit_description" name="description" bind:value={editRoleDescription} rows="2" placeholder="Briefly describe what this role can do"></textarea>
|
||
|
|
</div>
|
||
|
|
<div class="two fields">
|
||
|
|
<div class="field">
|
||
|
|
<button type="button" class="ui fluid button" onclick={cancelEditing}>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<button type="submit" class="ui primary fluid button">
|
||
|
|
Update Role
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
{:else}
|
||
|
|
<div class="ui segment">
|
||
|
|
<h3 class="ui header">Create New Role</h3>
|
||
|
|
<form class="ui form" method="POST" action="{window.location.origin.endsWith('/') ? window.location.origin : window.location.origin + '/'}{adminPath.startsWith('/') ? adminPath.substring(1) : adminPath}/roles">
|
||
|
|
<input type="hidden" name="_token" value={document.querySelector('meta[name="csrf-token"]').getAttribute('content')}>
|
||
|
|
<div class="field required">
|
||
|
|
<label for="new_name">Role Name</label>
|
||
|
|
<input id="new_name" type="text" name="name" bind:value={newRoleName} oninput={handleNameInput} placeholder="Editor" required>
|
||
|
|
</div>
|
||
|
|
<div class="field required">
|
||
|
|
<label for="new_slug">Slug</label>
|
||
|
|
<input id="new_slug" type="text" name="slug" bind:value={newRoleSlug} placeholder="editor" required>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label for="new_description">Description</label>
|
||
|
|
<textarea id="new_description" name="description" bind:value={newRoleDescription} rows="2" placeholder="Briefly describe what this role can do"></textarea>
|
||
|
|
</div>
|
||
|
|
<button type="submit" class="ui primary fluid button">
|
||
|
|
<i class="plus icon"></i> Create Role
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="ten wide column">
|
||
|
|
<div class="ui segment">
|
||
|
|
<h3 class="ui header">Existing Roles</h3>
|
||
|
|
<div class="ui divided list">
|
||
|
|
{#each displayRoles as role}
|
||
|
|
<div class="item" style="padding: 1em 0;">
|
||
|
|
<div class="right floated content">
|
||
|
|
{#if !role.is_protected}
|
||
|
|
<button class="ui mini blue icon button" onclick={() => startEditing(role)} title="Edit Role">
|
||
|
|
<i class="edit icon"></i>
|
||
|
|
</button>
|
||
|
|
<button class="ui mini red icon button" onclick={() => deleteRole(role.id, role.name)} title="Delete Role">
|
||
|
|
<i class="trash icon"></i>
|
||
|
|
</button>
|
||
|
|
{:else}
|
||
|
|
<div class="ui mini basic label"><i class="lock icon"></i> Protected</div>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
<div class="content">
|
||
|
|
<div class="header">{role.name} <code style="font-weight: normal; font-size: 0.8em; margin-left: 0.5em;">({role.slug})</code></div>
|
||
|
|
<div class="description">{role.description || 'No description provided.'}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{/each}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="sixteen wide column">
|
||
|
|
<div class="ui segment">
|
||
|
|
<h2 class="ui dividing header">Authorization Assignment Grid</h2>
|
||
|
|
<div style="overflow-x: auto;">
|
||
|
|
<table class="ui celled definition table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th></th>
|
||
|
|
{#each displayRoles as role}
|
||
|
|
<th class="center aligned">{role.name}</th>
|
||
|
|
{/each}
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{#each Object.entries(groupedPermissions) as [resource, perms]}
|
||
|
|
<tr class="active">
|
||
|
|
<td colspan={displayRoles.length + 1}>
|
||
|
|
<strong>{resource.charAt(0).toUpperCase() + resource.slice(1)} Management</strong>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{#each perms as perm}
|
||
|
|
<tr>
|
||
|
|
<td>{perm.name}</td>
|
||
|
|
{#each displayRoles as role}
|
||
|
|
<td class="center aligned">
|
||
|
|
{#if role.is_protected && role.slug === 'admin'}
|
||
|
|
<i class="green check circle icon" title="Admin has all permissions"></i>
|
||
|
|
{:else}
|
||
|
|
<div class="ui fitted toggle checkbox {savingPermission ? 'disabled' : ''}" use:checkboxAction>
|
||
|
|
<input
|
||
|
|
id="perm-{role.id}-{perm.id}"
|
||
|
|
type="checkbox"
|
||
|
|
name="active"
|
||
|
|
checked={role.permissions.some(p => p.id === perm.id)}
|
||
|
|
disabled={role.is_protected || savingPermission}
|
||
|
|
>
|
||
|
|
<label for="perm-{role.id}-{perm.id}"></label>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
</td>
|
||
|
|
{/each}
|
||
|
|
</tr>
|
||
|
|
{/each}
|
||
|
|
{/each}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|