mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 15:33:17 +00:00
- Add IAM v2 auth with role_id, is_owner, status checks, and API key without tenant_admin bypass - Enforce CRUD permissions and project/warehouse scope across API routes - Rewrite usuarios.vue with ribbon, tree, user/role management, and permission matrix - Add dynamic menu, route guards, usePermissions/useScope composables - Apply role templates on create; filter project docs by category; hide cost tab without permission - Add iam_test unit tests; update bootstrap and SaaS tenant admin to use role_id Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
521 lines
18 KiB
Vue
521 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 />
|
|
<label for="scope-all">Todas las obras</label>
|
|
</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 />
|
|
<label for="wh-central">Almacén central</label>
|
|
</div>
|
|
<div class="flex align-items-center gap-2">
|
|
<Checkbox v-model="userForm.warehouse_projects" input-id="wh-projects" binary />
|
|
<label for="wh-projects">Almacenes de obra</label>
|
|
</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; }
|
|
</style>
|