Files
data-entry-app/frontend/src/lib/components/settings/RoleManagementPanel.svelte
T
admin 3f8279af10 v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
2026-06-17 21:55:04 +12:00

793 lines
20 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type {
InternalRole,
InternalRoleCreateInput,
InternalRoleModuleDefinition,
InternalRoleUpdateInput
} from '$lib/types';
import { Pencil, ShieldCheck, Trash2, TriangleAlert, Waypoints, Plus } from 'lucide-svelte';
let roles = $state<InternalRole[]>([]);
let modules = $state<InternalRoleModuleDefinition[]>([]);
let loading = $state(true);
let loadError = $state('');
async function load() {
loading = true;
loadError = '';
try {
const [roleList, moduleList] = await Promise.all([
api.accessRoles(),
api.accessRoleModules()
]);
roles = roleList;
modules = moduleList;
} catch (err: unknown) {
loadError = err instanceof Error ? err.message : 'Failed to load roles';
} finally {
loading = false;
}
}
onMount(load);
function emptyModulePermissions() {
return Object.fromEntries(modules.map((module) => [module.key, 'none'])) as Record<string, string>;
}
type FormMode = 'create' | 'edit';
let formOpen = $state(false);
let formMode = $state<FormMode>('create');
let formRoleId = $state<number | null>(null);
let formName = $state('');
let formDescription = $state('');
let formModulePermissions = $state<Record<string, string>>({});
let formProtected = $state(false);
let formSaving = $state(false);
let formError = $state('');
function openCreate() {
formMode = 'create';
formRoleId = null;
formName = '';
formDescription = '';
formModulePermissions = emptyModulePermissions();
formProtected = false;
formError = '';
formOpen = true;
}
function openEdit(role: InternalRole) {
formMode = 'edit';
formRoleId = role.id;
formName = role.name;
formDescription = role.description ?? '';
formModulePermissions = { ...emptyModulePermissions(), ...role.module_permissions };
formProtected = role.is_protected;
formError = '';
formOpen = true;
}
function closeForm() {
if (formSaving) return;
formOpen = false;
}
function setModuleLevel(moduleKey: string, level: string) {
formModulePermissions = { ...formModulePermissions, [moduleKey]: level };
}
function summary(role: InternalRole) {
return modules
.map((module) => {
const level = role.module_permissions[module.key];
return level && level !== 'none' ? `${module.label}: ${level}` : null;
})
.filter(Boolean)
.join(' • ');
}
async function saveForm() {
formError = '';
const name = formName.trim();
if (!name) {
formError = 'Role name is required';
return;
}
formSaving = true;
const tid = toast.loading(formMode === 'create' ? 'Creating role…' : 'Saving role…');
try {
const payload: InternalRoleCreateInput | InternalRoleUpdateInput = {
name,
description: formDescription.trim() || null,
module_permissions: formModulePermissions
};
if (formMode === 'create') {
await api.createAccessRole(payload as InternalRoleCreateInput);
} else if (formRoleId != null) {
await api.updateAccessRole(formRoleId, payload);
}
toast.dismiss(tid);
toast.success(formMode === 'create' ? 'Role created' : 'Role updated');
formOpen = false;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
const message = err instanceof Error ? err.message : 'An error occurred';
formError = message;
toast.error(message);
} finally {
formSaving = false;
}
}
let deleteRole = $state<InternalRole | null>(null);
let deleting = $state(false);
function openDelete(role: InternalRole) {
deleteRole = role;
}
function closeDelete() {
if (deleting) return;
deleteRole = null;
}
async function confirmDelete() {
if (!deleteRole) return;
deleting = true;
const tid = toast.loading('Deleting role…');
try {
await api.deleteAccessRole(deleteRole.id);
toast.dismiss(tid);
toast.success(`Deleted ${deleteRole.name}`);
deleteRole = null;
await load();
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to delete role');
} finally {
deleting = false;
}
}
</script>
<div class="panel-section">
<header class="panel-header">
<div class="header-copy">
<h2>Roles</h2>
<p>Define which modules each role can open, edit, or manage.</p>
</div>
<button type="button" class="btn-primary" onclick={openCreate}>
<Plus size={16} strokeWidth={2.2} /> Add role
</button>
</header>
{#if loading}
<p class="state-msg">Loading roles…</p>
{:else if loadError}
<p class="state-msg error"><TriangleAlert size={15} strokeWidth={2.2} /> {loadError}</p>
{:else}
<div class="table-wrap">
<table class="roles-table">
<thead>
<tr>
<th>Role</th>
<th>Assigned users</th>
<th>Module access</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{#each roles as role (role.id)}
<tr>
<td>
<div class="role-cell">
<div class="role-title-row">
<strong>{role.name}</strong>
{#if role.is_protected}
<span class="protected-chip">
<ShieldCheck size={12} strokeWidth={2.4} /> Protected
</span>
{/if}
</div>
{#if role.description}
<p>{role.description}</p>
{/if}
</div>
</td>
<td>{role.user_count}</td>
<td class="summary-cell">{summary(role) || 'No module access'}</td>
<td class="actions-col">
<div class="row-actions">
<button type="button" class="icon-btn" title="Edit role" onclick={() => openEdit(role)}>
<Pencil size={15} strokeWidth={2.1} />
</button>
<button
type="button"
class="icon-btn danger"
title={role.is_protected
? 'Lean and admin roles cannot be deleted'
: role.user_count > 0
? 'Reassign users before deleting this role'
: 'Delete role'}
disabled={role.is_protected || role.user_count > 0}
onclick={() => openDelete(role)}
>
<Trash2 size={15} strokeWidth={2.1} />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if formOpen}
<div class="modal-backdrop" role="presentation" onclick={closeForm}>
<div
class="modal-card modal-card-wide"
role="dialog"
aria-modal="true"
aria-labelledby="role-form-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') closeForm(); }}
>
<div class="modal-top">
<div class="modal-icon"><Waypoints size={20} strokeWidth={2.2} /></div>
<div>
<h2 id="role-form-title" class="modal-title">{formMode === 'create' ? 'Add role' : 'Edit role'}</h2>
<p class="modal-text">Module access levels are translated into the underlying permissions automatically.</p>
</div>
</div>
<form class="modal-form" onsubmit={(event) => { event.preventDefault(); saveForm(); }}>
<div class="field-row">
<div class="field">
<label for="rf-name">Role name</label>
<input id="rf-name" type="text" bind:value={formName} disabled={formProtected && formMode === 'edit'} required />
</div>
<div class="field">
<label for="rf-description">Description</label>
<input id="rf-description" type="text" bind:value={formDescription} />
</div>
</div>
<div class="permissions-section">
<div class="permissions-head">
<div>
<h3>Module access</h3>
<p>Each row controls where this role can go and what it can do there.</p>
</div>
</div>
<div class="permissions-scroll">
<table class="permissions-table">
<thead>
<tr>
<th>Module</th>
<th>What it covers</th>
<th>Access level</th>
</tr>
</thead>
<tbody>
{#each modules as module (module.key)}
<tr>
<td class="module-name-cell">
<strong>{module.label}</strong>
</td>
<td class="module-description-cell">{module.description}</td>
<td class="module-level-cell">
<label class="matrix-select">
<span class="sr-only">Access level for {module.label}</span>
<select
value={formModulePermissions[module.key] ?? 'none'}
onchange={(event) => setModuleLevel(module.key, (event.currentTarget as HTMLSelectElement).value)}
>
{#each module.levels as level (level)}
<option value={level}>{level}</option>
{/each}
</select>
</label>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{#if formError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {formError}</p>
{/if}
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeForm} disabled={formSaving}>Cancel</button>
<button type="submit" class="btn-primary" disabled={formSaving}>
{formSaving ? 'Saving…' : formMode === 'create' ? 'Create role' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
{/if}
{#if deleteRole}
<div class="modal-backdrop" role="presentation" onclick={closeDelete}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-role-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') closeDelete(); }}
>
<div class="modal-icon danger"><Trash2 size={20} strokeWidth={2.2} /></div>
<h2 id="delete-role-title" class="modal-title">Delete role?</h2>
<p class="modal-text">
This removes <strong>{deleteRole.name}</strong>. Users must be reassigned first.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={closeDelete} disabled={deleting}>Cancel</button>
<button type="button" class="modal-confirm" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete role'}
</button>
</div>
</div>
</div>
{/if}
<style>
.panel-section {
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.5rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.header-copy h2,
.modal-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.header-copy p,
.modal-text {
margin: 0.3rem 0 0;
font-size: 0.85rem;
color: var(--muted);
}
.btn-primary,
.modal-confirm,
.modal-cancel,
.icon-btn {
transition: opacity 140ms ease, border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 1.1rem;
background: var(--color-brand);
color: #fff;
border: none;
border-radius: 0.6rem;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled,
.modal-confirm:disabled,
.modal-cancel:disabled,
.icon-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.state-msg {
padding: 1.5rem 1.75rem;
margin: 0;
color: var(--muted);
}
.state-msg.error,
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
color: #c53030;
}
.table-wrap {
overflow-x: auto;
padding: 0.5rem 1.75rem 1.75rem;
}
.roles-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.roles-table th,
.roles-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.roles-table th {
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.role-cell p,
.summary-cell {
margin: 0.25rem 0 0;
color: var(--muted);
line-height: 1.45;
}
.role-title-row {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.protected-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.08rem 0.42rem;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, transparent);
color: var(--color-brand);
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
}
.actions-col {
text-align: right;
white-space: nowrap;
}
.row-actions {
display: inline-flex;
gap: 0.3rem;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel);
color: var(--muted);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
color: var(--text);
border-color: var(--color-brand);
}
.icon-btn.danger:hover:not(:disabled) {
color: #c53030;
border-color: color-mix(in srgb, #e53e3e 45%, transparent);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(34rem, 100%);
display: grid;
gap: 0.8rem;
padding: 1.6rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card-wide {
width: min(54rem, 100%);
max-height: min(88vh, 60rem);
}
.modal-top {
display: flex;
gap: 0.85rem;
align-items: flex-start;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
color: var(--color-brand);
}
.modal-icon.danger {
background: #fdecee;
color: #b3261e;
}
.modal-form {
display: grid;
gap: 0.85rem;
min-height: 0;
}
.field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.field,
.matrix-select {
display: grid;
gap: 0.4rem;
}
.field label,
.matrix-select span {
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
}
.field input,
.matrix-select select {
width: 100%;
padding: 0.58rem 0.8rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
box-sizing: border-box;
}
.permissions-section {
display: grid;
gap: 0.75rem;
min-height: 0;
}
.permissions-head h3 {
margin: 0;
font-size: 0.94rem;
font-weight: 700;
color: var(--text);
}
.permissions-head p {
margin: 0.28rem 0 0;
font-size: 0.82rem;
color: var(--muted);
}
.permissions-scroll {
min-height: 0;
max-height: min(46vh, 30rem);
overflow: auto;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: var(--panel-soft);
}
.permissions-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.permissions-table th,
.permissions-table td {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.permissions-table th {
position: sticky;
top: 0;
z-index: 1;
background: color-mix(in srgb, var(--panel) 92%, var(--panel-soft));
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.permissions-table tbody tr:last-child td {
border-bottom: none;
}
.module-name-cell {
width: 11rem;
}
.module-name-cell strong {
display: block;
color: var(--text);
}
.module-description-cell {
color: var(--muted);
font-size: 0.83rem;
line-height: 1.45;
}
.module-level-cell {
width: 11rem;
}
.module-level-cell .matrix-select {
gap: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.form-error {
margin: 0;
padding: 0.6rem 0.8rem;
background: color-mix(in srgb, #e53e3e 8%, transparent);
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
border-radius: 0.55rem;
font-size: 0.83rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.3rem;
}
.modal-cancel {
padding: 0.55rem 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
.modal-confirm {
padding: 0.55rem 1.1rem;
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
@media (max-width: 860px) {
.field-row {
grid-template-columns: 1fr;
}
.permissions-scroll {
max-height: min(44vh, 26rem);
}
.permissions-table,
.permissions-table thead,
.permissions-table tbody,
.permissions-table tr,
.permissions-table th,
.permissions-table td {
display: block;
}
.permissions-table thead {
display: none;
}
.permissions-table tbody {
display: grid;
}
.permissions-table tr {
display: grid;
gap: 0.55rem;
padding: 0.95rem 1rem;
border-bottom: 1px solid var(--line);
}
.permissions-table td {
width: auto;
padding: 0;
border: none;
}
.module-level-cell .matrix-select {
gap: 0.35rem;
}
.module-level-cell .matrix-select .sr-only {
position: static;
width: auto;
height: auto;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
}
}
@media (max-width: 720px) {
.panel-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>