Compare commits

..

5 commits

Author SHA1 Message Date
Alberto Martinez
9ea4706954 IAM UX: dialog fixes, auto role code, permission matrix
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Changes

### IAM dialog UX
- Checkbox / scope fixes in user edit dialog
- Auto-generated role code from label (no manual Código field)

### Permission matrix (detail panel)
Replaces the flat list of 50+ checkboxes with a structured matrix:

- **Grouped by module** (Presupuesto, Gastos, Nómina, Documentos de obra, etc.)
- **Short description** per module explaining what it controls
- **CRUD columns**: Ver / Crear / Editar / Eliminar
- **Tooltips** on each cell describing the specific permission
- **Bulk actions**: Solo ver, Limpiar, Todo / Solo ver per module
- **Search** by module or permission name
- **Plantilla rápida** dropdown (Operativo, Supervisor, Contador, …)

## Test

1. Usuarios IAM → Roles → select a tenant role (not system).
2. Detail panel shows grouped matrix instead of flat list.
3. Use module checkbox or **Todo** / **Solo ver** to toggle a whole area.
4. Hover a cell for tooltip; apply **Plantilla rápida** → **Guardar permisos**.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-0da3751a-38ff-40ab-bba2-5df2322c8cda?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-0da3751a-38ff-40ab-bba2-5df2322c8cda&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-08 19:15:52 +00:00
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
Cursor Agent
2f7f121121
IAM: auto-generate role code from label when creating roles
Remove the manual Código field from the new-role dialog. Prefill label from
the selected template and derive a unique slug code on save.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 19:08:46 +00:00
Cursor Agent
d9aeee1073
IAM dialog: checkbox text no longer toggles the control
Replace label[for] captions with plain spans so warehouse and scope
options only change when clicking the checkbox itself.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 19:07:02 +00:00
Cursor Agent
4767676976
Fix scope MultiSelect toggling 'Todas las obras' checkbox
FormField wrapped slot content in a <label>, so clicks on the project
MultiSelect bubbled to the first control inside (scope_all_projects).
Use a div wrapper for multi-control field groups.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 19:05:19 +00:00
5 changed files with 681 additions and 42 deletions

View file

@ -17,34 +17,37 @@
<div v-else-if="detail.kind === 'iam-role' && detail.iamRole" class="iam-detail"> <div v-else-if="detail.kind === 'iam-role' && detail.iamRole" class="iam-detail">
<div class="section-title">Matriz del rol</div> <div class="section-title">Matriz del rol</div>
<p><strong>{{ detail.iamRole.label }}</strong> ({{ detail.iamRole.code }})</p> <p class="role-heading"><strong>{{ detail.iamRole.label }}</strong> ({{ detail.iamRole.code }})</p>
<Message v-if="detail.iamRole.is_system" severity="info" :closable="false">Rol de sistema: no editable</Message> <Message v-if="detail.iamRole.is_system" severity="info" :closable="false">Rol de sistema: no editable</Message>
<div v-else class="perm-grid mt-3"> <template v-else>
<div v-for="perm in groupedPermissions" :key="perm.code" class="perm-item"> <p class="matrix-intro">
<Checkbox Marque columnas <strong>Ver / Crear / Editar / Eliminar</strong> por módulo. Pase el cursor sobre cada casilla para ver qué habilita.
v-model="localPerms" </p>
:input-id="perm.code" <IamPermissionMatrix
:value="perm.code" v-model="localPerms"
:disabled="!canEdit" :catalog="assignableCatalog"
/> :templates="roleTemplates"
<label :for="perm.code">{{ perm.label }}</label> :disabled="!canEdit"
</div> />
</div> <Button
<Button v-if="canEdit"
v-if="canEdit" label="Guardar permisos"
label="Guardar permisos" icon="pi pi-save"
icon="pi pi-save" class="mt-3 w-full"
class="mt-3 w-full" size="small"
size="small" @click="savePerms"
@click="savePerms" />
/> </template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { displayErrorMessage } from "~/composables/api-response"; import { displayErrorMessage } from "~/composables/api-response";
import {
type PermRow = { code: string; label: string; module?: string; group?: string }; filterAssignableCatalog,
IAM_ROLE_TEMPLATES,
type IamPermRow,
} from "~/composables/iamPermissionMatrix";
const props = defineProps<{ const props = defineProps<{
detail: { detail: {
@ -57,18 +60,32 @@ const props = defineProps<{
const { api } = useApi(); const { api } = useApi();
const toast = useToast(); const toast = useToast();
const perms = usePermissions(); const perms = usePermissions();
const catalog = ref<PermRow[]>([]); const catalog = ref<IamPermRow[]>([]);
const localPerms = ref<string[]>([]); const localPerms = ref<string[]>([]);
const canEdit = computed(() => const canEdit = computed(() =>
perms.can("users.update") && props.detail.kind === "iam-role" && !props.detail.iamRole?.is_system, perms.can("users.update") && props.detail.kind === "iam-role" && !props.detail.iamRole?.is_system,
); );
const groupedPermissions = computed(() => catalog.value); const assignableCatalog = computed(() => filterAssignableCatalog(catalog.value, perms.isOwner.value));
const roleTemplates = computed(() => {
if (!perms.isOwner.value) {
const { admin_operativo: _a, ...rest } = IAM_ROLE_TEMPLATES;
return rest;
}
return IAM_ROLE_TEMPLATES;
});
async function loadCatalog() { async function loadCatalog() {
const r = await api<{ permissions?: PermRow[] }>("/v1/iam/permissions"); const r = await api<{ permissions?: IamPermRow[] }>("/v1/iam/permissions");
catalog.value = r.permissions ?? []; catalog.value = (r.permissions ?? []).map((p) => ({
code: p.code,
label: p.label,
module: p.module,
verb: p.verb,
group: (p as { group?: string }).group,
}));
} }
async function loadRolePerms(roleId: number) { async function loadRolePerms(roleId: number) {
@ -103,8 +120,13 @@ watch(
<style scoped> <style scoped>
.iam-detail { font-size: 13px; } .iam-detail { font-size: 13px; }
.section-title { font-weight: 600; margin-bottom: 4px; } .section-title { font-weight: 600; margin-bottom: 4px; }
.role-heading { margin: 0 0 8px; }
.matrix-intro {
font-size: 11px;
line-height: 1.4;
color: var(--text-color-secondary);
margin: 0 0 10px;
}
.muted { color: var(--text-color-secondary); } .muted { color: var(--text-color-secondary); }
.owner-badge { color: var(--orange-500); font-weight: 600; } .owner-badge { color: var(--orange-500); font-weight: 600; }
.perm-grid { display: grid; grid-template-columns: 1fr; gap: 6px; max-height: 50vh; overflow: auto; }
.perm-item { display: flex; align-items: flex-start; gap: 8px; }
</style> </style>

View file

@ -0,0 +1,277 @@
<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>

View file

@ -1,9 +1,10 @@
<template> <template>
<label class="form-field"> <!-- div, not label: a wrapping <label> breaks multi-control fields (e.g. checkbox + MultiSelect). -->
<div class="form-field">
<span class="form-field-label">{{ label }}<span v-if="required" class="required-mark"> *</span></span> <span class="form-field-label">{{ label }}<span v-if="required" class="required-mark"> *</span></span>
<slot /> <slot />
<small v-if="error" class="p-error">{{ error }}</small> <small v-if="error" class="p-error">{{ error }}</small>
</label> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View file

@ -0,0 +1,294 @@
export type IamPermRow = {
code: string;
label: string;
module?: string;
verb?: string;
group?: string;
};
export type IamVerb = "view" | "create" | "update" | "delete";
export type IamMatrixCell = {
code: string;
label: string;
hint: string;
};
export type IamMatrixRow = {
key: string;
label: string;
hint?: string;
cells: Partial<Record<IamVerb, IamMatrixCell>>;
};
export type IamMatrixModule = {
key: string;
label: string;
description: string;
rows: IamMatrixRow[];
codes: string[];
verbs: IamVerb[];
};
const VERB_ORDER: IamVerb[] = ["view", "create", "update", "delete"];
export const IAM_VERB_LABELS: Record<IamVerb, string> = {
view: "Ver",
create: "Crear",
update: "Editar",
delete: "Eliminar",
};
export const IAM_VERB_HINTS: Record<IamVerb, string> = {
view: "Consultar y abrir pantallas",
create: "Registrar datos nuevos",
update: "Modificar lo existente",
delete: "Eliminar o dar de baja",
};
const MODULE_META: Record<string, { label: string; description: string }> = {
workers: {
label: "Personal",
description: "Padrón de trabajadores, expediente y tablero kanban de altas.",
},
kanban: {
label: "Kanban",
description: "Tablero visual de seguimiento de personal.",
},
projects: {
label: "Obras",
description: "Alta, consulta y edición de proyectos/obras del tenant.",
},
companies: {
label: "Empresas",
description: "Empresas contratistas y subcontratos asociados a obras.",
},
budget: {
label: "Presupuesto",
description: "Partidas, importes y estructura económica de cada obra.",
},
work_program: {
label: "Programa de obra",
description: "Cronograma, avance físico e importación del programa.",
},
cost_control: {
label: "Control de costos",
description: "Comparativo programado vs real y tableros de costo.",
},
expenses: {
label: "Gastos",
description: "Captura y seguimiento de egresos por obra.",
},
warehouse: {
label: "Almacén",
description: "Entradas, salidas, stock y transferencias de materiales.",
},
payroll: {
label: "Nómina",
description: "Periodos, pagos, préstamos y líneas de nómina.",
},
documents: {
label: "Documentos y gafetes",
description: "Expediente documental de trabajadores y credenciales.",
},
project_docs: {
label: "Documentos de obra",
description: "Expediente por categoría: planos, contratos, permisos, etc.",
},
users: {
label: "Usuarios IAM",
description: "Administración de cuentas, roles y accesos del tenant.",
},
settings: {
label: "Configuración",
description: "Parámetros generales del tenant (solo propietario).",
},
reports: {
label: "Reportes e inicio",
description: "Tablero inicial y reportes operativos.",
},
};
const PROJECT_DOC_GROUP_LABELS: Record<string, string> = {
tecnico: "Planos y técnicos",
contrato: "Contrato",
permisos: "Permisos",
ambiental: "Ambiental",
imss: "IMSS",
sst: "SST",
otro: "Otros",
};
const MODULE_ORDER = [
"reports",
"projects",
"work_program",
"budget",
"cost_control",
"expenses",
"workers",
"kanban",
"documents",
"project_docs",
"warehouse",
"payroll",
"companies",
"users",
"settings",
];
function verbHint(module: string, verb: IamVerb, label: string): string {
if (module === "project_docs" && verb === "create") return "Subir archivos en esta categoría";
if (module === "warehouse" && verb === "update") return "Salidas, transferencias y cierres";
if (module === "payroll" && verb === "update") return "Armar periodo y registrar pagos";
return label;
}
export function buildIamMatrixModules(catalog: IamPermRow[]): IamMatrixModule[] {
const byModule = new Map<string, IamPermRow[]>();
for (const p of catalog) {
if (!p.module || !p.verb) continue;
const list = byModule.get(p.module) ?? [];
list.push(p);
byModule.set(p.module, list);
}
const modules: IamMatrixModule[] = [];
for (const moduleKey of MODULE_ORDER) {
const perms = byModule.get(moduleKey);
if (!perms?.length) continue;
const meta = MODULE_META[moduleKey] ?? {
label: moduleKey.replace(/_/g, " "),
description: "Permisos del módulo.",
};
const verbs = VERB_ORDER.filter((v) => perms.some((p) => p.verb === v));
const rows: IamMatrixRow[] = [];
if (moduleKey === "project_docs") {
const groups = new Map<string, IamPermRow[]>();
for (const p of perms) {
const g = p.group ?? "otro";
const list = groups.get(g) ?? [];
list.push(p);
groups.set(g, list);
}
for (const [groupKey, groupPerms] of groups) {
const cells: Partial<Record<IamVerb, IamMatrixCell>> = {};
for (const p of groupPerms) {
const verb = p.verb as IamVerb;
cells[verb] = {
code: p.code,
label: p.label,
hint: verbHint(moduleKey, verb, p.label),
};
}
rows.push({
key: groupKey,
label: PROJECT_DOC_GROUP_LABELS[groupKey] ?? groupKey,
cells,
});
}
} else {
const cells: Partial<Record<IamVerb, IamMatrixCell>> = {};
for (const p of perms) {
const verb = p.verb as IamVerb;
cells[verb] = {
code: p.code,
label: p.label,
hint: verbHint(moduleKey, verb, p.label),
};
}
rows.push({ key: moduleKey, label: meta.label, hint: meta.description, cells });
}
modules.push({
key: moduleKey,
label: meta.label,
description: meta.description,
rows,
codes: perms.map((p) => p.code),
verbs,
});
}
return modules;
}
export function filterAssignableCatalog(catalog: IamPermRow[], isOwner: boolean): IamPermRow[] {
if (isOwner) return catalog;
return catalog.filter(
(p) => !p.code.startsWith("users.") && !p.code.startsWith("settings."),
);
}
export function toggleCodes(selected: string[], codes: string[], on: boolean): string[] {
const set = new Set(selected);
for (const code of codes) {
if (on) set.add(code);
else set.delete(code);
}
return [...set];
}
export function moduleSelectionState(selected: Set<string>, codes: string[]): "none" | "partial" | "all" {
const hit = codes.filter((c) => selected.has(c)).length;
if (hit === 0) return "none";
if (hit === codes.length) return "all";
return "partial";
}
export function viewOnlyCodes(codes: string[], catalog: IamPermRow[]): string[] {
const viewSet = new Set(catalog.filter((p) => p.verb === "view").map((p) => p.code));
return codes.filter((c) => viewSet.has(c));
}
/** Conjuntos predefinidos (mismas claves que api/iam_catalog.ts ROLE_TEMPLATES). */
export const IAM_ROLE_TEMPLATES: Record<string, { label: string; permissions: string[] }> = {
admin_operativo: {
label: "Administrador operativo",
permissions: [
"workers.view", "workers.create", "workers.update", "workers.delete", "kanban.view",
"projects.view", "projects.create", "projects.update",
"companies.view", "companies.create", "companies.update",
"budget.view", "budget.create", "budget.update", "budget.delete",
"work_program.view", "work_program.create", "work_program.update",
"cost_control.view",
"expenses.view", "expenses.create", "expenses.update", "expenses.delete",
"warehouse.view", "warehouse.create", "warehouse.update",
"payroll.view", "payroll.create", "payroll.update", "payroll.delete",
"documents.view", "documents.create",
"project_docs.tecnico.view", "project_docs.tecnico.create",
"project_docs.contrato.view", "project_docs.contrato.create",
"project_docs.permisos.view", "project_docs.permisos.create",
"project_docs.ambiental.view", "project_docs.ambiental.create",
"project_docs.imss.view", "project_docs.imss.create",
"project_docs.sst.view", "project_docs.sst.create",
"project_docs.otro.view", "project_docs.otro.create",
"reports.view",
],
},
supervisor_obra: {
label: "Supervisor de obra",
permissions: [
"projects.view", "work_program.view", "work_program.update",
"project_docs.tecnico.view", "reports.view",
],
},
contador: {
label: "Contador",
permissions: [
"expenses.view", "expenses.create", "expenses.update", "expenses.delete",
"cost_control.view", "reports.view", "payroll.view",
],
},
operativo: {
label: "Operativo",
permissions: [
"workers.view", "workers.create", "workers.update",
"documents.view", "documents.create", "reports.view", "kanban.view",
],
},
};

View file

@ -73,7 +73,7 @@
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2"> <div class="flex align-items-center gap-2">
<Checkbox v-model="userForm.scope_all_projects" input-id="scope-all" binary /> <Checkbox v-model="userForm.scope_all_projects" input-id="scope-all" binary />
<label for="scope-all">Todas las obras</label> <span class="checkbox-caption">Todas las obras</span>
</div> </div>
<MultiSelect <MultiSelect
v-if="!userForm.scope_all_projects" v-if="!userForm.scope_all_projects"
@ -90,11 +90,11 @@
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2"> <div class="flex align-items-center gap-2">
<Checkbox v-model="userForm.warehouse_central" input-id="wh-central" binary /> <Checkbox v-model="userForm.warehouse_central" input-id="wh-central" binary />
<label for="wh-central">Almacén central</label> <span class="checkbox-caption">Almacén central</span>
</div> </div>
<div class="flex align-items-center gap-2"> <div class="flex align-items-center gap-2">
<Checkbox v-model="userForm.warehouse_projects" input-id="wh-projects" binary /> <Checkbox v-model="userForm.warehouse_projects" input-id="wh-projects" binary />
<label for="wh-projects">Almacenes de obra</label> <span class="checkbox-caption">Almacenes de obra</span>
</div> </div>
</div> </div>
</FormField> </FormField>
@ -113,11 +113,8 @@
<FormField label="Plantilla"> <FormField label="Plantilla">
<Select v-model="roleForm.template" :options="templateOptions" option-label="label" option-value="value" class="w-full" /> <Select v-model="roleForm.template" :options="templateOptions" option-label="label" option-value="value" class="w-full" />
</FormField> </FormField>
<FormField label="Código">
<InputText v-model="roleForm.code" class="w-full" />
</FormField>
<FormField label="Etiqueta"> <FormField label="Etiqueta">
<InputText v-model="roleForm.label" class="w-full" /> <InputText v-model="roleForm.label" class="w-full" placeholder="Nombre visible del rol" />
</FormField> </FormField>
</div> </div>
<template #footer> <template #footer>
@ -184,10 +181,11 @@ const userForm = reactive({
const roleForm = reactive({ const roleForm = reactive({
template: "operativo", template: "operativo",
code: "",
label: "", label: "",
}); });
const RESERVED_ROLE_CODES = new Set(["tenant_admin", "platform_admin", "user", "operativo"]);
const templateOptions = [ const templateOptions = [
{ label: "Administrador operativo", value: "admin_operativo" }, { label: "Administrador operativo", value: "admin_operativo" },
{ label: "Supervisor de obra", value: "supervisor_obra" }, { label: "Supervisor de obra", value: "supervisor_obra" },
@ -384,20 +382,54 @@ async function resetAccess() {
} }
} }
function slugRoleCode(text: string): string {
let s = text
.normalize("NFD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
if (!s || !/^[a-z]/.test(s)) s = `rol_${s || "nuevo"}`;
return s.slice(0, 64);
}
function uniqueRoleCode(base: string): string {
const taken = new Set(roles.value.map((r) => r.code));
let code = base;
let n = 2;
while (taken.has(code) || RESERVED_ROLE_CODES.has(code)) {
const suffix = `_${n}`;
code = `${base.slice(0, Math.max(1, 64 - suffix.length))}${suffix}`;
n++;
}
return code;
}
function roleCodeFromForm(): string {
const label = roleForm.label.trim();
const base = slugRoleCode(label || roleForm.template);
return uniqueRoleCode(base);
}
function openNewRole() { function openNewRole() {
roleForm.template = "operativo"; roleForm.template = "operativo";
roleForm.code = ""; roleForm.label = templateOptions.find((o) => o.value === "operativo")?.label ?? "";
roleForm.label = "";
showRoleDialog.value = true; showRoleDialog.value = true;
} }
async function saveRole() { async function saveRole() {
saving.value = true; saving.value = true;
try { try {
const code = (roleForm.code || roleForm.template).trim().toLowerCase(); const label = roleForm.label.trim();
if (!label) {
toast.add({ severity: "warn", summary: "Indique la etiqueta del rol", life: 3000 });
return;
}
const code = roleCodeFromForm();
await api("/v1/iam/roles", { await api("/v1/iam/roles", {
method: "POST", method: "POST",
json: { code, label: roleForm.label || code, template: roleForm.template }, json: { code, label, template: roleForm.template },
}); });
toast.add({ severity: "success", summary: "Rol creado", life: 2500 }); toast.add({ severity: "success", summary: "Rol creado", life: 2500 });
showRoleDialog.value = false; showRoleDialog.value = false;
@ -510,6 +542,14 @@ function syncChrome() {
watch([selectedUser, selectedRole, treeKey], syncChrome); watch([selectedUser, selectedRole, treeKey], syncChrome);
watch(
() => roleForm.template,
(tpl) => {
const opt = templateOptions.find((o) => o.value === tpl);
if (opt) roleForm.label = opt.label;
},
);
onMounted(async () => { onMounted(async () => {
if (!treeKey.value) treeKey.value = "users-active"; if (!treeKey.value) treeKey.value = "users-active";
await load(); await load();
@ -518,4 +558,9 @@ onMounted(async () => {
<style scoped> <style scoped>
.form-grid { display: flex; flex-direction: column; gap: 12px; } .form-grid { display: flex; flex-direction: column; gap: 12px; }
.checkbox-caption {
font-size: 0.875rem;
color: var(--text-color);
user-select: none;
}
</style> </style>