793 lines
20 KiB
Svelte
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>
|