panels-origin/web-panel/pages/usuarios.vue
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

526 lines
18 KiB
Vue

<template>
<div class="usuarios-page">
<DeskTableBar v-model="tableQ" :placeholder="treeKey.startsWith('roles') ? 'Buscar rol' : 'Buscar usuario'">
<Chip
:label="treeKey.startsWith('roles') ? `${roles.length} roles` : `${listedUsers.length} usuarios`"
icon="pi pi-users"
/>
</DeskTableBar>
<DataTable
v-if="!treeKey.startsWith('roles')"
v-model:selection="selectedUser"
:value="listedUsers"
selection-mode="single"
data-key="id"
paginator
:rows="25"
scrollable
scroll-height="flex"
@row-select="onUserSelect"
>
<Column selection-mode="single" style="width: 3rem" />
<Column field="username" header="Usuario" style="width: 10rem" />
<Column field="display_name" header="Nombre" />
<Column field="role_label" header="Rol" style="width: 11rem" />
<Column field="status" header="Estado" style="width: 7rem" />
<Column field="email" header="Correo" style="width: 14rem" />
</DataTable>
<DataTable
v-else
v-model:selection="selectedRole"
:value="listedRoles"
selection-mode="single"
data-key="id"
paginator
:rows="25"
scrollable
scroll-height="flex"
@row-select="onRoleSelect"
>
<Column selection-mode="single" style="width: 3rem" />
<Column field="code" header="Código" style="width: 10rem" />
<Column field="label" header="Etiqueta" />
<Column field="is_system" header="Sistema" style="width: 7rem">
<template #body="{ data }">
<Tag :value="data.is_system ? 'Sí' : 'No'" :severity="data.is_system ? 'info' : 'secondary'" />
</template>
</Column>
</DataTable>
<Dialog v-model:visible="showUserDialog" :header="userDialogTitle" modal class="iam-dialog" style="width: 34rem">
<div class="form-grid">
<FormField v-if="!editingUser" label="Usuario">
<InputText v-model="userForm.username" class="w-full" />
</FormField>
<FormField label="Nombre">
<InputText v-model="userForm.display_name" class="w-full" />
</FormField>
<FormField label="Correo">
<InputText v-model="userForm.email" class="w-full" />
</FormField>
<FormField label="Rol">
<Select
v-model="userForm.role_id"
:options="assignableRoles"
option-label="label"
option-value="id"
class="w-full"
/>
</FormField>
<FormField v-if="perms.isOwner.value" label="Alcance obras">
<div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2">
<Checkbox v-model="userForm.scope_all_projects" input-id="scope-all" binary />
<span class="checkbox-caption">Todas las obras</span>
</div>
<MultiSelect
v-if="!userForm.scope_all_projects"
v-model="userForm.project_ids"
:options="projects"
option-label="name"
option-value="id"
placeholder="Obras asignadas"
class="w-full"
/>
</div>
</FormField>
<FormField label="Almacén">
<div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2">
<Checkbox v-model="userForm.warehouse_central" input-id="wh-central" binary />
<span class="checkbox-caption">Almacén central</span>
</div>
<div class="flex align-items-center gap-2">
<Checkbox v-model="userForm.warehouse_projects" input-id="wh-projects" binary />
<span class="checkbox-caption">Almacenes de obra</span>
</div>
</div>
</FormField>
</div>
<Message v-if="tempPassword" severity="success" :closable="false" class="mt-3">
Contraseña temporal (cópiela ahora): <strong>{{ tempPassword }}</strong>
</Message>
<template #footer>
<Button label="Cancelar" text @click="showUserDialog = false" />
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="saveUser" />
</template>
</Dialog>
<Dialog v-model:visible="showRoleDialog" header="Nuevo rol" modal style="width: 28rem">
<div class="form-grid">
<FormField label="Plantilla">
<Select v-model="roleForm.template" :options="templateOptions" option-label="label" option-value="value" class="w-full" />
</FormField>
<FormField label="Código">
<InputText v-model="roleForm.code" class="w-full" />
</FormField>
<FormField label="Etiqueta">
<InputText v-model="roleForm.label" class="w-full" />
</FormField>
</div>
<template #footer>
<Button label="Cancelar" text @click="showRoleDialog = false" />
<Button label="Crear" icon="pi pi-check" :loading="saving" @click="saveRole" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import { displayErrorMessage } from "~/composables/api-response";
type UserRow = {
id: number;
username: string;
display_name: string;
role_id: number;
role_code: string;
role_label?: string;
email: string;
status: string;
is_owner?: boolean;
scope_all_projects?: boolean;
warehouse_central?: boolean;
warehouse_projects?: boolean;
project_ids?: number[];
};
type RoleRow = { id: number; code: string; label: string; is_system?: boolean };
type PermRow = { code: string; label: string; module: string; verb: string; group?: string };
type ProjectRow = { id: number; name: string; code?: string };
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const perms = usePermissions();
const tableQ = ref("");
const treeKey = ref("users-active");
const users = ref<UserRow[]>([]);
const roles = ref<RoleRow[]>([]);
const permissions = ref<PermRow[]>([]);
const rolePerms = ref<string[]>([]);
const projects = ref<ProjectRow[]>([]);
const selectedUser = ref<UserRow | null>(null);
const selectedRole = ref<RoleRow | null>(null);
const showUserDialog = ref(false);
const showRoleDialog = ref(false);
const editingUser = ref(false);
const saving = ref(false);
const tempPassword = ref("");
const userForm = reactive({
username: "",
display_name: "",
email: "",
role_id: null as number | null,
scope_all_projects: false,
warehouse_central: false,
warehouse_projects: true,
project_ids: [] as number[],
});
const roleForm = reactive({
template: "operativo",
code: "",
label: "",
});
const templateOptions = [
{ label: "Administrador operativo", value: "admin_operativo" },
{ label: "Supervisor de obra", value: "supervisor_obra" },
{ label: "Contador", value: "contador" },
{ label: "Operativo", value: "operativo" },
];
const assignableRoles = computed(() => roles.value.filter((r) => !r.is_system || r.code !== "tenant_admin"));
const listedUsers = computed(() => {
const q = tableQ.value.toLowerCase();
let rows = users.value;
if (treeKey.value === "users-active") rows = rows.filter((u) => u.status === "activo" && !u.is_owner);
else if (treeKey.value === "users-baja") rows = rows.filter((u) => u.status === "baja");
else if (treeKey.value === "users-owner") rows = rows.filter((u) => u.is_owner);
if (!q) return rows;
return rows.filter((u) => [u.username, u.display_name, u.email, u.role_code].join(" ").toLowerCase().includes(q));
});
const listedRoles = computed(() => {
const q = tableQ.value.toLowerCase();
const rows = roles.value.filter((r) => !r.is_system);
if (!q) return rows;
return rows.filter((r) => [r.code, r.label].join(" ").toLowerCase().includes(q));
});
const userDialogTitle = computed(() => (editingUser.value ? "Editar usuario" : "Nuevo usuario"));
const canCreateUser = computed(() => perms.can("users.create"));
const canUpdateUser = computed(() => perms.can("users.update"));
const canDeleteUser = computed(() => perms.can("users.delete"));
function onUserSelect(e: { data: UserRow }) {
selectedUser.value = e.data;
desk.openIamUser(e.data);
if (!treeKey.value.startsWith("roles")) loadRoleMatrixForUser(e.data);
}
function onRoleSelect(e: { data: RoleRow }) {
selectedRole.value = e.data;
desk.openIamRole(e.data);
loadRolePermissions(e.data.id);
}
async function loadRolePermissions(roleId: number) {
const rp = await api<{ permissions?: string[] }>(`/v1/iam/roles/${roleId}/permissions`);
rolePerms.value = rp.permissions ?? [];
}
async function loadRoleMatrixForUser(user: UserRow) {
if (user.role_id) await loadRolePermissions(user.role_id);
}
async function load() {
const [u, p, r, proj] = await Promise.all([
api<{ users?: UserRow[] }>("/v1/iam/users"),
api<{ permissions?: PermRow[] }>("/v1/iam/permissions"),
api<{ roles?: RoleRow[] }>("/v1/iam/roles"),
api<{ projects?: ProjectRow[] }>("/v1/projects").catch(() => ({ projects: [] as ProjectRow[] })),
]);
users.value = (u.users ?? []).map((row) => ({
...row,
role_label: r.roles?.find((role) => role.id === row.role_id)?.label ?? row.role_code,
}));
permissions.value = p.permissions ?? [];
roles.value = r.roles ?? [];
projects.value = proj.projects ?? [];
syncChrome();
}
function resetUserForm() {
userForm.username = "";
userForm.display_name = "";
userForm.email = "";
userForm.role_id = assignableRoles.value[0]?.id ?? null;
userForm.scope_all_projects = false;
userForm.warehouse_central = false;
userForm.warehouse_projects = true;
userForm.project_ids = [];
tempPassword.value = "";
}
function openNewUser() {
editingUser.value = false;
resetUserForm();
showUserDialog.value = true;
}
function openEditUser() {
if (!selectedUser.value || selectedUser.value.is_owner) return;
editingUser.value = true;
tempPassword.value = "";
userForm.display_name = selectedUser.value.display_name;
userForm.email = selectedUser.value.email ?? "";
userForm.role_id = selectedUser.value.role_id;
userForm.scope_all_projects = Boolean(selectedUser.value.scope_all_projects);
userForm.warehouse_central = Boolean(selectedUser.value.warehouse_central);
userForm.warehouse_projects = selectedUser.value.warehouse_projects !== false;
userForm.project_ids = [...(selectedUser.value.project_ids ?? [])];
showUserDialog.value = true;
}
async function saveUser() {
saving.value = true;
try {
if (editingUser.value && selectedUser.value) {
await api(`/v1/iam/users/${selectedUser.value.id}`, {
method: "PATCH",
json: {
display_name: userForm.display_name,
email: userForm.email,
role_id: userForm.role_id,
scope_all_projects: userForm.scope_all_projects,
warehouse_central: userForm.warehouse_central,
warehouse_projects: userForm.warehouse_projects,
project_ids: userForm.project_ids,
},
});
toast.add({ severity: "success", summary: "Usuario actualizado", life: 2500 });
} else {
const r = await api<{ temporary_password?: string }>("/v1/iam/users", {
method: "POST",
json: {
username: userForm.username,
display_name: userForm.display_name,
email: userForm.email,
role_id: userForm.role_id,
scope_all_projects: userForm.scope_all_projects,
warehouse_central: userForm.warehouse_central,
warehouse_projects: userForm.warehouse_projects,
project_ids: userForm.project_ids,
},
});
tempPassword.value = r.temporary_password ?? "";
toast.add({ severity: "success", summary: "Usuario creado", life: 2500 });
if (!tempPassword.value) showUserDialog.value = false;
}
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
async function deactivateUser() {
if (!selectedUser.value || selectedUser.value.is_owner) return;
saving.value = true;
try {
await api(`/v1/iam/users/${selectedUser.value.id}`, {
method: "PATCH",
json: { status: "baja" },
});
toast.add({ severity: "success", summary: "Usuario dado de baja", life: 2500 });
selectedUser.value = null;
desk.clearDetail();
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
async function reactivateUser() {
if (!selectedUser.value || selectedUser.value.is_owner) return;
saving.value = true;
try {
await api(`/v1/iam/users/${selectedUser.value.id}`, {
method: "PATCH",
json: { status: "activo" },
});
toast.add({ severity: "success", summary: "Usuario reactivado", life: 2500 });
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
async function resetAccess() {
if (!selectedUser.value || selectedUser.value.is_owner) return;
saving.value = true;
try {
const r = await api<{ temporary_password?: string }>(`/v1/iam/users/${selectedUser.value.id}/reset-access`, {
method: "POST",
});
tempPassword.value = r.temporary_password ?? "";
toast.add({ severity: "success", summary: "Acceso reseteado", detail: tempPassword.value || undefined, life: 6000 });
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
function openNewRole() {
roleForm.template = "operativo";
roleForm.code = "";
roleForm.label = "";
showRoleDialog.value = true;
}
async function saveRole() {
saving.value = true;
try {
const code = (roleForm.code || roleForm.template).trim().toLowerCase();
await api("/v1/iam/roles", {
method: "POST",
json: { code, label: roleForm.label || code, template: roleForm.template },
});
toast.add({ severity: "success", summary: "Rol creado", life: 2500 });
showRoleDialog.value = false;
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
async function saveRolePerms() {
const role = selectedRole.value ?? (selectedUser.value ? roles.value.find((r) => r.id === selectedUser.value!.role_id) : null);
if (!role || role.is_system) return;
saving.value = true;
try {
await api(`/v1/iam/roles/${role.id}/permissions`, {
method: "PUT",
json: { permissions: rolePerms.value },
});
toast.add({ severity: "success", summary: "Permisos guardados", life: 2500 });
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
async function deleteRole() {
if (!selectedRole.value || selectedRole.value.is_system) return;
saving.value = true;
try {
await api(`/v1/iam/roles/${selectedRole.value.id}`, { method: "DELETE" });
toast.add({ severity: "success", summary: "Rol eliminado", life: 2500 });
selectedRole.value = null;
desk.clearDetail();
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
function onTreeSelect(key: string) {
treeKey.value = key;
selectedUser.value = null;
selectedRole.value = null;
desk.clearDetail();
syncChrome();
}
function syncChrome() {
desk.configure({
tab: "Usuarios IAM",
treeTitle: "IAM",
showTree: true,
showDetail: true,
treeKey: treeKey.value,
onTreeSelect,
tree: [
{
key: "users",
label: "Usuarios",
icon: "pi pi-users",
selectable: false,
children: [
{ key: "users-active", label: "Activos", icon: "pi pi-user" },
{ key: "users-baja", label: "Bajas", icon: "pi pi-user-minus" },
{ key: "users-owner", label: "Propietario", icon: "pi pi-shield" },
],
},
{
key: "roles",
label: "Roles",
icon: "pi pi-key",
selectable: false,
children: [{ key: "roles-list", label: "Roles del tenant", icon: "pi pi-list" }],
},
],
ribbon: [
{
key: "usuarios",
label: "Usuarios",
items: [
{ key: "new-user", label: "Nuevo", icon: "pi pi-plus", disabled: !canCreateUser.value, command: openNewUser },
{ key: "edit-user", label: "Editar", icon: "pi pi-pencil", disabled: !canUpdateUser.value || !selectedUser.value || selectedUser.value.is_owner, command: openEditUser },
{ key: "baja-user", label: "Baja", icon: "pi pi-user-minus", disabled: !canDeleteUser.value || !selectedUser.value || selectedUser.value.is_owner || selectedUser.value.status === "baja", command: deactivateUser },
{ key: "reactivate-user", label: "Reactivar", icon: "pi pi-user-plus", disabled: !canUpdateUser.value || !selectedUser.value || selectedUser.value.is_owner || selectedUser.value.status !== "baja", command: reactivateUser },
{ key: "reset-user", label: "Reset acceso", icon: "pi pi-key", disabled: !canUpdateUser.value || !selectedUser.value || selectedUser.value.is_owner, command: resetAccess },
],
},
{
key: "roles",
label: "Roles",
items: [
{ key: "new-role", label: "Nuevo rol", icon: "pi pi-plus", disabled: !canCreateUser.value, command: openNewRole },
{ key: "delete-role", label: "Borrar rol", icon: "pi pi-trash", disabled: !canDeleteUser.value || !selectedRole.value || selectedRole.value.is_system, command: deleteRole },
{ key: "save-perms", label: "Guardar matriz", icon: "pi pi-save", disabled: !canUpdateUser.value, command: saveRolePerms },
],
},
{
key: "vista",
label: "Vista",
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: () => load() }],
},
],
});
}
watch([selectedUser, selectedRole, treeKey], syncChrome);
onMounted(async () => {
if (!treeKey.value) treeKey.value = "users-active";
await load();
});
</script>
<style scoped>
.form-grid { display: flex; flex-direction: column; gap: 12px; }
.checkbox-caption {
font-size: 0.875rem;
color: var(--text-color);
user-select: none;
}
</style>