panels-origin/web-panel/components/IamPermissionMatrix.vue
Cursor Agent 1a50823368
IAM: grouped CRUD permission matrix with hints and bulk actions
Replace the flat checkbox list with modules grouped in a Ver/Crear/Editar/
Eliminar grid, module descriptions, search, view-only shortcuts, and quick
template apply.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 19:11:14 +00:00

277 lines
7.5 KiB
Vue

<template>
<div class="iam-perm-matrix">
<div class="matrix-toolbar">
<IconField class="matrix-search">
<InputIcon class="pi pi-search" />
<InputText v-model="search" placeholder="Buscar módulo o permiso" size="small" class="w-full" />
</IconField>
<div class="matrix-quick">
<Button label="Solo ver" size="small" text :disabled="disabled" @click="applyViewOnly" />
<Button label="Limpiar" size="small" text severity="secondary" :disabled="disabled" @click="clearAll" />
</div>
</div>
<div v-if="templateOptions.length" class="matrix-templates">
<span class="templates-label">Plantilla rápida</span>
<Select
v-model="pickedTemplate"
:options="templateOptions"
option-label="label"
option-value="value"
placeholder="Aplicar conjunto…"
size="small"
class="w-full"
:disabled="disabled"
/>
</div>
<div class="matrix-scroll">
<section
v-for="mod in filteredModules"
:key="mod.key"
class="matrix-module"
>
<div class="module-head">
<div class="module-title-row">
<Checkbox
:model-value="moduleChecked(mod) === 'all'"
:indeterminate="moduleChecked(mod) === 'partial'"
binary
:disabled="disabled"
@update:model-value="(v: boolean) => toggleModule(mod, v)"
/>
<span class="module-title">{{ mod.label }}</span>
</div>
<p class="module-desc">{{ mod.description }}</p>
<div class="module-actions">
<Button label="Todo" size="small" text :disabled="disabled" @click="toggleModule(mod, true)" />
<Button label="Solo ver" size="small" text :disabled="disabled" @click="toggleModuleViewOnly(mod)" />
</div>
</div>
<div class="matrix-table-wrap">
<table class="matrix-table">
<thead>
<tr>
<th class="col-area">Área</th>
<th v-for="verb in mod.verbs" :key="verb" class="col-verb" :title="IAM_VERB_HINTS[verb]">
{{ IAM_VERB_LABELS[verb] }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in mod.rows" :key="row.key">
<td class="col-area">
<span class="row-label">{{ row.label }}</span>
<span v-if="row.hint && mod.rows.length > 1" class="row-hint">{{ row.hint }}</span>
</td>
<td v-for="verb in mod.verbs" :key="verb" class="col-verb">
<Checkbox
v-if="row.cells[verb]"
v-model="model"
:value="row.cells[verb]!.code"
:disabled="disabled"
v-tooltip.top="row.cells[verb]!.hint"
/>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<p v-if="!filteredModules.length" class="matrix-empty">Sin permisos que coincidan con la búsqueda.</p>
</div>
</div>
</template>
<script setup lang="ts">
import {
buildIamMatrixModules,
IAM_VERB_HINTS,
IAM_VERB_LABELS,
moduleSelectionState,
toggleCodes,
viewOnlyCodes,
type IamMatrixModule,
type IamPermRow,
} from "~/composables/iamPermissionMatrix";
const props = defineProps<{
catalog: IamPermRow[];
disabled?: boolean;
templates?: Record<string, { label: string; permissions: string[] }>;
}>();
const model = defineModel<string[]>({ required: true });
const search = ref("");
const pickedTemplate = ref<string | null>(null);
const modules = computed(() => buildIamMatrixModules(props.catalog));
const templateOptions = computed(() =>
Object.entries(props.templates ?? {}).map(([value, t]) => ({ value, label: t.label })),
);
const filteredModules = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return modules.value;
return modules.value
.map((mod) => {
const haystack = [
mod.label,
mod.description,
...mod.rows.flatMap((r) => [r.label, r.hint ?? "", ...Object.values(r.cells).map((c) => c?.label ?? "")]),
].join(" ").toLowerCase();
if (haystack.includes(q)) return mod;
const rows = mod.rows.filter((row) => {
const rowText = [row.label, row.hint ?? "", ...Object.values(row.cells).map((c) => c?.label ?? "")].join(" ").toLowerCase();
return rowText.includes(q);
});
if (!rows.length) return null;
return { ...mod, rows };
})
.filter(Boolean) as IamMatrixModule[];
});
const selectedSet = computed(() => new Set(model.value));
function moduleChecked(mod: IamMatrixModule): "none" | "partial" | "all" {
return moduleSelectionState(selectedSet.value, mod.codes);
}
function toggleModule(mod: IamMatrixModule, on: boolean) {
model.value = toggleCodes(model.value, mod.codes, on);
}
function toggleModuleViewOnly(mod: IamMatrixModule) {
model.value = toggleCodes(
toggleCodes(model.value, mod.codes, false),
viewOnlyCodes(mod.codes, props.catalog),
true,
);
}
function applyViewOnly() {
const allCodes = modules.value.flatMap((m) => m.codes);
model.value = viewOnlyCodes(allCodes, props.catalog);
}
function clearAll() {
model.value = [];
}
function applyTemplate(key: string | null) {
if (!key || !props.templates?.[key]) return;
const allowed = new Set(props.catalog.map((p) => p.code));
model.value = props.templates[key].permissions.filter((c) => allowed.has(c));
pickedTemplate.value = null;
}
watch(pickedTemplate, (key) => {
if (key) applyTemplate(key);
});
</script>
<style scoped>
.iam-perm-matrix {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.matrix-toolbar {
display: flex;
flex-direction: column;
gap: 6px;
}
.matrix-search { width: 100%; }
.matrix-quick {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.matrix-templates {
display: flex;
flex-direction: column;
gap: 4px;
}
.templates-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-color-secondary);
}
.matrix-scroll {
max-height: 52vh;
overflow: auto;
padding-right: 2px;
}
.matrix-module {
border: 1px solid var(--surface-border);
border-radius: 8px;
margin-bottom: 10px;
overflow: hidden;
background: var(--surface-card);
}
.module-head {
padding: 8px 10px;
background: var(--surface-50);
border-bottom: 1px solid var(--surface-border);
}
.module-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.module-title {
font-weight: 600;
font-size: 13px;
}
.module-desc {
margin: 4px 0 6px 28px;
font-size: 11px;
line-height: 1.35;
color: var(--text-color-secondary);
}
.module-actions {
display: flex;
gap: 2px;
margin-left: 28px;
}
.matrix-table-wrap { overflow-x: auto; }
.matrix-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.matrix-table th,
.matrix-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--surface-border);
vertical-align: middle;
}
.matrix-table th {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-color-secondary);
background: var(--surface-ground);
}
.col-area { min-width: 7rem; text-align: left; }
.col-verb { width: 2.5rem; text-align: center; }
.row-label { display: block; font-weight: 500; }
.row-hint {
display: block;
font-size: 10px;
color: var(--text-color-secondary);
margin-top: 2px;
}
.matrix-empty {
font-size: 12px;
color: var(--text-color-secondary);
text-align: center;
padding: 16px 8px;
}
</style>