Compare commits

..

No commits in common. "0d660c4549a08c865f0612d99a363ff5c4a99972" and "7bcef22d59e3ec838df44326a0492da46109e3fe" have entirely different histories.

31 changed files with 316 additions and 2865 deletions

View file

@ -7,6 +7,7 @@ import { createSession, getSession, revokeSession, revokeAllSessionsForUser } fr
import { hashPassword, verifyPassword } from "./crypto.ts";
export type AuthRealm = "app" | "platform";
export type AuthRole = "platform_admin" | "tenant_admin" | "user";
export type AuthUser = {
id: number;
@ -17,10 +18,7 @@ export type AuthUser = {
company_name: string | null;
company_kind: string | null;
tenant_id: number | null;
role_id: number | null;
role_code: string;
is_owner: boolean;
status: string;
role: AuthRole;
realm: AuthRealm;
must_change_password: boolean;
};
@ -28,14 +26,6 @@ export type AuthUser = {
const COOKIE = "po_session";
const TTL_SECONDS = 60 * 60 * 24 * 7;
const USER_SELECT = `
SELECT u.id, u.username, u.password_hash, u.display_name, u.company_id, u.tenant_id,
u.role_id, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id,
u.status, u.must_change_password, u.email
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = ?`;
export async function createSessionCookie(
c: Context,
userId: number,
@ -58,13 +48,8 @@ export async function clearSession(c: Context) {
deleteCookie(c, COOKIE, { path: "/" });
}
function isOwnerRole(row: Record<string, unknown>): boolean {
return row.role_code === "tenant_admin"
&& Boolean(row.role_is_system)
&& row.role_tenant_id == null;
}
function asAppUser(row: Record<string, unknown>): AuthUser {
const role = (row.role_code as string) || "user";
return {
id: Number(row.id),
username: String(row.username),
@ -74,10 +59,7 @@ function asAppUser(row: Record<string, unknown>): AuthUser {
company_name: (row.company_name as string | null) ?? null,
company_kind: (row.company_kind as string | null) ?? null,
tenant_id: row.tenant_id == null ? null : Number(row.tenant_id),
role_id: row.role_id == null ? null : Number(row.role_id),
role_code: String(row.role_code ?? "user"),
is_owner: isOwnerRole(row),
status: String(row.status ?? "activo"),
role: role === "tenant_admin" ? "tenant_admin" : "user",
realm: "app",
must_change_password: Boolean(row.must_change_password),
};
@ -93,10 +75,7 @@ function asPlatformUser(row: { id: number; username: string; display_name: strin
company_name: null,
company_kind: null,
tenant_id: null,
role_id: null,
role_code: "platform_admin",
is_owner: true,
status: "activo",
role: "platform_admin",
realm: "platform",
must_change_password: false,
};
@ -124,10 +103,12 @@ async function userFromCookie(c: Context): Promise<AuthUser | null> {
* empresa (core) -- usado tras login y tras cambiar contraseña. */
export async function getFreshAppUser(userId: number, tenantId: number | null): Promise<AuthUser | null> {
const row = await withIamTenant(tenantId, async (db) => {
return await db.prepare(USER_SELECT).get(userId);
return await db.prepare(
`SELECT id, username, password_hash, display_name, company_id, tenant_id, role_code,
must_change_password, email FROM users WHERE id = ?`,
).get(userId);
});
if (!row) return null;
if (String(row.status ?? "activo") !== "activo") return null;
const company = await enrichWithCompanyViaCore((row.company_id as number) ?? null);
return asAppUser({ ...row, ...company });
}
@ -143,6 +124,10 @@ function apiKeyOk(c: Context): boolean {
export async function requireAuth(c: Context, next: Next) {
if (apiKeyOk(c)) {
// La API key ya NO otorga visibilidad cruzada de todos los tenants
// (era un hallazgo crítico de la revisión de seguridad): ahora exige un
// tenant explícito por header, y las políticas de RLS son fail-closed
// si no se fija -- sin X-Tenant-Id válido, la API key no ve nada.
const tenantHeader = c.req.header("x-tenant-id") ?? "";
const tenantId = Number(tenantHeader);
if (!tenantHeader || !Number.isInteger(tenantId) || tenantId <= 0) {
@ -157,10 +142,7 @@ export async function requireAuth(c: Context, next: Next) {
company_name: null,
company_kind: null,
tenant_id: tenantId,
role_id: null,
role_code: "api_key",
is_owner: false,
status: "activo",
role: "tenant_admin",
realm: "app",
must_change_password: false,
} satisfies AuthUser);
@ -176,7 +158,7 @@ export async function requireAuth(c: Context, next: Next) {
export async function requirePlatformAdmin(c: Context, next: Next) {
const user = await userFromCookie(c);
if (!user) return c.json({ error: "No autenticado" }, 401);
if (user.realm !== "platform" || user.role_code !== "platform_admin") {
if (user.realm !== "platform" || user.role !== "platform_admin") {
return c.json({ error: "Solo administradores SaaS" }, 403);
}
c.set("user", user);
@ -193,14 +175,14 @@ export async function login(username: string, password: string): Promise<AuthUse
const pass = password.trim();
if (!user || !pass) return null;
// username es único globalmente (no por tenant) -- se resuelve con el
// pool que omite RLS (ver iam_db.ts#findUserByUsernameAnyTenant); recién
// después de esto se conoce el tenant_id para todo lo demás.
const appRow = await findUserByUsernameAnyTenant(user);
if (appRow && await verifyPassword(pass, appRow.password_hash as string)) {
if (String(appRow.status ?? "activo") !== "activo") {
throw Object.assign(new Error("Usuario dado de baja"), { code: "USER_INACTIVE" });
}
const authUser = await withIamTenant(
appRow.tenant_id == null ? null : Number(appRow.tenant_id),
async () => {
async (db) => {
const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null);
return asAppUser({ ...appRow, ...company });
},
@ -266,6 +248,9 @@ export async function changePassword(
await db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = false WHERE id = ?",
).run(hash, userId);
// Cambiar password revoca TODAS las demás sesiones activas de este
// usuario -- antes (cookie HMAC stateless) esto era imposible; ahora
// sí, porque el estado real vive en Redis (ver sessions.ts).
await revokeAllSessionsForUser(userId, "app");
return {};
});

View file

@ -5,8 +5,7 @@ import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
import { requirePermission } from "./permissions.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
import { requireAnyPermission } from "./permissions.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
@ -15,48 +14,38 @@ function tid(c: { get: (k: "user") => AuthUser }): number {
}
export function registerCostControlRoutes(app: App) {
const auth = [...requireCoreAuth, requirePermission("cost_control.view")] as const;
app.get("/v1/projects/:id/cost-control/summary", ...auth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
app.get("/v1/projects/:id/cost-control/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_project_summary", {
tenant_id: tid(c),
project_id: projectId,
project_id: Number(c.req.param("id")),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/projects/:id/cost-control/items", ...auth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
app.get("/v1/projects/:id/cost-control/items", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_by_item", {
tenant_id: tid(c),
project_id: projectId,
project_id: Number(c.req.param("id")),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/projects/:id/cost-control/chapters", ...auth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
app.get("/v1/projects/:id/cost-control/chapters", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_by_chapter", {
tenant_id: tid(c),
project_id: projectId,
project_id: Number(c.req.param("id")),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/projects/:id/cost-control/deviations", ...auth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
app.get("/v1/projects/:id/cost-control/deviations", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_top_deviations", {
tenant_id: tid(c),
project_id: projectId,
project_id: Number(c.req.param("id")),
limit: c.req.query("limit"),
}, { route: routeLabel(c) });
return respondRpc(c, env);

View file

@ -233,7 +233,7 @@ Deno.test("RPC control presupuestal: summary, items, chapters, deviations", asyn
});
});
Deno.test("RPC IAM v2: roles y permisos por role_id", async () => {
Deno.test("RPC IAM: usuarios y permisos", async () => {
const url = Deno.env.get("DATABASE_URL_IAM");
if (!url) throw new Error("DATABASE_URL_IAM requerido");
const pool = createPool(url, { max: 2 });
@ -254,21 +254,16 @@ Deno.test("RPC IAM v2: roles y permisos por role_id", async () => {
assertEquals(perms.result.ok, true);
assert(perms.result.data.permissions.length > 0);
const roleRow = await db.prepare(
`SELECT id FROM roles WHERE tenant_id = ? AND code = 'operativo' LIMIT 1`,
).get(TENANT_ID) as { id: number } | undefined;
if (roleRow) {
const roleGet = await db.prepare(`SELECT iam.fn_role_permissions_get($1::jsonb) AS result`).get({
role_id: roleRow.id,
role_code: "user",
}) as { result: { ok: boolean } };
assertEquals(roleGet.result.ok, true);
const roleSave = await db.prepare(`SELECT iam.fn_role_permissions_save($1::jsonb) AS result`).get({
role_id: roleRow.id,
permissions: ["workers.view", "warehouse.view", "documents.view", "reports.view"],
role_code: "user",
permissions: ["view_expenses", "view_warehouse", "manage_workers", "manage_documents", "view_reports"],
}) as { result: { ok: boolean } };
assertEquals(roleSave.result.ok, true);
}
throw ROLLBACK;
});

View file

@ -4,11 +4,10 @@ import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import { requirePermission } from "./permissions.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission, requirePermission } from "./permissions.ts";
import { encryptBytes, decryptBytes } from "./docs_crypto.ts";
import { expenseDocKey, getObject, putObject, s3Configured } from "./storage.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
import { randomBytes } from "node:crypto";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
@ -23,13 +22,11 @@ function actor(c: Context): { id: number; name: string } {
}
export function registerExpenseRoutes(app: App) {
app.get("/v1/expenses", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => {
const projectId = c.req.query("project_id");
if (projectId && !await denyUnlessProjectScope(c, Number(projectId))) return;
app.get("/v1/expenses", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_list", {
tenant_id: tid(c),
project_id: projectId,
project_id: c.req.query("project_id"),
company_id: c.req.query("company_id"),
date_from: c.req.query("date_from"),
date_to: c.req.query("date_to"),
@ -39,20 +36,14 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => {
app.get("/v1/expenses/:id", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
if (env.ok && env.data) {
const expense = (env.data as { expense?: { project_id?: number } }).expense ?? env.data;
const projectId = Number((expense as { project_id?: number }).project_id);
if (projectId && !await denyUnlessProjectScope(c, projectId)) return;
}
return respondRpc(c, env);
});
app.post("/v1/expenses", ...requireCoreAuth, requirePermission("expenses.create"), async (c) => {
app.post("/v1/expenses", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
if (!await denyUnlessProjectScope(c, Number(body.project_id))) return;
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_expense_create", {
@ -64,14 +55,9 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.update"), async (c) => {
app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const got = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
if (got.ok && got.data) {
const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data;
if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return;
}
const env = await callCoreFn(db, "core.fn_expense_update", {
...body,
id: Number(c.req.param("id")),
@ -80,29 +66,18 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.delete"), async (c) => {
app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
const db = c.get("db");
const got = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
if (got.ok && got.data) {
const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data;
if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return;
}
const env = await callCoreFn(db, "core.fn_expense_void", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("expenses.update"), async (c) => {
app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
const expenseId = Number(c.req.param("id"));
const db = c.get("db");
const got = await callCoreFn(db, "core.fn_expense_get", { id: expenseId }, { route: routeLabel(c) });
if (got.ok && got.data) {
const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data;
if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return;
}
const form = await c.req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
const env = await callCoreFn(db, "core.fn_expense_attachment_add", {
const env = await callCoreFn(c.get("db"), "core.fn_expense_attachment_add", {
expense_id: expenseId,
...JSON.parse(String(form.get("meta") ?? "{}")),
}, { route: routeLabel(c) });
@ -114,6 +89,7 @@ export function registerExpenseRoutes(app: App) {
if (s3Configured()) {
await putObject(expenseDocKey(expenseId, storageName), cipher);
}
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_expense_attachment_add", {
expense_id: expenseId,
@ -129,15 +105,10 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => {
app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const expenseId = Number(c.req.param("id"));
const attId = Number(c.req.param("attId"));
const db = c.get("db");
const got = await callCoreFn(db, "core.fn_expense_get", { id: expenseId }, { route: routeLabel(c) });
if (got.ok && got.data) {
const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data;
if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return;
}
const row = await db.prepare(
"SELECT storage_name, iv, mime, original_name FROM expense_attachments WHERE id = ? AND expense_id = ?",
).get(attId, expenseId) as { storage_name: string; iv: string; mime: string; original_name: string } | undefined;
@ -149,7 +120,7 @@ export function registerExpenseRoutes(app: App) {
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/iva/summary", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => {
app.get("/v1/iva/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_iva_period_summary", {
tenant_id: tid(c),
@ -160,13 +131,13 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.view"), async (c) => {
app.get("/v1/cost-settings", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_cost_settings"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_settings_get", { tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.update"), async (c) => {
app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("manage_cost_settings"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_settings_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) });

View file

@ -1,135 +0,0 @@
/** Catálogo de permisos CRUD IAM v2 ({modulo}.{verbo}). */
export type PermissionDef = {
code: string;
label: string;
module: string;
verb: "view" | "create" | "update" | "delete";
group?: string;
};
export const IAM_SENSITIVE_PREFIXES = ["users.", "settings."] as const;
export const RESERVED_ROLE_CODES = new Set([
"tenant_admin",
"platform_admin",
"user",
]);
export const PERMISSION_CATALOG: PermissionDef[] = [
{ code: "workers.view", label: "Consultar padrón", module: "workers", verb: "view" },
{ code: "workers.create", label: "Alta de personal", module: "workers", verb: "create" },
{ code: "workers.update", label: "Editar personal", module: "workers", verb: "update" },
{ code: "workers.delete", label: "Baja de personal", module: "workers", verb: "delete" },
{ code: "kanban.view", label: "Tablero kanban", module: "kanban", verb: "view" },
{ code: "projects.view", label: "Consultar obras", module: "projects", verb: "view" },
{ code: "projects.create", label: "Alta de obras", module: "projects", verb: "create" },
{ code: "projects.update", label: "Editar obras", module: "projects", verb: "update" },
{ code: "companies.view", label: "Consultar empresas", module: "companies", verb: "view" },
{ code: "companies.create", label: "Alta de empresas", module: "companies", verb: "create" },
{ code: "companies.update", label: "Editar empresas", module: "companies", verb: "update" },
{ code: "budget.view", label: "Ver presupuesto", module: "budget", verb: "view" },
{ code: "budget.create", label: "Alta partidas presupuesto", module: "budget", verb: "create" },
{ code: "budget.update", label: "Editar presupuesto", module: "budget", verb: "update" },
{ code: "budget.delete", label: "Eliminar partidas", module: "budget", verb: "delete" },
{ code: "work_program.view", label: "Ver programa de obra", module: "work_program", verb: "view" },
{ code: "work_program.create", label: "Importar/generar programa", module: "work_program", verb: "create" },
{ code: "work_program.update", label: "Capturar avances", module: "work_program", verb: "update" },
{ code: "cost_control.view", label: "Control de costos", module: "cost_control", verb: "view" },
{ code: "expenses.view", label: "Ver gastos", module: "expenses", verb: "view" },
{ code: "expenses.create", label: "Capturar gastos", module: "expenses", verb: "create" },
{ code: "expenses.update", label: "Editar gastos", module: "expenses", verb: "update" },
{ code: "expenses.delete", label: "Eliminar gastos", module: "expenses", verb: "delete" },
{ code: "warehouse.view", label: "Ver almacén", module: "warehouse", verb: "view" },
{ code: "warehouse.create", label: "Entradas almacén", module: "warehouse", verb: "create" },
{ code: "warehouse.update", label: "Salidas/transferencias", module: "warehouse", verb: "update" },
{ code: "payroll.view", label: "Ver nómina", module: "payroll", verb: "view" },
{ code: "payroll.create", label: "Alta nómina/préstamos", module: "payroll", verb: "create" },
{ code: "payroll.update", label: "Armar/pagar nómina", module: "payroll", verb: "update" },
{ code: "payroll.delete", label: "Eliminar líneas nómina", module: "payroll", verb: "delete" },
{ code: "documents.view", label: "Ver documentos/gafetes", module: "documents", verb: "view" },
{ code: "documents.create", label: "Subir documentos/gafetes", module: "documents", verb: "create" },
{ code: "project_docs.tecnico.view", label: "Planos y docs técnicos", module: "project_docs", verb: "view", group: "tecnico" },
{ code: "project_docs.tecnico.create", label: "Subir docs técnicos", module: "project_docs", verb: "create", group: "tecnico" },
{ code: "project_docs.contrato.view", label: "Docs contrato", module: "project_docs", verb: "view", group: "contrato" },
{ code: "project_docs.contrato.create", label: "Subir docs contrato", module: "project_docs", verb: "create", group: "contrato" },
{ code: "project_docs.permisos.view", label: "Docs permisos", module: "project_docs", verb: "view", group: "permisos" },
{ code: "project_docs.permisos.create", label: "Subir docs permisos", module: "project_docs", verb: "create", group: "permisos" },
{ code: "project_docs.ambiental.view", label: "Docs ambientales", module: "project_docs", verb: "view", group: "ambiental" },
{ code: "project_docs.ambiental.create", label: "Subir docs ambientales", module: "project_docs", verb: "create", group: "ambiental" },
{ code: "project_docs.imss.view", label: "Docs IMSS", module: "project_docs", verb: "view", group: "imss" },
{ code: "project_docs.imss.create", label: "Subir docs IMSS", module: "project_docs", verb: "create", group: "imss" },
{ code: "project_docs.sst.view", label: "Docs SST", module: "project_docs", verb: "view", group: "sst" },
{ code: "project_docs.sst.create", label: "Subir docs SST", module: "project_docs", verb: "create", group: "sst" },
{ code: "project_docs.otro.view", label: "Otros docs obra", module: "project_docs", verb: "view", group: "otro" },
{ code: "project_docs.otro.create", label: "Subir otros docs", module: "project_docs", verb: "create", group: "otro" },
{ code: "users.view", label: "Listar usuarios IAM", module: "users", verb: "view" },
{ code: "users.create", label: "Alta usuarios IAM", module: "users", verb: "create" },
{ code: "users.update", label: "Editar usuarios IAM", module: "users", verb: "update" },
{ code: "users.delete", label: "Baja usuarios IAM", module: "users", verb: "delete" },
{ code: "settings.view", label: "Ver configuración", module: "settings", verb: "view" },
{ code: "settings.update", label: "Guardar configuración", module: "settings", verb: "update" },
{ code: "reports.view", label: "Reportes e inicio", module: "reports", verb: "view" },
];
export const ALL_PERMISSION_CODES = PERMISSION_CATALOG.map((p) => p.code);
/** Mapeo legacy → v2 para backfill y transición. */
export const LEGACY_TO_V2: Record<string, string[]> = {
manage_workers: ["workers.view", "workers.create", "workers.update", "workers.delete", "kanban.view"],
manage_projects: ["projects.view", "projects.create", "projects.update"],
manage_companies: ["companies.view", "companies.create", "companies.update"],
manage_budget: ["budget.view", "budget.create", "budget.update", "budget.delete", "work_program.view", "work_program.create", "work_program.update"],
manage_expenses: ["expenses.view", "expenses.create", "expenses.update", "expenses.delete", "cost_control.view"],
view_expenses: ["expenses.view", "cost_control.view"],
manage_payroll: ["payroll.view", "payroll.create", "payroll.update", "payroll.delete"],
manage_documents: ["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"],
manage_users: ["users.view", "users.create", "users.update", "users.delete"],
manage_settings: ["settings.view", "settings.update"],
view_reports: ["reports.view"],
view_warehouse: ["warehouse.view"],
manage_warehouse: ["warehouse.view", "warehouse.create", "warehouse.update"],
close_warehouse: ["warehouse.update"],
manage_cost_settings: ["settings.update", "cost_control.view"],
};
export const PROJECT_DOC_CATEGORIES = [
"tecnico", "contrato", "permisos", "ambiental", "imss", "sst", "otro",
] as const;
export type ProjectDocCategory = typeof PROJECT_DOC_CATEGORIES[number];
export function projectDocPerm(category: string, verb: "view" | "create"): string {
return `project_docs.${category}.${verb}`;
}
export const ROLE_TEMPLATES: Record<string, { label: string; permissions: string[] }> = {
admin_operativo: {
label: "Administrador operativo",
permissions: ALL_PERMISSION_CODES.filter(
(c) => !IAM_SENSITIVE_PREFIXES.some((p) => c.startsWith(p)),
),
},
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

@ -41,12 +41,9 @@ export async function pingIamDb(): Promise<void> {
* qué hacer con tenant_id/role_code. */
export async function findUserByUsernameAnyTenant(username: string) {
const rows = await getAuthPool()`
SELECT u.id, u.username, u.password_hash, u.display_name, u.company_id, u.tenant_id,
u.role_id, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id,
u.status, u.must_change_password, u.email, u.created_at
FROM iam.users u
JOIN iam.roles r ON r.id = u.role_id
WHERE u.username = ${username}`;
SELECT id, username, password_hash, display_name, company_id, tenant_id,
role_code, must_change_password, email, created_at
FROM iam.users WHERE username = ${username}`;
return rows[0] as Record<string, unknown> | undefined;
}

View file

@ -2,302 +2,63 @@ import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { requireAuth, tenantScope } from "./auth.ts";
import type { Db } from "./db.ts";
import { generateSecurePassword, hashPassword } from "./crypto.ts";
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import {
callIamFn,
filterSensitivePermissions,
invalidatePermissionCache,
requirePermission,
revokeSessionsForRoleUsers,
} from "./permissions.ts";
import { RESERVED_ROLE_CODES, ROLE_TEMPLATES } from "./iam_catalog.ts";
import { invalidateUserScopeCache } from "./user_scope.ts";
import { revokeAllSessionsForUser } from "./sessions.ts";
import { getUserScope, isOwnerAccountRow } from "./user_scope.ts";
import { respondRpc } from "./http_errors.ts";
import { requirePermission, callIamFn } from "./permissions.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
function tid(c: { get: (k: "user") => AuthUser }): number {
return tenantScope(c.get("user")) ?? 0;
}
async function assertNotOwnerTarget(
c: Parameters<typeof respondRpc>[0],
tenantId: number,
userId: number,
): Promise<boolean> {
const list = await callIamFn<{ ok: boolean; data?: { users?: Array<{ id: number; is_owner?: boolean }> } }>(
tenantId,
"iam.fn_user_list",
{ tenant_id: tenantId },
);
const users = list?.data?.users ?? [];
const target = users.find((u) => u.id === userId);
if (target && isOwnerAccountRow(target)) {
respondApiError(c, "FORBIDDEN", "No se puede modificar la cuenta propietaria desde IAM", {
route: routeLabel(c),
user_id: userId,
});
return false;
}
return true;
}
export function registerIamRoutes(app: App) {
app.get("/v1/iam/users", requireAuth, requirePermission("users.view"), async (c) => {
const t = tid(c);
const row = await callIamFn(t, "iam.fn_user_list", { tenant_id: t });
app.get("/v1/iam/users", requireAuth, requirePermission("manage_users"), async (c) => {
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_user_list", { tenant_id: tid });
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar usuarios IAM", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.get("/v1/iam/permissions", requireAuth, requirePermission("users.view"), async (c) => {
const row = await callIamFn(tid(c), "iam.fn_permission_list", {});
app.get("/v1/iam/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_permission_list", {});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos IAM", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.get("/v1/iam/roles", requireAuth, requirePermission("users.view"), async (c) => {
const t = tid(c);
const row = await callIamFn(t, "iam.fn_role_list", { tenant_id: t });
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar roles", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.post("/v1/iam/roles", requireAuth, requirePermission("users.create"), async (c) => {
const user = c.get("user");
const body = await c.req.json<{ code?: string; label?: string; template?: string }>();
const code = (body.code ?? body.template ?? "").trim().toLowerCase();
if (RESERVED_ROLE_CODES.has(code)) {
return respondApiError(c, "VALIDATION", `Código de rol reservado: ${code}`, { route: routeLabel(c) });
}
const t = tid(c);
const row = await callIamFn(t, "iam.fn_role_create", {
tenant_id: t,
code,
label: body.label ?? code,
});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo crear rol", data: null, errors: null });
}
const env = row as { ok: boolean; data?: { role?: { id: number } } };
if (env.ok && body.template && ROLE_TEMPLATES[body.template]) {
const roleId = env.data?.role?.id ?? (env.data as { id?: number })?.id;
if (roleId) {
const perms = filterSensitivePermissions(ROLE_TEMPLATES[body.template].permissions, user.is_owner);
await callIamFn(t, "iam.fn_role_permissions_save", { role_id: roleId, permissions: perms });
}
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.patch("/v1/iam/roles/:id", requireAuth, requirePermission("users.update"), async (c) => {
const t = tid(c);
const body = await c.req.json<{ label?: string }>();
const row = await callIamFn(t, "iam.fn_role_update", {
tenant_id: t,
role_id: Number(c.req.param("id")),
label: body.label,
});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo actualizar rol", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.delete("/v1/iam/roles/:id", requireAuth, requirePermission("users.delete"), async (c) => {
const t = tid(c);
const roleId = Number(c.req.param("id"));
const row = await callIamFn(t, "iam.fn_role_delete", { tenant_id: t, role_id: roleId });
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo eliminar rol", data: null, errors: null });
}
const env = row as { ok: boolean; code?: string };
if (!env.ok && env.code === "CONFLICT") {
return respondApiError(c, "CONFLICT", "El rol tiene usuarios asignados", { route: routeLabel(c), role_id: roleId });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.get("/v1/iam/roles/:id/permissions", requireAuth, requirePermission("users.view"), async (c) => {
const t = tid(c);
const row = await callIamFn(t, "iam.fn_role_permissions_get", { role_id: Number(c.req.param("id")) });
app.get("/v1/iam/roles/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_role_permissions_get", { role_code: c.req.param("role") });
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos del rol", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.put("/v1/iam/roles/:id/permissions", requireAuth, requirePermission("users.update"), async (c) => {
const user = c.get("user");
app.put("/v1/iam/roles/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
const body = await c.req.json<{ permissions?: string[] }>();
const roleId = Number(c.req.param("id"));
const perms = filterSensitivePermissions(body.permissions ?? [], user.is_owner);
const t = tid(c);
const row = await callIamFn(t, "iam.fn_role_permissions_save", {
role_id: roleId,
permissions: perms,
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_role_permissions_save", {
role_code: c.req.param("role"),
permissions: body.permissions ?? [],
});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo guardar permisos del rol", data: null, errors: null });
}
invalidatePermissionCache(roleId);
await revokeSessionsForRoleUsers(t, roleId);
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.post("/v1/iam/users", requireAuth, requirePermission("users.create"), async (c) => {
const user = c.get("user");
const body = await c.req.json<{
username?: string;
display_name?: string;
email?: string;
role_id?: number;
company_id?: number | null;
scope_all_projects?: boolean;
warehouse_central?: boolean;
warehouse_projects?: boolean;
project_ids?: number[];
}>();
const tempPassword = generateSecurePassword();
const hash = await hashPassword(tempPassword);
const t = tid(c);
const row = await callIamFn(t, "iam.fn_user_create", {
tenant_id: t,
username: body.username,
display_name: body.display_name,
email: body.email ?? "",
role_id: body.role_id,
company_id: body.company_id,
password_hash: hash,
must_change_password: true,
scope_all_projects: user.is_owner ? Boolean(body.scope_all_projects) : false,
warehouse_central: Boolean(body.warehouse_central),
warehouse_projects: body.warehouse_projects !== false,
});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo crear usuario", data: null, errors: null });
}
const env = row as { ok: boolean; data?: { user?: { id: number } } };
if (env.ok && env.data?.user?.id && body.project_ids?.length) {
await callIamFn(t, "iam.fn_user_projects_save", {
user_id: env.data.user.id,
project_ids: body.project_ids,
});
}
if (env.ok) {
(env as { data: Record<string, unknown> }).data = {
...(env.data ?? {}),
temporary_password: tempPassword,
};
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.patch("/v1/iam/users/:id", requireAuth, requirePermission("users.update"), async (c) => {
const user = c.get("user");
const userId = Number(c.req.param("id"));
const t = tid(c);
if (!await assertNotOwnerTarget(c, t, userId)) return;
const body = await c.req.json<{
display_name?: string;
email?: string;
role_id?: number;
status?: string;
scope_all_projects?: boolean;
warehouse_central?: boolean;
warehouse_projects?: boolean;
project_ids?: number[];
}>();
const row = await callIamFn(t, "iam.fn_user_update", {
tenant_id: t,
id: userId,
display_name: body.display_name,
email: body.email,
role_id: body.role_id,
status: body.status,
scope_all_projects: user.is_owner ? body.scope_all_projects : false,
warehouse_central: body.warehouse_central,
warehouse_projects: body.warehouse_projects,
});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo actualizar usuario", data: null, errors: null });
}
const env = row as { ok: boolean };
if (env.ok && body.project_ids) {
await callIamFn(t, "iam.fn_user_projects_save", { user_id: userId, project_ids: body.project_ids });
}
if (env.ok) {
invalidateUserScopeCache(userId);
if (body.role_id != null || body.status === "baja") {
await revokeAllSessionsForUser(userId, "app");
}
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.post("/v1/iam/users/:id/reset-access", requireAuth, requirePermission("users.update"), async (c) => {
const userId = Number(c.req.param("id"));
const t = tid(c);
if (!await assertNotOwnerTarget(c, t, userId)) return;
const tempPassword = generateSecurePassword();
const hash = await hashPassword(tempPassword);
const row = await callIamFn(t, "iam.fn_user_reset_access", {
tenant_id: t,
user_id: userId,
password_hash: hash,
});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo resetear acceso", data: null, errors: null });
}
const env = row as { ok: boolean; data?: Record<string, unknown> };
if (env.ok) {
await revokeAllSessionsForUser(userId, "app");
env.data = { ...(env.data ?? {}), temporary_password: tempPassword };
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.get("/v1/me/permissions", requireAuth, async (c) => {
const user = c.get("user");
const { userPermissionsByRoleId } = await import("./permissions.ts");
const perms = await userPermissionsByRoleId(user.tenant_id, user.role_id, user.is_owner);
const { userPermissions } = await import("./permissions.ts");
const role = user.role === "tenant_admin" ? "tenant_admin" : "user";
const perms = await userPermissions(user.tenant_id, role);
return respondRpc(c, {
ok: true,
code: "OK",
layer: "api",
message: `Permisos del usuario ${user.username}`,
data: {
permissions: [...perms],
role_code: user.role_code,
role_id: user.role_id,
is_owner: user.is_owner,
},
errors: null,
});
});
app.get("/v1/me/scope", requireAuth, async (c) => {
const user = c.get("user");
const scope = await getUserScope(user);
return respondRpc(c, {
ok: true,
code: "OK",
layer: "api",
message: "Alcance del usuario",
data: {
all_projects: scope.allProjects,
project_ids: scope.projectIds,
warehouse_central: scope.warehouseCentral,
warehouse_projects: scope.warehouseProjects,
},
data: { permissions: [...perms], role: user.role },
errors: null,
});
});

View file

@ -1,72 +0,0 @@
import { assert, assertEquals } from "jsr:@std/assert@1";
import {
ALL_PERMISSION_CODES,
LEGACY_TO_V2,
PROJECT_DOC_CATEGORIES,
projectDocPerm,
RESERVED_ROLE_CODES,
ROLE_TEMPLATES,
} from "./iam_catalog.ts";
import { filterSensitivePermissions } from "./permissions.ts";
import { assertProjectAccess, isOwnerAccountRow } from "./user_scope.ts";
Deno.test("IAM catálogo: permisos CRUD únicos", () => {
const codes = new Set(ALL_PERMISSION_CODES);
assertEquals(codes.size, ALL_PERMISSION_CODES.length);
assert(codes.has("workers.view"));
assert(codes.has("project_docs.tecnico.view"));
assert(codes.has("users.view"));
});
Deno.test("IAM: códigos de rol reservados", () => {
assert(RESERVED_ROLE_CODES.has("tenant_admin"));
assert(RESERVED_ROLE_CODES.has("user"));
assert(!RESERVED_ROLE_CODES.has("supervisor_obra"));
});
Deno.test("IAM: plantilla supervisor sin gastos ni IAM", () => {
const sup = ROLE_TEMPLATES.supervisor_obra.permissions;
assert(sup.includes("projects.view"));
assert(sup.includes("project_docs.tecnico.view"));
assert(!sup.some((p) => p.startsWith("expenses.")));
assert(!sup.some((p) => p.startsWith("users.")));
assert(!sup.some((p) => p.startsWith("settings.")));
});
Deno.test("IAM: plantilla contador con gastos y costos", () => {
const cont = ROLE_TEMPLATES.contador.permissions;
assert(cont.includes("expenses.view"));
assert(cont.includes("cost_control.view"));
assert(!cont.some((p) => p.startsWith("project_docs.")));
});
Deno.test("IAM: permisos sensibles filtrados para no propietario", () => {
const input = ["workers.view", "users.create", "settings.update", "reports.view"];
const filtered = filterSensitivePermissions(input, false);
assertEquals(filtered, ["workers.view", "reports.view"]);
assertEquals(filterSensitivePermissions(input, true), input);
});
Deno.test("IAM: alcance por obra", () => {
const scope = { allProjects: false, projectIds: [10, 20], warehouseCentral: false, warehouseProjects: true };
assert(assertProjectAccess(scope, 10));
assert(!assertProjectAccess(scope, 99));
assert(assertProjectAccess({ ...scope, allProjects: true }, 99));
});
Deno.test("IAM: cuenta propietaria", () => {
assert(isOwnerAccountRow({ is_owner: true }));
assert(isOwnerAccountRow({ role_code: "tenant_admin" }));
assert(!isOwnerAccountRow({ role_code: "supervisor" }));
});
Deno.test("IAM: categorías documento obra", () => {
for (const cat of PROJECT_DOC_CATEGORIES) {
assertEquals(projectDocPerm(cat, "view"), `project_docs.${cat}.view`);
}
});
Deno.test("IAM: mapeo legacy a v2", () => {
assert(LEGACY_TO_V2.manage_workers.includes("workers.view"));
assert(LEGACY_TO_V2.manage_expenses.includes("cost_control.view"));
});

View file

@ -36,14 +36,6 @@ import {
type AuthUser,
} from "./auth.ts";
import { requireCoreAuth, withCoreScope } from "./scope.ts";
import { requirePermission, requireAnyPermission } from "./permissions.ts";
import {
denyUnlessProjectScope,
denyUnlessProjectDocCreate,
denyUnlessProjectDocView,
filterProjectDocumentsForUser,
filterProjectListEnvelope,
} from "./scope_enforcement.ts";
import { getPlatformDb, pingPlatformDb } from "./platform_db.ts";
import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts";
import { smtpConfigured, testSmtp } from "./mail.ts";
@ -90,8 +82,6 @@ type StorageDoc = {
iv: string;
original_name: string;
mime?: string;
type_code?: string;
type?: string;
};
async function rpcDocumentForDownload(
@ -285,7 +275,7 @@ app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) =>
});
});
app.get("/v1/catalogs", ...requireCoreAuth, requirePermission("reports.view"), async (c) => {
app.get("/v1/catalogs", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const user = c.get("user");
const tid = tenantScope(user);
@ -304,15 +294,20 @@ app.get("/v1/catalogs", ...requireCoreAuth, requirePermission("reports.view"), a
// Configuración del tenant (Fase 4d): zona horaria usada por nómina y
// vigencia de documentos -- reemplaza el PAYROLL_TZ hardcodeado.
app.get("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.view"), async (c) => {
app.get("/v1/configuracion", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const tid = tenantScope(c.get("user"));
const timezone = await tenantTimezone(db, tid);
return c.json({ timezone });
});
app.put("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.update"), async (c) => {
app.put("/v1/configuracion", ...requireCoreAuth, async (c) => {
const user = c.get("user");
if (user.role !== "tenant_admin") {
return respondApiError(c, "FORBIDDEN", "Solo administradores pueden cambiar la configuración del tenant", {
route: routeLabel(c),
});
}
const db = c.get("db");
const tid = tenantScope(user);
if (tid == null) {
@ -337,7 +332,7 @@ app.put("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.upd
));
});
app.get("/v1/companies", ...requireCoreAuth, requirePermission("companies.view"), async (c) => {
app.get("/v1/companies", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const tid = tenantScope(c.get("user"));
return respondRpc(c, await callCoreFn(
@ -348,7 +343,7 @@ app.get("/v1/companies", ...requireCoreAuth, requirePermission("companies.view")
));
});
app.post("/v1/companies", ...requireCoreAuth, requirePermission("companies.create"), async (c) => {
app.post("/v1/companies", ...requireCoreAuth, async (c) => {
const body = await c.req.json();
const db = c.get("db");
const tid = tenantScope(c.get("user"));
@ -366,7 +361,7 @@ app.post("/v1/companies", ...requireCoreAuth, requirePermission("companies.creat
});
});
app.patch("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.update"), async (c) => {
app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const db = c.get("db");
@ -385,7 +380,7 @@ app.patch("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.
});
});
app.get("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.view"), async (c) => {
app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const tid = tenantScope(c.get("user"));
@ -419,7 +414,7 @@ app.get("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.vi
});
});
app.post("/v1/companies/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
app.post("/v1/companies/:id/documents", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const form = await c.req.formData();
const type = String(form.get("type") || "");
@ -436,7 +431,7 @@ app.post("/v1/companies/:id/documents", ...requireCoreAuth, requirePermission("d
return c.json({ ok: true, checklist: await companyChecklistFor(db, id) });
});
app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => {
const companyId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId"));
const db = c.get("db");
@ -459,7 +454,7 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermiss
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/projects", ...requireCoreAuth, requirePermission("projects.view"), async (c) => {
app.get("/v1/projects", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const status = (c.req.query("status") ?? "").trim();
const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : [];
@ -469,13 +464,12 @@ app.get("/v1/projects", ...requireCoreAuth, requirePermission("projects.view"),
status,
});
}
const env = await callCoreFn(
return respondRpc(c, await callCoreFn(
db,
"core.fn_project_list",
{ status: allowed.length ? allowed.join(",") : undefined },
{ route: routeLabel(c) },
);
return respondRpc(c, await filterProjectListEnvelope(c, env as Parameters<typeof filterProjectListEnvelope>[1]));
));
});
type ProjectInput = {
@ -525,7 +519,7 @@ function projectFields(body: ProjectInput, cur?: Record<string, unknown>) {
};
}
app.post("/v1/projects", ...requireCoreAuth, requirePermission("projects.create"), async (c) => {
app.post("/v1/projects", ...requireCoreAuth, async (c) => {
const body = await c.req.json<ProjectInput>();
if (!body.name?.trim()) {
return respondApiError(c, "VALIDATION", `Nombre de proyecto obligatorio en ${routeLabel(c)}`, {
@ -573,9 +567,8 @@ app.post("/v1/projects", ...requireCoreAuth, requirePermission("projects.create"
));
});
app.patch("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.update"), async (c) => {
app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const body = await c.req.json<ProjectInput>();
const db = c.get("db");
const route = routeLabel(c);
@ -624,9 +617,8 @@ app.patch("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.up
));
});
app.get("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.view"), async (c) => {
app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const db = c.get("db");
const route = routeLabel(c);
const projectEnv = await callCoreFn<{ project: Record<string, unknown> }>(db, "core.fn_project_get", { id }, { route });
@ -642,12 +634,6 @@ app.get("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.view
{ route },
);
if (!docsEnv.ok) return respondRpc(c, docsEnv);
const user = c.get("user") as AuthUser;
const documents = await filterProjectDocumentsForUser(
user,
db,
(docsEnv.data?.documents ?? []) as Array<{ type_code?: string; type?: string }>,
);
return respondRpc(c, {
ok: true,
code: "OK",
@ -655,22 +641,20 @@ app.get("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.view
message: `Proyecto ${project.code} cargado`,
data: {
project,
documents,
documents: docsEnv.data?.documents ?? [],
checklist: await projectChecklistFor(db, id),
},
});
});
app.post("/v1/projects/:id/documents", ...requireCoreAuth, requireAnyPermission("project_docs.tecnico.create", "project_docs.contrato.create", "project_docs.permisos.create", "project_docs.ambiental.create", "project_docs.imss.create", "project_docs.sst.create", "project_docs.otro.create"), async (c) => {
app.post("/v1/projects/:id/documents", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const form = await c.req.formData();
const type = String(form.get("type") || "");
const file = form.get("file");
if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400);
const db = c.get("db");
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
if (!await denyUnlessProjectDocCreate(c, db, type)) return;
const bytes = new Uint8Array(await file.arrayBuffer());
try {
await storeProjectDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id);
@ -680,9 +664,8 @@ app.post("/v1/projects/:id/documents", ...requireCoreAuth, requireAnyPermission(
return c.json({ ok: true, checklist: await projectChecklistFor(db, id) });
});
app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermission("projects.view"), async (c) => {
app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const docId = Number(c.req.param("docId"));
const db = c.get("db");
const route = routeLabel(c);
@ -693,8 +676,6 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermissi
route,
);
if (!doc) return respondRpc(c, envelope);
const typeCode = String(doc.type_code ?? doc.type ?? "");
if (!await denyUnlessProjectDocView(c, db, typeCode)) return;
const enc = await getObject(projectDocKey(projectId, doc.storage_name));
const plain = await decryptBytes(doc.iv, enc);
c.header("Content-Type", "application/octet-stream");
@ -703,24 +684,22 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermissi
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/projects/:id/budget", ...requireCoreAuth, requirePermission("budget.view"), async (c) => {
app.get("/v1/projects/:id/budget", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const db = c.get("db");
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
return c.json(await listBudget(db, id));
});
app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, requirePermission("budget.view"), (c) => {
app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, (c) => {
const bytes = buildBudgetTemplate();
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"');
return c.body(bytes.slice());
});
app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, requirePermission("budget.view"), async (c) => {
app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const db = c.get("db");
const project = await projectById(db, id);
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);
@ -731,7 +710,7 @@ app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, requirePermission(
return c.body(bytes.slice());
});
app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
app.post("/v1/budget/preview", ...requireCoreAuth, async (c) => {
const form = await c.req.formData();
const file = form.get("file");
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
@ -741,9 +720,8 @@ app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.cre
return c.json(preview);
});
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const form = await c.req.formData();
const file = form.get("file");
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
@ -755,9 +733,8 @@ app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermissio
return c.json(preview);
});
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const form = await c.req.formData();
const file = form.get("file");
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
@ -769,9 +746,8 @@ app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission
return c.json(report);
});
app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>();
if (!body.name?.trim()) {
return respondApiError(c, "VALIDATION", `Nombre de capítulo obligatorio en ${routeLabel(c)}`, {
@ -793,9 +769,8 @@ app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, requirePermissi
));
});
app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const cid = Number(c.req.param("cid"));
const body = await c.req.json<{ name?: string; code?: string }>();
const db = c.get("db");
@ -812,9 +787,8 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, requirePe
));
});
app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, requirePermission("budget.delete"), async (c) => {
app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const cid = Number(c.req.param("cid"));
const db = c.get("db");
return respondRpc(c, await callCoreFn(
@ -834,9 +808,8 @@ type BudgetItemInput = {
unit_price?: number;
};
app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const body = await c.req.json<BudgetItemInput>();
if (!body.description?.trim()) {
return respondApiError(c, "VALIDATION", `Descripción de partida obligatoria en ${routeLabel(c)}`, {
@ -861,9 +834,8 @@ app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, requirePermission(
));
});
app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const iid = Number(c.req.param("iid"));
const body = await c.req.json<BudgetItemInput>();
const db = c.get("db");
@ -884,9 +856,8 @@ app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePermi
));
});
app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePermission("budget.delete"), async (c) => {
app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const iid = Number(c.req.param("iid"));
const db = c.get("db");
return respondRpc(c, await callCoreFn(
@ -897,7 +868,7 @@ app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePerm
));
});
app.post("/v1/workers/validate", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => {
const body = await c.req.json<WorkerInput & { id?: number }>();
const errors = validateWorkerFields(body);
const db = c.get("db");
@ -928,13 +899,12 @@ app.post("/v1/workers/validate", ...requireCoreAuth, requirePermission("workers.
return c.json({ ok: Object.keys(errors).length === 0, errors });
});
app.get("/v1/workers", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
app.get("/v1/workers", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const tid = tenantScope(c.get("user"));
const q = (c.req.query("q") ?? "").trim();
const status = c.req.query("status");
const projectId = c.req.query("project_id");
if (projectId && !await denyUnlessProjectScope(c, Number(projectId))) return;
return respondRpc(c, await callCoreFn(
db,
"core.fn_worker_list",
@ -948,7 +918,7 @@ app.get("/v1/workers", ...requireCoreAuth, requirePermission("workers.view"), as
));
});
app.get("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const tid = tenantScope(c.get("user"));
@ -996,7 +966,7 @@ async function findExistingWorker(
return env.data.worker ?? undefined;
}
app.post("/v1/workers", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
app.post("/v1/workers", ...requireCoreAuth, async (c) => {
const body = await c.req.json<WorkerInput & { project_id?: number }>();
const errors = validateWorkerFields(body);
const db = c.get("db");
@ -1020,7 +990,6 @@ app.post("/v1/workers", ...requireCoreAuth, requirePermission("workers.create"),
const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION";
return respondApiError(c, code, blocked.error, { route, project_id: body.project_id });
}
if (!await denyUnlessProjectScope(c, body.project_id)) return;
}
const tid = tenantScope(c.get("user"));
return respondRpc(c, await callCoreFn(
@ -1031,7 +1000,7 @@ app.post("/v1/workers", ...requireCoreAuth, requirePermission("workers.create"),
));
});
app.patch("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.update"), async (c) => {
app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const tid = tenantScope(c.get("user"));
@ -1062,7 +1031,7 @@ app.patch("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.upda
));
});
app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, requirePermission("workers.update"), async (c) => {
app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const { pipeline_status } = await c.req.json<{ pipeline_status: string }>();
const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"];
@ -1088,7 +1057,7 @@ app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, requirePermission("wor
});
});
app.post("/v1/workers/:id/assign", ...requireCoreAuth, requirePermission("workers.update"), async (c) => {
app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>();
const db = c.get("db");
@ -1124,7 +1093,7 @@ app.post("/v1/workers/:id/assign", ...requireCoreAuth, requirePermission("worker
));
});
app.get("/v1/workers/import/template", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const bytes = buildImportTemplate(await listCompanies(db));
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
@ -1132,7 +1101,7 @@ app.get("/v1/workers/import/template", ...requireCoreAuth, requirePermission("wo
return c.body(bytes.buffer as ArrayBuffer);
});
app.post("/v1/workers/import", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
app.post("/v1/workers/import", ...requireCoreAuth, async (c) => {
const form = await c.req.formData();
const file = form.get("file");
const projectId = Number(form.get("project_id") || 0) || null;
@ -1151,7 +1120,7 @@ app.post("/v1/workers/import", ...requireCoreAuth, requirePermission("workers.cr
return c.json(report);
});
app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const form = await c.req.formData();
const type = String(form.get("type") || "");
@ -1229,7 +1198,7 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc
});
});
app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => {
const workerId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId"));
const db = c.get("db");
@ -1249,7 +1218,7 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermissio
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/workers/:id/photo", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
app.get("/v1/workers/:id/photo", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const bytes = await loadCurrentPhoto(db, id);
@ -1265,7 +1234,7 @@ app.get("/v1/workers/:id/photo", ...requireCoreAuth, requirePermission("workers.
return c.body(bytes.buffer as ArrayBuffer);
});
app.get("/v1/badge-qr", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => {
const curp = (c.req.query("curp") ?? "").trim().toUpperCase();
if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400);
const png = await badgeQrPng(curp);
@ -1274,9 +1243,8 @@ app.get("/v1/badge-qr", ...requireCoreAuth, requirePermission("documents.view"),
return c.body(png.buffer as ArrayBuffer);
});
app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] }));
const db = c.get("db");
const user = c.get("user");
@ -1334,18 +1302,18 @@ app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, requirePermission("d
});
});
app.get("/v1/badge-jobs", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => {
const db = c.get("db");
return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_list", {}, { route: routeLabel(c) }));
});
app.get("/v1/badge-jobs/:id", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
app.get("/v1/badge-jobs/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_get", { job_id: id }, { route: routeLabel(c) }));
});
app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const route = routeLabel(c);
@ -1363,7 +1331,7 @@ app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, requirePermission("documen
return c.body(bytes.buffer as ArrayBuffer);
});
app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) => {
const jobId = Number(c.req.param("id"));
const workerId = Number(c.req.param("workerId"));
const { delivered } = await c.req.json<{ delivered: boolean }>();

View file

@ -2,8 +2,6 @@ import type { Context, Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import { requirePermission } from "./permissions.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
import { projectById, projectMustBe, type Db } from "./db.ts";
import { generateLoanReceiptPdf } from "./pdf.ts";
import { fullName } from "./mx.ts";
@ -46,7 +44,7 @@ function pdfBody(c: Context, bytes: Uint8Array, filename: string) {
}
export function registerPayrollRoutes(app: App) {
app.get("/v1/attendance", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/attendance", ...requireCoreAuth, async (c) => {
const projectId = Number(c.req.query("project_id"));
const from = c.req.query("from") ?? "";
const to = c.req.query("to") ?? "";
@ -60,7 +58,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.put("/v1/attendance", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
app.put("/v1/attendance", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{
project_id: number;
worker_id: number;
@ -88,13 +86,13 @@ export function registerPayrollRoutes(app: App) {
return c.json({ ok: true });
});
app.get("/v1/payroll/settings", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/payroll/settings", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const tenantId = tid(c);
return c.json({ settings: await getSettings(db, tenantId), units: await listUnits(db, tenantId) });
});
app.patch("/v1/payroll/settings", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
app.patch("/v1/payroll/settings", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const body = await c.req.json<{
loan_commission_enabled?: number | boolean;
@ -111,7 +109,7 @@ export function registerPayrollRoutes(app: App) {
return c.json({ settings });
});
app.post("/v1/destajo/units", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
app.post("/v1/destajo/units", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{ code: string; label?: string }>();
const db = c.get("db");
try {
@ -122,7 +120,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.get("/v1/loans", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/loans", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const workerId = c.req.query("worker_id");
const env = await callCoreFn<{ loans: unknown[] }>(
@ -134,7 +132,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.post("/v1/loans", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
app.post("/v1/loans", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{
worker_id: number;
amount?: number;
@ -164,7 +162,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.get("/v1/loans/:id/recibo", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const env = await callCoreFn<{ loan: {
@ -215,7 +213,7 @@ export function registerPayrollRoutes(app: App) {
return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`);
});
app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const env = await callCoreFn<{ payment: {
@ -250,7 +248,7 @@ export function registerPayrollRoutes(app: App) {
return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`);
});
app.get("/v1/payroll/weeks", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/payroll/weeks", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const tenantId = tid(c);
const start = c.req.query("week_start") || weekContaining(await resolveToday(db, tenantId)).weekStart;
@ -258,12 +256,12 @@ export function registerPayrollRoutes(app: App) {
return c.json(bundle);
});
app.get("/v1/payroll/weeks/open", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/payroll/weeks/open", ...requireCoreAuth, async (c) => {
const db = c.get("db");
return c.json({ weeks: await listOpenWeeks(db, tid(c)) });
});
app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, async (c) => {
const db = c.get("db");
try {
const nowIso = await resolveToday(db, tid(c));
@ -275,7 +273,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, async (c) => {
const db = c.get("db");
try {
const nowIso = await resolveToday(db, tid(c));
@ -287,7 +285,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const csv = await weekCsv(db, id);
@ -296,7 +294,7 @@ export function registerPayrollRoutes(app: App) {
return c.body(csv);
});
app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{ project_id: number; worker_id: number }>();
const db = c.get("db");
try {
@ -308,7 +306,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>();
const db = c.get("db");
try {
@ -320,7 +318,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{ amount: number }>();
const db = c.get("db");
try {
@ -332,7 +330,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, requirePermission("payroll.delete"), async (c) => {
app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => {
const db = c.get("db");
try {
await removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")));
@ -343,7 +341,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.post("/v1/destajo/jobs", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
app.post("/v1/destajo/jobs", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{
week_id: number;
project_id: number;
@ -365,7 +363,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{
week_id: number;
qty_planned?: number;
@ -382,7 +380,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.post("/v1/payroll/periods", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
app.post("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
const body = await c.req.json<{
project_id: number;
week_start: string;
@ -417,7 +415,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/payroll/periods", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const projectId = c.req.query("project_id");
const env = await callCoreFn<{ periods: unknown[] }>(
@ -429,7 +427,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const env = await callCoreFn<{ period: unknown; lines: unknown[] }>(
@ -441,7 +439,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const env = await callCoreFn<{ period: unknown; lines: Array<Record<string, unknown>> }>(
@ -462,7 +460,7 @@ export function registerPayrollRoutes(app: App) {
return c.body(csv);
});
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const { status } = await c.req.json<{ status: string }>();
const db = c.get("db");

View file

@ -3,62 +3,44 @@ import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { withIamTenant } from "./iam_db.ts";
import { respondApiError, routeLabel } from "./http_errors.ts";
import { ALL_PERMISSION_CODES, IAM_SENSITIVE_PREFIXES } from "./iam_catalog.ts";
const permissionCache = new Map<string, { perms: Set<string>; at: number }>();
const CACHE_TTL_MS = 60_000;
export function invalidatePermissionCache(roleId?: number) {
if (roleId != null) {
for (const key of [...permissionCache.keys()]) {
if (key.startsWith(`${roleId}:`)) permissionCache.delete(key);
}
return;
}
permissionCache.clear();
}
export async function userPermissionsByRoleId(
export async function userPermissions(
tenantId: number | null,
roleId: number | null,
isOwner: boolean,
roleCode: string,
): Promise<Set<string>> {
if (isOwner) return new Set(ALL_PERMISSION_CODES);
if (roleId == null) return new Set();
const key = `${roleId}:${tenantId ?? 0}`;
const key = `${tenantId ?? 0}:${roleCode}`;
const hit = permissionCache.get(key);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.perms;
if (roleCode === "tenant_admin") {
const all = new Set([
"view_expenses", "manage_expenses", "view_warehouse", "manage_warehouse",
"close_warehouse", "manage_cost_settings", "manage_users", "manage_budget",
"manage_payroll", "manage_workers", "manage_projects", "manage_companies",
"manage_documents", "manage_settings", "view_reports",
]);
permissionCache.set(key, { perms: all, at: Date.now() });
return all;
}
const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare(
"SELECT permission_code FROM role_permissions WHERE role_id = ?",
).all(roleId) as { permission_code: string }[],
"SELECT permission_code FROM role_permissions WHERE role_code = ?",
).all(roleCode) as { permission_code: string }[],
);
const perms = new Set(rows.map((r) => r.permission_code));
permissionCache.set(key, { perms, at: Date.now() });
return perms;
}
/** @deprecated use userPermissionsByRoleId */
export async function userPermissions(
tenantId: number | null,
roleCode: string,
): Promise<Set<string>> {
if (roleCode === "tenant_admin") return new Set(ALL_PERMISSION_CODES);
const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare(
`SELECT rp.permission_code FROM role_permissions rp
JOIN roles r ON r.id = rp.role_id
WHERE r.code = ? AND (r.tenant_id = ? OR (r.is_system AND r.tenant_id IS NULL))`,
).all(roleCode, tenantId) as { permission_code: string }[],
);
return new Set(rows.map((r) => r.permission_code));
}
export async function hasPermission(user: AuthUser, code: string): Promise<boolean> {
if (user.realm === "platform") return true;
if (user.is_owner) return true;
const perms = await userPermissionsByRoleId(user.tenant_id, user.role_id, false);
export async function hasPermission(
user: AuthUser,
code: string,
): Promise<boolean> {
if (user.role === "tenant_admin") return true;
const role = user.role === "user" ? "user" : user.role;
const perms = await userPermissions(user.tenant_id, role);
return perms.has(code);
}
@ -74,7 +56,7 @@ export function requirePermission(code: string) {
c,
"FORBIDDEN",
`Permiso requerido: ${code}`,
{ route: routeLabel(c), permission: code, role: user.role_code },
{ route: routeLabel(c), permission: code, role: user.role },
);
}
await next();
@ -103,16 +85,6 @@ export function requireAnyPermission(...codes: string[]) {
};
}
export function filterSensitivePermissions(
permissions: string[],
actorIsOwner: boolean,
): string[] {
if (actorIsOwner) return permissions;
return permissions.filter(
(p) => !IAM_SENSITIVE_PREFIXES.some((prefix) => p.startsWith(prefix)),
);
}
export async function callIamFn<T = unknown>(
tenantId: number | null,
fn: string,
@ -127,19 +99,3 @@ export async function callIamFn<T = unknown>(
}
return null;
}
export async function usersWithRole(tenantId: number | null, roleId: number): Promise<number[]> {
const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare("SELECT id FROM users WHERE role_id = ?").all(roleId) as { id: number }[],
);
return rows.map((r) => Number(r.id));
}
export async function revokeSessionsForRoleUsers(
tenantId: number | null,
roleId: number,
): Promise<void> {
const { revokeAllSessionsForUser } = await import("./sessions.ts");
const ids = await usersWithRole(tenantId, roleId);
await Promise.all(ids.map((id) => revokeAllSessionsForUser(id, "app")));
}

View file

@ -236,12 +236,10 @@ export async function getTenantDetail(
const admins = await withIamOwner(async (iam) => {
const rows = await iam.prepare(
`SELECT u.id, u.username, u.display_name, COALESCE(u.email, '') AS email, r.code AS role,
COALESCE(u.must_change_password, false) AS must_change_password, u.created_at
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.tenant_id = ? AND r.code = 'tenant_admin' AND r.is_system AND r.tenant_id IS NULL
ORDER BY u.id`,
`SELECT id, username, display_name, COALESCE(email, '') AS email, role_code AS role,
COALESCE(must_change_password, false) AS must_change_password, created_at
FROM users WHERE tenant_id = ? AND role_code = 'tenant_admin'
ORDER BY id`,
).all(tenantId) as Array<Record<string, unknown>>;
return rows.map((a) => ({
id: Number(a.id),
@ -346,20 +344,16 @@ export async function createTenant(
).then(() => core.lastInsertId())
);
// Paso 3: admin del tenant en iam (rol sistema tenant_admin).
// Paso 3: admin del tenant en iam.
const hash = await hashPassword(temporaryPassword);
await withIamOwner(async (iam) => {
const adminRole = await iam.prepare(
`SELECT id FROM roles WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL`,
).get() as { id: number } | undefined;
if (!adminRole) throw new Error("Rol sistema tenant_admin no encontrado en iam.roles");
await iam.prepare(
await withIamOwner((iam) =>
iam.prepare(
`INSERT INTO users (
username, password_hash, display_name, company_id, tenant_id, role_id,
must_change_password, email, status, scope_all_projects, warehouse_central, warehouse_projects
) VALUES (?, ?, ?, ?, ?, ?, true, ?, 'activo', true, true, true)`,
).run(adminUser, hash, adminName, companyId, tenantId, adminRole.id, contactEmail);
});
username, password_hash, display_name, company_id, tenant_id, role_code,
must_change_password, email
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`,
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail)
);
} catch (e) {
// Compensación: limpiar en orden inverso lo que sí se alcanzó a crear.
if (companyId != null) {

View file

@ -1,116 +0,0 @@
import type { Context } from "hono";
import type { AuthUser } from "./auth.ts";
import type { Db } from "./db.ts";
import { respondApiError, routeLabel, type RpcEnvelope } from "./http_errors.ts";
import { hasPermission } from "./permissions.ts";
import { projectDocPerm } from "./iam_catalog.ts";
import { assertProjectAccess, getUserScope, type UserScope } from "./user_scope.ts";
export async function denyUnlessProjectScope(
c: Context,
projectId: number | null | undefined,
): Promise<boolean> {
if (projectId == null || !Number.isFinite(projectId)) return true;
const user = c.get("user") as AuthUser;
if (user.realm === "platform" || user.is_owner) return true;
const scope = await getUserScope(user);
if (assertProjectAccess(scope, projectId)) return true;
respondApiError(c, "FORBIDDEN", "Obra fuera de su alcance", {
route: routeLabel(c),
project_id: projectId,
});
return false;
}
export function filterProjectsByScope<T extends { id: number }>(
items: T[],
scope: UserScope,
): T[] {
if (scope.allProjects) return items;
const allowed = new Set(scope.projectIds);
return items.filter((p) => allowed.has(Number(p.id)));
}
export async function filterProjectListEnvelope(
c: Context,
env: RpcEnvelope<{ projects?: Array<{ id: number }> }>,
): Promise<RpcEnvelope<{ projects?: Array<{ id: number }> }>> {
const user = c.get("user") as AuthUser;
if (!env.ok || !env.data?.projects || user.realm === "platform" || user.is_owner) return env;
const scope = await getUserScope(user);
return {
...env,
data: {
...env.data,
projects: filterProjectsByScope(env.data.projects, scope),
},
};
}
export async function projectDocCategory(
db: Db,
typeCode: string,
): Promise<string> {
const row = await db.prepare(
"SELECT category FROM project_document_types WHERE code = ?",
).get(typeCode) as { category: string } | undefined;
return row?.category ?? "otro";
}
export async function denyUnlessProjectDocView(
c: Context,
db: Db,
typeCode: string,
): Promise<boolean> {
const user = c.get("user") as AuthUser;
if (user.realm === "platform" || user.is_owner) return true;
const category = await projectDocCategory(db, typeCode);
if (await hasPermission(user, projectDocPerm(category, "view"))) return true;
respondApiError(c, "FORBIDDEN", "Sin permiso para este tipo de documento de obra", {
route: routeLabel(c),
type_code: typeCode,
category,
});
return false;
}
export async function denyUnlessProjectDocCreate(
c: Context,
db: Db,
typeCode: string,
): Promise<boolean> {
const user = c.get("user") as AuthUser;
if (user.realm === "platform" || user.is_owner) return true;
const category = await projectDocCategory(db, typeCode);
if (await hasPermission(user, projectDocPerm(category, "create"))) return true;
respondApiError(c, "FORBIDDEN", "Sin permiso para subir este tipo de documento", {
route: routeLabel(c),
type_code: typeCode,
category,
});
return false;
}
export async function filterProjectDocumentsForUser(
user: AuthUser,
db: Db,
documents: Array<{ type_code?: string; type?: string }>,
): Promise<typeof documents> {
if (user.realm === "platform" || user.is_owner) return documents;
const out: typeof documents = [];
for (const doc of documents) {
const typeCode = String(doc.type_code ?? doc.type ?? "");
const category = await projectDocCategory(db, typeCode);
if (await hasPermission(user, projectDocPerm(category, "view"))) out.push(doc);
}
return out;
}
export async function denyUnlessWarehouseCentral(c: Context): Promise<boolean> {
const user = c.get("user") as AuthUser;
if (user.realm === "platform" || user.is_owner) return true;
const scope = await getUserScope(user);
if (scope.warehouseCentral) return true;
respondApiError(c, "FORBIDDEN", "Sin acceso al almacén central", { route: routeLabel(c) });
return false;
}

View file

@ -74,27 +74,18 @@ async function bootstrapTenantAdmin() {
const hash = await hashPassword(config.seedPassword);
await withIamOwner(async (db) => {
const adminRole = await db.prepare(
`SELECT id FROM roles WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL`,
).get() as { id: number } | undefined;
if (!adminRole) throw new Error("Rol sistema tenant_admin no encontrado");
const existing = await db.prepare("SELECT id FROM users WHERE username = ?").get(username);
if (existing) {
await db.prepare(
`UPDATE users SET password_hash = ?, display_name = ?, company_id = ?, tenant_id = ?,
role_id = ?, must_change_password = true, status = 'activo',
scope_all_projects = true, warehouse_central = true, warehouse_projects = true
WHERE username = ?`,
).run(hash, displayName, company.id, tenantId, adminRole.id, username);
role_code = 'tenant_admin', must_change_password = true WHERE username = ?`,
).run(hash, displayName, company.id, tenantId, username);
console.log(`[bootstrap] iam.users.${username} actualizado (tenant_id=${tenantId})`);
} else {
await db.prepare(
`INSERT INTO users (
username, password_hash, display_name, company_id, tenant_id, role_id,
must_change_password, email, status, scope_all_projects, warehouse_central, warehouse_projects
) VALUES (?, ?, ?, ?, ?, ?, true, '', 'activo', true, true, true)`,
).run(username, hash, displayName, company.id, tenantId, adminRole.id);
`INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role_code, must_change_password, email)
VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, '')`,
).run(username, hash, displayName, company.id, tenantId);
console.log(`[bootstrap] iam.users.${username} creado (tenant_id=${tenantId})`);
}
});

View file

@ -1,77 +0,0 @@
import type { AuthUser } from "./auth.ts";
import { withIamTenant } from "./iam_db.ts";
export type UserScope = {
allProjects: boolean;
projectIds: number[];
warehouseCentral: boolean;
warehouseProjects: boolean;
};
const scopeCache = new Map<number, { scope: UserScope; at: number }>();
const SCOPE_TTL_MS = 60_000;
export function invalidateUserScopeCache(userId?: number) {
if (userId != null) scopeCache.delete(userId);
else scopeCache.clear();
}
export async function loadUserScope(user: AuthUser): Promise<UserScope> {
if (user.is_owner || user.realm === "platform") {
return {
allProjects: true,
projectIds: [],
warehouseCentral: true,
warehouseProjects: true,
};
}
const hit = scopeCache.get(user.id);
if (hit && Date.now() - hit.at < SCOPE_TTL_MS) return hit.scope;
const row = await withIamTenant(user.tenant_id, async (db) => {
const u = await db.prepare(
`SELECT scope_all_projects, warehouse_central, warehouse_projects FROM users WHERE id = ?`,
).get(user.id) as {
scope_all_projects: boolean;
warehouse_central: boolean;
warehouse_projects: boolean;
} | undefined;
if (!u) return null;
const projects = await db.prepare(
`SELECT project_id FROM user_projects WHERE user_id = ? ORDER BY project_id`,
).all(user.id) as { project_id: number }[];
return {
allProjects: Boolean(u.scope_all_projects),
projectIds: projects.map((p) => Number(p.project_id)),
warehouseCentral: Boolean(u.warehouse_central),
warehouseProjects: Boolean(u.warehouse_projects),
};
});
const scope = row ?? {
allProjects: false,
projectIds: [],
warehouseCentral: false,
warehouseProjects: false,
};
scopeCache.set(user.id, { scope, at: Date.now() });
return scope;
}
export function allowedProjectIds(scope: UserScope): number[] | null {
if (scope.allProjects) return null;
return scope.projectIds;
}
export function assertProjectAccess(scope: UserScope, projectId: number | null | undefined): boolean {
if (projectId == null || !Number.isFinite(projectId)) return false;
if (scope.allProjects) return true;
return scope.projectIds.includes(Number(projectId));
}
export async function getUserScope(user: AuthUser): Promise<UserScope> {
return await loadUserScope(user);
}
export function isOwnerAccountRow(row: { is_owner?: boolean; role_code?: string }): boolean {
return Boolean(row.is_owner) || row.role_code === "tenant_admin";
}

View file

@ -4,10 +4,8 @@ import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import { requirePermission } from "./permissions.ts";
import { denyUnlessProjectScope, denyUnlessWarehouseCentral } from "./scope_enforcement.ts";
import { getUserScope } from "./user_scope.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission, requirePermission } from "./permissions.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
@ -20,59 +18,37 @@ function actor(c: Context): { id: number; name: string } {
return { id: u.id, name: u.display_name };
}
async function filterWarehouseList(c: Context, env: Awaited<ReturnType<typeof callCoreFn>>) {
const user = c.get("user") as AuthUser;
if (!env.ok || !env.data || user.realm === "platform" || user.is_owner) return env;
const scope = await getUserScope(user);
const warehouses = (env.data as { warehouses?: Array<{ id: number; kind?: string; project_id?: number | null }> }).warehouses ?? [];
const filtered = warehouses.filter((wh) => {
if (wh.kind === "central") return scope.warehouseCentral;
if (wh.kind === "project") {
if (!scope.warehouseProjects) return false;
if (scope.allProjects) return true;
return scope.projectIds.includes(Number(wh.project_id));
}
return true;
});
return { ...env, data: { ...(env.data as object), warehouses: filtered } };
}
export function registerWarehouseRoutes(app: App) {
app.get("/v1/warehouses", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
app.get("/v1/warehouses", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_list", {
tenant_id: tid(c),
include_closed: c.req.query("include_closed") === "true",
}, { route: routeLabel(c) });
return respondRpc(c, await filterWarehouseList(c, env));
return respondRpc(c, env);
});
app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
if (!await denyUnlessWarehouseCentral(c)) return;
app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_ensure_central", { tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/projects/:id/warehouse/open", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
app.post("/v1/projects/:id/warehouse/open", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_open_project", {
tenant_id: tid(c),
project_id: projectId,
project_id: Number(c.req.param("id")),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("close_warehouse"), async (c) => {
const body = await c.req.json<{ force?: boolean; close_notes?: string }>().catch(() => ({} as { force?: boolean; close_notes?: string }));
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_close_project", {
project_id: projectId,
project_id: Number(c.req.param("id")),
force: body.force,
closed_by_name: a.name,
close_notes: body.close_notes,
@ -80,26 +56,17 @@ export function registerWarehouseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/warehouses/:id/stock", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
app.get("/v1/warehouses/:id/stock", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
const db = c.get("db");
const whId = Number(c.req.param("id"));
const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as
| { kind: string; project_id: number | null }
| undefined;
if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId });
if (wh.kind === "central") {
if (!await denyUnlessWarehouseCentral(c)) return;
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
const env = await callCoreFn(db, "core.fn_warehouse_stock_list", {
tenant_id: tid(c),
warehouse_id: whId,
warehouse_id: Number(c.req.param("id")),
global: false,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/warehouses/stock/global", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
if (!await denyUnlessWarehouseCentral(c)) return;
app.get("/v1/warehouses/stock/global", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_stock_list", {
tenant_id: tid(c),
@ -108,75 +75,51 @@ export function registerWarehouseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/warehouses/:id/movements", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
app.get("/v1/warehouses/:id/movements", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
const db = c.get("db");
const whId = Number(c.req.param("id"));
const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as
| { kind: string; project_id: number | null }
| undefined;
if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId });
if (wh.kind === "central") {
if (!await denyUnlessWarehouseCentral(c)) return;
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
const env = await callCoreFn(db, "core.fn_warehouse_movement_list", {
warehouse_id: whId,
warehouse_id: Number(c.req.param("id")),
material_id: c.req.query("material_id"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => {
app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_material_upsert", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => {
const whId = Number(c.req.param("id"));
const db = c.get("db");
const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as
| { kind: string; project_id: number | null }
| undefined;
if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId });
if (wh.kind === "central") {
if (!await denyUnlessWarehouseCentral(c)) return;
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_entry_create", {
...body,
tenant_id: tid(c),
warehouse_id: whId,
warehouse_id: Number(c.req.param("id")),
created_by_id: a.id,
created_by_name: a.name,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const whId = Number(c.req.param("id"));
const db = c.get("db");
const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as
| { kind: string; project_id: number | null }
| undefined;
if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId });
if (wh.kind === "central") {
if (!await denyUnlessWarehouseCentral(c)) return;
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_exit_create", {
...body,
tenant_id: tid(c),
warehouse_id: whId,
warehouse_id: Number(c.req.param("id")),
created_by_id: a.id,
created_by_name: a.name,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const a = actor(c);

View file

@ -1,10 +1,9 @@
import type { Context, Hono } from "hono";
import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { projectById } from "./db.ts";
import { requireAnyPermission, requirePermission } from "./permissions.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
import { requirePermission } from "./permissions.ts";
import {
createEmptyWorkProgram,
exportWorkProgram,
@ -23,11 +22,7 @@ import {
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
const viewAuth = [...requireCoreAuth, requirePermission("work_program.view")] as const;
async function ensureProject(c: Context, id: number): Promise<boolean> {
return await denyUnlessProjectScope(c, id);
}
const auth = [...requireCoreAuth, requirePermission("manage_budget")] as const;
async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Promise<Uint8Array> {
const form = await c.req.formData();
@ -37,90 +32,70 @@ async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Pr
}
export function registerWorkProgramRoutes(app: App) {
app.get("/v1/projects/:id/work-program", ...viewAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.get("/v1/projects/:id/work-program", ...auth, async (c) => {
const db = c.get("db");
const data = await getWorkProgram(db, id);
const data = await getWorkProgram(db, Number(c.req.param("id")));
return c.json({ ok: true, data });
});
app.get("/v1/projects/:id/work-program/concepts", ...viewAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.get("/v1/projects/:id/work-program/concepts", ...auth, async (c) => {
const db = c.get("db");
const data = await listConcepts(db, id);
const data = await listConcepts(db, Number(c.req.param("id")));
return c.json({ ok: true, data });
});
app.get("/v1/projects/:id/work-program/partidas", ...viewAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.get("/v1/projects/:id/work-program/partidas", ...auth, async (c) => {
const db = c.get("db");
const data = await listPartidas(db, id);
const data = await listPartidas(db, Number(c.req.param("id")));
return c.json({ ok: true, data });
});
app.get("/v1/projects/:id/work-program/vs-cost", ...requireCoreAuth, requirePermission("cost_control.view"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.get("/v1/projects/:id/work-program/vs-cost", ...auth, async (c) => {
const db = c.get("db");
const data = await getVsCost(db, id);
const data = await getVsCost(db, Number(c.req.param("id")));
return c.json({ ok: true, data });
});
app.post("/v1/projects/:id/work-program/preview", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.post("/v1/projects/:id/work-program/preview", ...auth, async (c) => {
const db = c.get("db");
const bytes = await readUpload(c);
const preview = await previewWorkProgramExcel(bytes, id, db);
const preview = await previewWorkProgramExcel(bytes, Number(c.req.param("id")), db);
return c.json({ ok: true, data: preview });
});
app.post("/v1/projects/:id/work-program/import", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.post("/v1/projects/:id/work-program/import", ...auth, async (c) => {
const db = c.get("db");
const bytes = await readUpload(c);
const createChapters = c.req.query("create_chapters") !== "false";
const data = await importWorkProgramExcel(db, id, bytes, { createChapters });
const data = await importWorkProgramExcel(db, Number(c.req.param("id")), bytes, { createChapters });
return c.json({ ok: true, data });
});
app.post("/v1/projects/:id/work-program", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.post("/v1/projects/:id/work-program", ...auth, async (c) => {
const db = c.get("db");
const data = await createEmptyWorkProgram(db, id);
const data = await createEmptyWorkProgram(db, Number(c.req.param("id")));
return c.json({ ok: true, data });
});
app.post("/v1/projects/:id/work-program/generate", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.post("/v1/projects/:id/work-program/generate", ...auth, async (c) => {
const db = c.get("db");
const body = await c.req.json().catch(() => ({})) as { strategy?: string };
const data = await generateWorkProgram(db, id, body.strategy || "keyword");
const data = await generateWorkProgram(db, Number(c.req.param("id")), body.strategy || "keyword");
return c.json({ ok: true, data });
});
app.post("/v1/projects/:id/work-program/recalculate-partidas", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.post("/v1/projects/:id/work-program/recalculate-partidas", ...auth, async (c) => {
const db = c.get("db");
const env = await recalculatePartidas(db, id);
const env = await recalculatePartidas(db, Number(c.req.param("id")));
return c.json({ ok: true, data: env });
});
app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...auth, async (c) => {
const db = c.get("db");
const body = await c.req.json() as { start_pct?: number; end_pct?: number };
const data = await updateConceptSlot(
db,
id,
Number(c.req.param("id")),
Number(c.req.param("itemId")),
Number(c.req.param("periodId")),
Number(body.start_pct ?? 0),
@ -129,14 +104,12 @@ export function registerWorkProgramRoutes(app: App) {
return c.json({ ok: true, data });
});
app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...auth, async (c) => {
const db = c.get("db");
const body = await c.req.json() as { amount?: number };
const data = await updatePartidaAmount(
db,
id,
Number(c.req.param("id")),
Number(c.req.param("chapterId")),
Number(c.req.param("periodId")),
Number(body.amount ?? 0),
@ -144,14 +117,12 @@ export function registerWorkProgramRoutes(app: App) {
return c.json({ ok: true, data });
});
app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...auth, async (c) => {
const db = c.get("db");
const body = await c.req.json() as { pct_complete?: number };
const data = await updateProgress(
db,
id,
Number(c.req.param("id")),
Number(c.req.param("itemId")),
Number(c.req.param("periodId")),
Number(body.pct_complete ?? 0),
@ -159,10 +130,9 @@ export function registerWorkProgramRoutes(app: App) {
return c.json({ ok: true, data });
});
app.get("/v1/projects/:id/work-program/export", ...viewAuth, async (c) => {
app.get("/v1/projects/:id/work-program/export", ...auth, async (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const kind = (c.req.query("kind") === "partida" ? "partida" : "concepto") as "concepto" | "partida";
const project = await projectById(db, id);
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);

View file

@ -37,6 +37,5 @@
<include file="changesets/028-fix-cost-settings-ambiguous.sql" relativeToChangelogFile="true"/>
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -1,12 +0,0 @@
--liquibase formatted sql
-- PANELS · core · tipos de documento técnico adicionales
--changeset panel:core-031a-project-doc-types-technical endDelimiter:; splitStatements:true
INSERT INTO core.project_document_types (code, label, required, category) VALUES
('renders', 'Renders', false, 'tecnico'),
('modelo_3d', 'Modelo 3D', false, 'tecnico'),
('sketchup', 'SketchUp', false, 'tecnico')
ON CONFLICT (code) DO NOTHING;
--changeset panel:core-031b-iam-rpc-created-grant endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.rpc_created(jsonb, text, jsonb) TO panels_iam_app;

View file

@ -10,6 +10,5 @@
<include file="changesets/002-rls.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-expenses-warehouse-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-rpc-users-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-iam-v2-roles-crud.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -1,972 +0,0 @@
--liquibase formatted sql
-- PANELS · iam · IAM v2: permisos CRUD, roles por tenant, usuarios ampliados y RPC
--changeset panel:iam-005a-permissions-metadata endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='permissions' AND column_name='module'
ALTER TABLE iam.permissions ADD COLUMN module TEXT;
ALTER TABLE iam.permissions ADD COLUMN verb TEXT;
ALTER TABLE iam.permissions ADD COLUMN perm_group TEXT;
--changeset panel:iam-005b-permissions-v2-seed endDelimiter:; splitStatements:true
INSERT INTO iam.permissions (code, label, module, verb, perm_group) VALUES
('workers.view', 'Consultar padrón', 'workers', 'view', NULL),
('workers.create', 'Alta de personal', 'workers', 'create', NULL),
('workers.update', 'Editar personal', 'workers', 'update', NULL),
('workers.delete', 'Baja de personal', 'workers', 'delete', NULL),
('kanban.view', 'Tablero kanban', 'kanban', 'view', NULL),
('projects.view', 'Consultar obras', 'projects', 'view', NULL),
('projects.create', 'Alta de obras', 'projects', 'create', NULL),
('projects.update', 'Editar obras', 'projects', 'update', NULL),
('companies.view', 'Consultar empresas', 'companies', 'view', NULL),
('companies.create', 'Alta de empresas', 'companies', 'create', NULL),
('companies.update', 'Editar empresas', 'companies', 'update', NULL),
('budget.view', 'Ver presupuesto', 'budget', 'view', NULL),
('budget.create', 'Alta partidas presupuesto', 'budget', 'create', NULL),
('budget.update', 'Editar presupuesto', 'budget', 'update', NULL),
('budget.delete', 'Eliminar partidas', 'budget', 'delete', NULL),
('work_program.view', 'Ver programa de obra', 'work_program', 'view', NULL),
('work_program.create', 'Importar/generar programa', 'work_program', 'create', NULL),
('work_program.update', 'Capturar avances', 'work_program', 'update', NULL),
('cost_control.view', 'Control de costos', 'cost_control', 'view', NULL),
('expenses.view', 'Ver gastos', 'expenses', 'view', NULL),
('expenses.create', 'Capturar gastos', 'expenses', 'create', NULL),
('expenses.update', 'Editar gastos', 'expenses', 'update', NULL),
('expenses.delete', 'Eliminar gastos', 'expenses', 'delete', NULL),
('warehouse.view', 'Ver almacén', 'warehouse', 'view', NULL),
('warehouse.create', 'Entradas almacén', 'warehouse', 'create', NULL),
('warehouse.update', 'Salidas/transferencias', 'warehouse', 'update', NULL),
('payroll.view', 'Ver nómina', 'payroll', 'view', NULL),
('payroll.create', 'Alta nómina/préstamos', 'payroll', 'create', NULL),
('payroll.update', 'Armar/pagar nómina', 'payroll', 'update', NULL),
('payroll.delete', 'Eliminar líneas nómina', 'payroll', 'delete', NULL),
('documents.view', 'Ver documentos/gafetes', 'documents', 'view', NULL),
('documents.create', 'Subir documentos/gafetes', 'documents', 'create', NULL),
('project_docs.tecnico.view', 'Planos y docs técnicos', 'project_docs', 'view', 'tecnico'),
('project_docs.tecnico.create', 'Subir docs técnicos', 'project_docs', 'create', 'tecnico'),
('project_docs.contrato.view', 'Docs contrato', 'project_docs', 'view', 'contrato'),
('project_docs.contrato.create', 'Subir docs contrato', 'project_docs', 'create', 'contrato'),
('project_docs.permisos.view', 'Docs permisos', 'project_docs', 'view', 'permisos'),
('project_docs.permisos.create', 'Subir docs permisos', 'project_docs', 'create', 'permisos'),
('project_docs.ambiental.view', 'Docs ambientales', 'project_docs', 'view', 'ambiental'),
('project_docs.ambiental.create', 'Subir docs ambientales', 'project_docs', 'create', 'ambiental'),
('project_docs.imss.view', 'Docs IMSS', 'project_docs', 'view', 'imss'),
('project_docs.imss.create', 'Subir docs IMSS', 'project_docs', 'create', 'imss'),
('project_docs.sst.view', 'Docs SST', 'project_docs', 'view', 'sst'),
('project_docs.sst.create', 'Subir docs SST', 'project_docs', 'create', 'sst'),
('project_docs.otro.view', 'Otros docs obra', 'project_docs', 'view', 'otro'),
('project_docs.otro.create', 'Subir otros docs', 'project_docs', 'create', 'otro'),
('users.view', 'Listar usuarios IAM', 'users', 'view', NULL),
('users.create', 'Alta usuarios IAM', 'users', 'create', NULL),
('users.update', 'Editar usuarios IAM', 'users', 'update', NULL),
('users.delete', 'Baja usuarios IAM', 'users', 'delete', NULL),
('settings.view', 'Ver configuración', 'settings', 'view', NULL),
('settings.update', 'Guardar configuración', 'settings', 'update', NULL),
('reports.view', 'Reportes e inicio', 'reports', 'view', NULL)
ON CONFLICT (code) DO UPDATE SET
label = EXCLUDED.label,
module = EXCLUDED.module,
verb = EXCLUDED.verb,
perm_group = EXCLUDED.perm_group;
--changeset panel:iam-005c-legacy-perm-map endDelimiter:; splitStatements:true
CREATE TABLE IF NOT EXISTS iam._legacy_perm_map (
legacy_code TEXT NOT NULL,
v2_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE,
PRIMARY KEY (legacy_code, v2_code)
);
INSERT INTO iam._legacy_perm_map (legacy_code, v2_code) VALUES
('manage_workers', 'workers.view'),
('manage_workers', 'workers.create'),
('manage_workers', 'workers.update'),
('manage_workers', 'workers.delete'),
('manage_workers', 'kanban.view'),
('manage_projects', 'projects.view'),
('manage_projects', 'projects.create'),
('manage_projects', 'projects.update'),
('manage_companies', 'companies.view'),
('manage_companies', 'companies.create'),
('manage_companies', 'companies.update'),
('manage_budget', 'budget.view'),
('manage_budget', 'budget.create'),
('manage_budget', 'budget.update'),
('manage_budget', 'budget.delete'),
('manage_budget', 'work_program.view'),
('manage_budget', 'work_program.create'),
('manage_budget', 'work_program.update'),
('manage_expenses', 'expenses.view'),
('manage_expenses', 'expenses.create'),
('manage_expenses', 'expenses.update'),
('manage_expenses', 'expenses.delete'),
('manage_expenses', 'cost_control.view'),
('view_expenses', 'expenses.view'),
('view_expenses', 'cost_control.view'),
('manage_payroll', 'payroll.view'),
('manage_payroll', 'payroll.create'),
('manage_payroll', 'payroll.update'),
('manage_payroll', 'payroll.delete'),
('manage_documents', 'documents.view'),
('manage_documents', 'documents.create'),
('manage_documents', 'project_docs.tecnico.view'),
('manage_documents', 'project_docs.tecnico.create'),
('manage_documents', 'project_docs.contrato.view'),
('manage_documents', 'project_docs.contrato.create'),
('manage_documents', 'project_docs.permisos.view'),
('manage_documents', 'project_docs.permisos.create'),
('manage_users', 'users.view'),
('manage_users', 'users.create'),
('manage_users', 'users.update'),
('manage_users', 'users.delete'),
('manage_settings', 'settings.view'),
('manage_settings', 'settings.update'),
('view_reports', 'reports.view'),
('view_warehouse', 'warehouse.view'),
('manage_warehouse', 'warehouse.view'),
('manage_warehouse', 'warehouse.create'),
('manage_warehouse', 'warehouse.update'),
('close_warehouse', 'warehouse.update'),
('manage_cost_settings', 'settings.update'),
('manage_cost_settings', 'cost_control.view')
ON CONFLICT DO NOTHING;
--changeset panel:iam-005d-roles-v2 endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='roles' AND column_name='id'
CREATE TABLE iam.roles_v2 (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER,
code TEXT NOT NULL,
label TEXT NOT NULL,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO iam.roles_v2 (tenant_id, code, label, is_system)
SELECT NULL, code, label, true
FROM iam.roles
WHERE code = 'tenant_admin';
INSERT INTO iam.roles_v2 (tenant_id, code, label, is_system)
SELECT DISTINCT u.tenant_id, 'operativo', 'Operativo', false
FROM iam.users u
WHERE u.role_code = 'user' AND u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM iam.roles_v2 r
WHERE r.tenant_id = u.tenant_id AND r.code = 'operativo'
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_roles_v2_tenant_code
ON iam.roles_v2 (tenant_id, code)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_roles_v2_system_code
ON iam.roles_v2 (code)
WHERE tenant_id IS NULL AND is_system;
--changeset panel:iam-005e-role-permissions-v2 endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='role_permissions' AND column_name='role_id'
CREATE TABLE iam.role_permissions_v2 (
role_id BIGINT NOT NULL REFERENCES iam.roles_v2(id) ON DELETE CASCADE,
permission_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_code)
);
INSERT INTO iam.role_permissions_v2 (role_id, permission_code)
SELECT DISTINCT sr.id, lm.v2_code
FROM iam.role_permissions rp
JOIN iam._legacy_perm_map lm ON lm.legacy_code = rp.permission_code
JOIN iam.roles_v2 sr ON sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL
WHERE rp.role_code = 'tenant_admin'
ON CONFLICT DO NOTHING;
INSERT INTO iam.role_permissions_v2 (role_id, permission_code)
SELECT sr.id, p.code
FROM iam.roles_v2 sr
CROSS JOIN iam.permissions p
WHERE sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL
AND p.module IS NOT NULL
ON CONFLICT DO NOTHING;
INSERT INTO iam.role_permissions_v2 (role_id, permission_code)
SELECT DISTINCT tr.id, lm.v2_code
FROM iam.role_permissions rp
JOIN iam._legacy_perm_map lm ON lm.legacy_code = rp.permission_code
JOIN iam.roles_v2 tr ON tr.code = 'operativo' AND tr.tenant_id IS NOT NULL
WHERE rp.role_code = 'user'
ON CONFLICT DO NOTHING;
--changeset panel:iam-005f-users-v2-columns endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='users' AND column_name='role_id'
ALTER TABLE iam.users ADD COLUMN role_id BIGINT;
ALTER TABLE iam.users ADD COLUMN status TEXT NOT NULL DEFAULT 'activo';
ALTER TABLE iam.users ADD COLUMN scope_all_projects BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE iam.users ADD COLUMN warehouse_central BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE iam.users ADD COLUMN warehouse_projects BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE iam.users ADD CONSTRAINT users_status_check
CHECK (status IN ('activo', 'baja'));
--changeset panel:iam-005g-users-backfill-role-id endDelimiter:; splitStatements:true
UPDATE iam.users u
SET role_id = sr.id
FROM iam.roles_v2 sr
WHERE u.role_code = 'tenant_admin'
AND sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL
AND u.role_id IS NULL;
UPDATE iam.users u
SET role_id = sr.id
FROM iam.roles_v2 sr
WHERE u.role_code = 'user'
AND sr.code = 'operativo' AND sr.tenant_id = u.tenant_id
AND u.role_id IS NULL;
UPDATE iam.users u
SET role_id = sr.id
FROM iam.roles_v2 sr
WHERE u.role_id IS NULL
AND sr.code = 'operativo' AND sr.tenant_id = u.tenant_id;
--changeset panel:iam-005h-swap-roles endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='roles' AND column_name='id'
ALTER TABLE iam.users DROP CONSTRAINT IF EXISTS users_role_code_fkey;
ALTER TABLE iam.role_permissions DROP CONSTRAINT IF EXISTS role_permissions_role_code_fkey;
ALTER TABLE iam.role_permissions DROP CONSTRAINT IF EXISTS role_permissions_permission_code_fkey;
DROP TABLE iam.role_permissions;
ALTER TABLE iam.users DROP COLUMN IF EXISTS role_code;
ALTER TABLE iam.role_permissions_v2 RENAME TO role_permissions;
ALTER TABLE iam.roles DROP CONSTRAINT roles_pkey;
DROP TABLE iam.roles;
ALTER TABLE iam.roles_v2 RENAME TO roles;
ALTER TABLE iam.users
ADD CONSTRAINT users_role_id_fkey FOREIGN KEY (role_id) REFERENCES iam.roles(id);
ALTER TABLE iam.users ALTER COLUMN role_id SET NOT NULL;
DO $$
DECLARE
v_admin_role_id bigint;
BEGIN
SELECT id INTO v_admin_role_id
FROM iam.roles
WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL
LIMIT 1;
IF v_admin_role_id IS NOT NULL THEN
EXECUTE format(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_users_one_tenant_admin_per_tenant ON iam.users (tenant_id) WHERE role_id = %s',
v_admin_role_id
);
END IF;
END $$;
--changeset panel:iam-005i-user-projects endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='user_projects'
CREATE TABLE iam.user_projects (
user_id BIGINT NOT NULL REFERENCES iam.users(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL,
PRIMARY KEY (user_id, project_id)
);
CREATE INDEX idx_iam_user_projects_user ON iam.user_projects(user_id);
--changeset panel:iam-005j-rls endDelimiter:; splitStatements:true
ALTER TABLE iam.roles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_and_system_roles ON iam.roles;
CREATE POLICY tenant_and_system_roles ON iam.roles
USING (
(tenant_id IS NOT NULL AND tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
OR (tenant_id IS NULL AND is_system)
)
WITH CHECK (
tenant_id IS NOT NULL
AND tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
AND NOT is_system
);
ALTER TABLE iam.role_permissions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_role_permissions ON iam.role_permissions;
CREATE POLICY tenant_role_permissions ON iam.role_permissions
USING (
EXISTS (
SELECT 1 FROM iam.roles r
WHERE r.id = role_permissions.role_id
AND (
(r.tenant_id IS NOT NULL AND r.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
OR (r.tenant_id IS NULL AND r.is_system)
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM iam.roles r
WHERE r.id = role_permissions.role_id
AND r.tenant_id IS NOT NULL
AND r.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
AND NOT r.is_system
)
);
ALTER TABLE iam.user_projects ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_user_projects ON iam.user_projects;
CREATE POLICY tenant_user_projects ON iam.user_projects
USING (
EXISTS (
SELECT 1 FROM iam.users u
WHERE u.id = user_projects.user_id
AND u.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM iam.users u
WHERE u.id = user_projects.user_id
AND u.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
)
);
--changeset panel:iam-005k-fn-permission-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_permission_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'code', p.code,
'label', p.label,
'module', p.module,
'verb', p.verb,
'group', p.perm_group
) ORDER BY p.module, p.code
), '[]'::jsonb) INTO v_rows
FROM permissions p
WHERE p.module IS NOT NULL;
RETURN core.rpc_ok(
jsonb_build_object('permissions', v_rows),
format('Catálogo de permisos v2: %s', jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_permission_list')
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_permission_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005l-fn-role-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', r.id,
'code', r.code,
'label', r.label,
'tenant_id', r.tenant_id,
'is_system', r.is_system,
'is_editable', NOT r.is_system,
'created_at', r.created_at
) ORDER BY r.is_system DESC, r.label
), '[]'::jsonb) INTO v_rows
FROM roles r
WHERE (r.tenant_id = v_tid) OR (r.tenant_id IS NULL AND r.is_system);
RETURN core.rpc_ok(
jsonb_build_object('roles', v_rows),
format('Roles del tenant %s: %s', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_role_list', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005m-fn-role-create splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_code text := lower(nullif(btrim(payload->>'code'), ''));
v_label text := nullif(btrim(payload->>'label'), '');
v_new_id bigint;
v_role jsonb;
BEGIN
IF v_tid IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_create: tenant_id es obligatorio',
jsonb_build_object('fn', 'fn_role_create'));
END IF;
IF v_code IS NULL OR v_code !~ '^[a-z][a-z0-9_]{1,63}$' THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_create: code inválido (minúsculas, números, _, 2-64 chars)',
jsonb_build_object('fn', 'fn_role_create', 'field', 'code'));
END IF;
IF v_label IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_create: label es obligatorio',
jsonb_build_object('fn', 'fn_role_create', 'field', 'label'));
END IF;
IF v_code IN ('tenant_admin', 'platform_admin', 'user', 'operativo') THEN
RETURN core.rpc_err('VALIDATION', format('fn_role_create: el código %s está reservado', v_code),
jsonb_build_object('fn', 'fn_role_create', 'code', v_code));
END IF;
INSERT INTO roles (tenant_id, code, label, is_system)
VALUES (v_tid, v_code, v_label, false)
RETURNING id INTO v_new_id;
SELECT to_jsonb(r) INTO v_role FROM roles r WHERE r.id = v_new_id;
RETURN core.rpc_created(
jsonb_build_object('role', v_role),
format('Rol %s creado (id=%s)', v_code, v_new_id),
jsonb_build_object('fn', 'fn_role_create', 'role_id', v_new_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('fn_role_create: ya existe el rol %s en el tenant', v_code),
jsonb_build_object('fn', 'fn_role_create', 'code', v_code, 'tenant_id', v_tid));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005n-fn-role-update splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_label text := nullif(btrim(payload->>'label'), '');
v_role record;
v_updated jsonb;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_update: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_update', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_update: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id));
END IF;
IF v_role.is_system OR v_role.tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_update: no se puede modificar un rol del sistema',
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_role_update: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id, 'tenant_id', v_tid));
END IF;
IF v_label IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_update: label es obligatorio',
jsonb_build_object('fn', 'fn_role_update', 'field', 'label'));
END IF;
UPDATE roles SET label = v_label WHERE id = v_role_id;
SELECT to_jsonb(r) INTO v_updated FROM roles r WHERE r.id = v_role_id;
RETURN core.rpc_ok(
jsonb_build_object('role', v_updated),
format('Rol %s actualizado (id=%s)', v_updated->>'code', v_role_id),
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005o-fn-role-delete splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_delete(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_role record;
v_assigned integer;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_delete: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_delete', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_delete: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id));
END IF;
IF v_role.is_system OR v_role.tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_delete: no se puede eliminar un rol del sistema',
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_role_delete: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id, 'tenant_id', v_tid));
END IF;
SELECT COUNT(*)::integer INTO v_assigned FROM users WHERE role_id = v_role_id;
IF v_assigned > 0 THEN
RETURN core.rpc_err('CONFLICT',
format('fn_role_delete: rol %s tiene %s usuario(s) asignado(s)', v_role.code, v_assigned),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id, 'assigned_users', v_assigned));
END IF;
DELETE FROM roles WHERE id = v_role_id;
RETURN core.rpc_ok(
jsonb_build_object('role_id', v_role_id, 'code', v_role.code),
format('Rol %s eliminado (id=%s)', v_role.code, v_role_id),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_delete', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005p-fn-role-permissions-get splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_role record;
v_rows jsonb;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_get: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_permissions_get', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_permissions_get: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_permissions_get', 'role_id', v_role_id));
END IF;
SELECT COALESCE(jsonb_agg(rp.permission_code ORDER BY rp.permission_code), '[]'::jsonb) INTO v_rows
FROM role_permissions rp WHERE rp.role_id = v_role_id;
RETURN core.rpc_ok(
jsonb_build_object(
'role_id', v_role_id,
'role_code', v_role.code,
'is_editable', NOT v_role.is_system,
'permissions', v_rows
),
format('Permisos del rol %s: %s', v_role.code, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_role_permissions_get', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_permissions_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005q-fn-role-permissions-save splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_perms jsonb := COALESCE(payload->'permissions', '[]'::jsonb);
v_role record;
v_perm text;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_permissions_save', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_permissions_save: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id));
END IF;
IF v_role.is_system AND v_role.code = 'tenant_admin' THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar tenant_admin',
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id));
END IF;
IF v_role.is_system OR v_role.tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar un rol del sistema',
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id));
END IF;
DELETE FROM role_permissions WHERE role_id = v_role_id;
FOR v_perm IN SELECT jsonb_array_elements_text(v_perms)
LOOP
INSERT INTO role_permissions (role_id, permission_code)
VALUES (v_role_id, v_perm)
ON CONFLICT DO NOTHING;
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('role_id', v_role_id, 'role_code', v_role.code, 'permissions', v_perms),
format('Permisos del rol %s actualizados (%s permiso(s))', v_role.code, jsonb_array_length(v_perms)),
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_permissions_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005r-fn-user-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(row_data ORDER BY row_data->>'display_name'), '[]'::jsonb) INTO v_rows
FROM (
SELECT jsonb_build_object(
'id', u.id,
'username', u.username,
'display_name', u.display_name,
'email', u.email,
'company_id', u.company_id,
'tenant_id', u.tenant_id,
'role_id', u.role_id,
'role_code', r.code,
'role_label', r.label,
'status', u.status,
'scope_all_projects', u.scope_all_projects,
'warehouse_central', u.warehouse_central,
'warehouse_projects', u.warehouse_projects,
'project_ids', COALESCE((
SELECT jsonb_agg(up.project_id ORDER BY up.project_id)
FROM user_projects up WHERE up.user_id = u.id
), '[]'::jsonb),
'is_owner', (r.code = 'tenant_admin' AND r.is_system AND r.tenant_id IS NULL),
'must_change_password', u.must_change_password,
'created_at', u.created_at
) AS row_data
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.tenant_id = v_tid
) sub;
RETURN core.rpc_ok(
jsonb_build_object('users', v_rows),
format('Usuarios del tenant %s: %s cuenta(s)', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_user_list', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005s-fn-user-create splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_username text := lower(nullif(btrim(payload->>'username'), ''));
v_password_hash text := nullif(btrim(payload->>'password_hash'), '');
v_display_name text := nullif(btrim(payload->>'display_name'), '');
v_email text := COALESCE(nullif(btrim(payload->>'email'), ''), '');
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_company_id integer := NULLIF(payload->>'company_id', '')::integer;
v_status text := COALESCE(nullif(btrim(payload->>'status'), ''), 'activo');
v_scope_all boolean := COALESCE((payload->>'scope_all_projects')::boolean, false);
v_wh_central boolean := COALESCE((payload->>'warehouse_central')::boolean, false);
v_wh_projects boolean := COALESCE((payload->>'warehouse_projects')::boolean, true);
v_role record;
v_new_id bigint;
v_user jsonb;
BEGIN
IF v_tid IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: tenant_id es obligatorio',
jsonb_build_object('fn', 'fn_user_create'));
END IF;
IF v_username IS NULL OR v_username !~ '^[a-z0-9._-]{3,64}$' THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: username inválido',
jsonb_build_object('fn', 'fn_user_create', 'field', 'username'));
END IF;
IF v_password_hash IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: password_hash es obligatorio',
jsonb_build_object('fn', 'fn_user_create', 'field', 'password_hash'));
END IF;
IF v_display_name IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: display_name es obligatorio',
jsonb_build_object('fn', 'fn_user_create', 'field', 'display_name'));
END IF;
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: role_id es obligatorio',
jsonb_build_object('fn', 'fn_user_create', 'field', 'role_id'));
END IF;
IF v_status NOT IN ('activo', 'baja') THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: status debe ser activo o baja',
jsonb_build_object('fn', 'fn_user_create', 'field', 'status'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_create: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id));
END IF;
IF v_role.code = 'tenant_admin' AND v_role.is_system THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: use bootstrap para crear tenant_admin',
jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id IS NOT NULL AND v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_create: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id));
END IF;
INSERT INTO users (
username, password_hash, display_name, email, company_id, tenant_id, role_id,
status, scope_all_projects, warehouse_central, warehouse_projects, must_change_password
) VALUES (
v_username, v_password_hash, v_display_name, v_email, v_company_id, v_tid, v_role_id,
v_status, v_scope_all, v_wh_central, v_wh_projects, COALESCE((payload->>'must_change_password')::boolean, true)
)
RETURNING id INTO v_new_id;
SELECT to_jsonb(u) INTO v_user FROM users u WHERE u.id = v_new_id;
RETURN core.rpc_created(
jsonb_build_object('user', v_user),
format('Usuario %s creado (id=%s)', v_username, v_new_id),
jsonb_build_object('fn', 'fn_user_create', 'user_id', v_new_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('fn_user_create: ya existe el username %s', v_username),
jsonb_build_object('fn', 'fn_user_create', 'username', v_username));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005t-fn-user-update splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_user_id bigint := NULLIF(payload->>'id', '')::bigint;
v_user record;
v_role record;
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_updated jsonb;
BEGIN
IF v_user_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_update: id es obligatorio',
jsonb_build_object('fn', 'fn_user_update', 'field', 'id'));
END IF;
SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id
INTO v_user
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = v_user_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_update: usuario id=%s no encontrado', v_user_id),
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id));
END IF;
IF v_user.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_update: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid),
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id));
END IF;
IF v_user.role_code = 'tenant_admin' AND v_user.role_is_system AND v_user.role_tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_update: no se puede modificar el propietario del tenant',
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id));
END IF;
IF v_role_id IS NOT NULL THEN
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_update: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id));
END IF;
IF v_role.code = 'tenant_admin' AND v_role.is_system THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_update: no se puede asignar rol tenant_admin',
jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id IS NOT NULL AND v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_update: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id));
END IF;
END IF;
UPDATE users SET
display_name = COALESCE(nullif(btrim(payload->>'display_name'), ''), display_name),
email = COALESCE(nullif(btrim(payload->>'email'), ''), email),
company_id = COALESCE(NULLIF(payload->>'company_id', '')::integer, company_id),
role_id = COALESCE(v_role_id, role_id),
status = COALESCE(nullif(btrim(payload->>'status'), ''), status),
scope_all_projects = COALESCE((payload->>'scope_all_projects')::boolean, scope_all_projects),
warehouse_central = COALESCE((payload->>'warehouse_central')::boolean, warehouse_central),
warehouse_projects = COALESCE((payload->>'warehouse_projects')::boolean, warehouse_projects)
WHERE id = v_user_id;
SELECT to_jsonb(u) INTO v_updated FROM users u WHERE u.id = v_user_id;
RETURN core.rpc_ok(
jsonb_build_object('user', v_updated),
format('Usuario id=%s actualizado', v_user_id),
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005u-fn-user-reset-access splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_reset_access(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_user_id bigint := NULLIF(payload->>'user_id', '')::bigint;
v_password_hash text := nullif(btrim(payload->>'password_hash'), '');
v_user record;
BEGIN
IF v_user_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_reset_access: user_id es obligatorio',
jsonb_build_object('fn', 'fn_user_reset_access', 'field', 'user_id'));
END IF;
IF v_password_hash IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_reset_access: password_hash es obligatorio',
jsonb_build_object('fn', 'fn_user_reset_access', 'field', 'password_hash'));
END IF;
SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id
INTO v_user
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = v_user_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_reset_access: usuario id=%s no encontrado', v_user_id),
jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id));
END IF;
IF v_user.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_reset_access: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid),
jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id));
END IF;
UPDATE users
SET password_hash = v_password_hash,
must_change_password = COALESCE((payload->>'must_change_password')::boolean, true)
WHERE id = v_user_id;
RETURN core.rpc_ok(
jsonb_build_object('user_id', v_user_id),
format('Acceso restablecido para usuario id=%s', v_user_id),
jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_reset_access', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005v-fn-user-projects-save splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_projects_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_user_id bigint := NULLIF(payload->>'user_id', '')::bigint;
v_project_ids jsonb := COALESCE(payload->'project_ids', '[]'::jsonb);
v_user record;
v_pid integer;
BEGIN
IF v_user_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_projects_save: user_id es obligatorio',
jsonb_build_object('fn', 'fn_user_projects_save', 'field', 'user_id'));
END IF;
SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id
INTO v_user
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = v_user_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_projects_save: usuario id=%s no encontrado', v_user_id),
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id));
END IF;
IF v_user.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_projects_save: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid),
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id));
END IF;
IF v_user.role_code = 'tenant_admin' AND v_user.role_is_system AND v_user.role_tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_projects_save: no se puede modificar proyectos del propietario',
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id));
END IF;
DELETE FROM user_projects WHERE user_id = v_user_id;
FOR v_pid IN
SELECT DISTINCT (elem)::integer
FROM jsonb_array_elements_text(v_project_ids) AS elem
WHERE btrim(elem) ~ '^\d+$'
LOOP
INSERT INTO user_projects (user_id, project_id) VALUES (v_user_id, v_pid);
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('user_id', v_user_id, 'project_ids', v_project_ids),
format('Proyectos del usuario id=%s actualizados (%s)', v_user_id, jsonb_array_length(v_project_ids)),
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_projects_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005w-iam-v2-rpc-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION iam.fn_permission_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_create(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_update(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_delete(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_get(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_save(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_create(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_update(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_reset_access(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_projects_save(jsonb) TO panels_iam_app;

View file

@ -1,110 +0,0 @@
<template>
<div v-if="detail.kind === 'iam-user' && detail.iamUser" class="iam-detail">
<div class="section-title">Usuario</div>
<p><strong>{{ detail.iamUser.display_name }}</strong></p>
<p class="muted">{{ detail.iamUser.username }}</p>
<p>Rol: {{ detail.iamUser.role_label || detail.iamUser.role_code }}</p>
<p>Estado: {{ detail.iamUser.status }}</p>
<p v-if="detail.iamUser.is_owner" class="owner-badge">Cuenta propietaria (solo SaaS)</p>
<template v-if="!detail.iamUser.is_owner">
<div class="section-title mt-3">Alcance</div>
<p v-if="detail.iamUser.scope_all_projects">Todas las obras</p>
<p v-else>Obras asignadas: {{ (detail.iamUser.project_ids ?? []).length }}</p>
<p>Central: {{ detail.iamUser.warehouse_central ? "Sí" : "No" }}</p>
<p>Obras: {{ detail.iamUser.warehouse_projects !== false ? "Sí" : "No" }}</p>
</template>
</div>
<div v-else-if="detail.kind === 'iam-role' && detail.iamRole" class="iam-detail">
<div class="section-title">Matriz del rol</div>
<p><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>
<div v-else class="perm-grid mt-3">
<div v-for="perm in groupedPermissions" :key="perm.code" class="perm-item">
<Checkbox
v-model="localPerms"
:input-id="perm.code"
:value="perm.code"
:disabled="!canEdit"
/>
<label :for="perm.code">{{ perm.label }}</label>
</div>
</div>
<Button
v-if="canEdit"
label="Guardar permisos"
icon="pi pi-save"
class="mt-3 w-full"
size="small"
@click="savePerms"
/>
</div>
</template>
<script setup lang="ts">
import { displayErrorMessage } from "~/composables/api-response";
type PermRow = { code: string; label: string; module?: string; group?: string };
const props = defineProps<{
detail: {
kind: string;
iamUser?: Record<string, unknown>;
iamRole?: Record<string, unknown> & { id?: number; is_system?: boolean };
};
}>();
const { api } = useApi();
const toast = useToast();
const perms = usePermissions();
const catalog = ref<PermRow[]>([]);
const localPerms = ref<string[]>([]);
const canEdit = computed(() =>
perms.can("users.update") && props.detail.kind === "iam-role" && !props.detail.iamRole?.is_system,
);
const groupedPermissions = computed(() => catalog.value);
async function loadCatalog() {
const r = await api<{ permissions?: PermRow[] }>("/v1/iam/permissions");
catalog.value = r.permissions ?? [];
}
async function loadRolePerms(roleId: number) {
const r = await api<{ permissions?: string[] }>(`/v1/iam/roles/${roleId}/permissions`);
localPerms.value = r.permissions ?? [];
}
async function savePerms() {
const roleId = props.detail.iamRole?.id;
if (!roleId) return;
try {
await api(`/v1/iam/roles/${roleId}/permissions`, {
method: "PUT",
json: { permissions: localPerms.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 });
}
}
watch(
() => props.detail,
async (d) => {
if (!catalog.value.length) await loadCatalog();
if (d.kind === "iam-role" && d.iamRole?.id) await loadRolePerms(Number(d.iamRole.id));
},
{ immediate: true, deep: true },
);
</script>
<style scoped>
.iam-detail { font-size: 13px; }
.section-title { font-weight: 600; margin-bottom: 4px; }
.muted { color: var(--text-color-secondary); }
.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>

View file

@ -48,10 +48,7 @@ export const useAuth = () => {
company_name?: string | null;
company_kind?: string | null;
tenant_id?: number | null;
role_id?: number | null;
role_code?: string;
is_owner?: boolean;
status?: string;
role?: "platform_admin" | "tenant_admin" | "user";
realm?: "app" | "platform";
must_change_password?: boolean;
} | null>("auth-user", () => null);

View file

@ -16,14 +16,12 @@ export type RibbonGroup = {
};
export type DeskDetail = {
kind: "none" | "worker" | "project" | "budget" | "iam-user" | "iam-role";
kind: "none" | "worker" | "project" | "budget";
id?: number;
doc?: string;
tab?: string;
project?: Record<string, unknown>;
budgetItem?: Record<string, unknown>;
iamUser?: Record<string, unknown>;
iamRole?: Record<string, unknown>;
};
export type DeskStatusMessage = {
@ -116,16 +114,6 @@ export function useDesktop() {
showDetail.value = true;
}
function openIamUser(row: Record<string, unknown> & { id: number }) {
detail.value = { kind: "iam-user", id: row.id, iamUser: row };
showDetail.value = true;
}
function openIamRole(row: Record<string, unknown> & { id: number }) {
detail.value = { kind: "iam-role", id: row.id, iamRole: row };
showDetail.value = true;
}
function clearDetail() {
detail.value = { kind: "none" };
}
@ -167,8 +155,6 @@ export function useDesktop() {
openWorker,
openProject,
openBudgetItem,
openIamUser,
openIamRole,
clearDetail,
bumpDetail,
loanPrompt,

View file

@ -1,39 +0,0 @@
export function usePermissions() {
const permissions = useState<string[]>("user-permissions", () => []);
const isOwner = useState("user-permissions-owner", () => false);
const loaded = useState("user-permissions-loaded", () => false);
const { api } = useApi();
async function load() {
try {
const r = await api<{
permissions?: string[];
is_owner?: boolean;
}>("/v1/me/permissions");
permissions.value = r.permissions ?? [];
isOwner.value = Boolean(r.is_owner);
loaded.value = true;
} catch {
permissions.value = [];
isOwner.value = false;
loaded.value = true;
}
}
function can(code: string): boolean {
if (isOwner.value) return true;
return permissions.value.includes(code);
}
function canAny(...codes: string[]): boolean {
return codes.some((c) => can(c));
}
function canModuleView(module: string): boolean {
return can(`${module}.view`) || canAny(
...permissions.value.filter((p) => p.startsWith(`${module}.`) && p.endsWith(".view")),
);
}
return { permissions, isOwner, loaded, load, can, canAny, canModuleView };
}

View file

@ -1,35 +0,0 @@
export type UserScopePayload = {
all_projects: boolean;
project_ids: number[];
warehouse_central: boolean;
warehouse_projects: boolean;
};
export function useScope() {
const scope = useState<UserScopePayload | null>("user-scope", () => null);
const loaded = useState("user-scope-loaded", () => false);
const { api } = useApi();
async function load() {
try {
scope.value = await api<UserScopePayload>("/v1/me/scope");
loaded.value = true;
} catch {
scope.value = {
all_projects: false,
project_ids: [],
warehouse_central: false,
warehouse_projects: false,
};
loaded.value = true;
}
}
function canAccessProject(projectId: number): boolean {
if (!scope.value) return true;
if (scope.value.all_projects) return true;
return scope.value.project_ids.includes(projectId);
}
return { scope, loaded, load, canAccessProject };
}

View file

@ -90,7 +90,6 @@
:item="detail.budgetItem"
@edit="editFromFicha"
/>
<IamDetail v-else-if="detail.kind === 'iam-user' || detail.kind === 'iam-role'" :detail="detail" />
<div v-else class="detail-empty">
<i class="pi pi-file detail-empty-icon" />
<strong>Seleccione un registro</strong>
@ -115,10 +114,8 @@
<script setup lang="ts">
import LoanDialog from "~/components/padron/LoanDialog.vue";
import IamDetail from "~/components/IamDetail.vue";
const { user, logout } = useAuth();
const perms = usePermissions();
const { initials } = useUi();
const route = useRoute();
const desk = useDesktop();
@ -164,43 +161,42 @@ function nav(label: string, icon: string, to: string): NavItem {
return { label, icon, to, command: () => navigateTo(to) };
}
function navIf(label: string, icon: string, to: string, permission: string): NavItem | null {
if (!perms.can(permission)) return null;
return nav(label, icon, to);
}
const menu = computed<NavItem[]>(() => {
const items: NavItem[] = [];
const home = navIf("Inicio", "pi pi-home", "/", "reports.view");
if (home) items.push(home);
const obraItems = [
navIf("Proyectos", "pi pi-building", "/proyectos", "projects.view"),
navIf("Presupuesto", "pi pi-calculator", "/presupuesto", "budget.view"),
navIf("Programa de obra", "pi pi-calendar", "/programa-obra", "work_program.view"),
navIf("Control costos", "pi pi-chart-bar", "/control-presupuesto", "cost_control.view"),
].filter(Boolean) as NavItem[];
if (obraItems.length) items.push({ label: "Obra", icon: "pi pi-building", items: obraItems });
const opItems = [
navIf("Gastos", "pi pi-money-bill", "/gastos", "expenses.view"),
navIf("Almacén", "pi pi-box", "/almacen", "warehouse.view"),
navIf("Kanban", "pi pi-th-large", "/kanban", "kanban.view"),
].filter(Boolean) as NavItem[];
if (opItems.length) items.push({ label: "Operación", icon: "pi pi-briefcase", items: opItems });
const personalItems = [
navIf("Padrón", "pi pi-users", "/padron", "workers.view"),
navIf("Gafetes", "pi pi-id-card", "/gafetes", "documents.view"),
navIf("Nómina", "pi pi-wallet", "/nomina", "payroll.view"),
].filter(Boolean) as NavItem[];
if (personalItems.length) items.push({ label: "Personal", icon: "pi pi-users", items: personalItems });
const adminItems = [navIf("Usuarios", "pi pi-shield", "/usuarios", "users.view")].filter(Boolean) as NavItem[];
if (adminItems.length) items.push({ label: "Administración", icon: "pi pi-shield", items: adminItems });
return items;
});
const menu: NavItem[] = [
nav("Inicio", "pi pi-home", "/"),
{
label: "Obra",
icon: "pi pi-building",
items: [
nav("Proyectos", "pi pi-building", "/proyectos"),
nav("Presupuesto", "pi pi-calculator", "/presupuesto"),
nav("Programa de obra", "pi pi-calendar", "/programa-obra"),
nav("Control costos", "pi pi-chart-bar", "/control-presupuesto"),
],
},
{
label: "Operación",
icon: "pi pi-briefcase",
items: [
nav("Gastos", "pi pi-money-bill", "/gastos"),
nav("Almacén", "pi pi-box", "/almacen"),
nav("Kanban", "pi pi-th-large", "/kanban"),
],
},
{
label: "Personal",
icon: "pi pi-users",
items: [
nav("Padrón", "pi pi-users", "/padron"),
nav("Gafetes", "pi pi-id-card", "/gafetes"),
nav("Nómina", "pi pi-wallet", "/nomina"),
],
},
{
label: "Administración",
icon: "pi pi-shield",
items: [nav("Usuarios", "pi pi-shield", "/usuarios")],
},
];
function isActive(to: string) {
if (!to) return false;
@ -260,7 +256,6 @@ watch(
if (!workerOk && desk.detail.value.kind === "worker") desk.clearDetail();
if (!path.startsWith("/proyectos") && desk.detail.value.kind === "project") desk.clearDetail();
if (!path.startsWith("/presupuesto") && desk.detail.value.kind === "budget") desk.clearDetail();
if (!path.startsWith("/usuarios") && (desk.detail.value.kind === "iam-user" || desk.detail.value.kind === "iam-role")) desk.clearDetail();
},
);
</script>

View file

@ -1,20 +1,3 @@
const ROUTE_PERMISSIONS: Record<string, string> = {
"/": "reports.view",
"/proyectos": "projects.view",
"/obras": "projects.view",
"/presupuesto": "budget.view",
"/programa-obra": "work_program.view",
"/control-presupuesto": "cost_control.view",
"/gastos": "expenses.view",
"/almacen": "warehouse.view",
"/kanban": "kanban.view",
"/padron": "workers.view",
"/gafetes": "documents.view",
"/nomina": "payroll.view",
"/configuracion": "settings.view",
"/usuarios": "users.view",
};
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === "/login") return;
const { user, fetchMe } = useAuth();
@ -31,7 +14,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
if (!user.value) return navigateTo("/login");
if (user.value.role_code === "platform_admin" && user.value.realm === "platform") {
if (user.value.role === "platform_admin" && user.value.realm === "platform") {
user.value = null;
try {
await api("/v1/auth/logout", { method: "POST" });
@ -51,17 +34,4 @@ export default defineNuxtRouteMiddleware(async (to) => {
if (!user.value.must_change_password && to.path === "/cambiar-password") {
return navigateTo("/");
}
const perms = usePermissions();
const userScope = useScope();
if (!perms.loaded.value) await perms.load();
if (!userScope.loaded.value) await userScope.load();
const required = Object.entries(ROUTE_PERMISSIONS).find(([path]) =>
path === "/" ? to.path === "/" : to.path === path || to.path.startsWith(`${path}/`),
)?.[1];
if (required && !perms.can(required)) {
return navigateTo("/");
}
});

View file

@ -281,7 +281,7 @@ async function onSubmit() {
await login(username.value.trim(), password.value.trim());
const { user } = useAuth();
const { api } = useApi();
if (user.value?.role_code === "platform_admin" && user.value?.realm === "platform") {
if (user.value?.role === "platform_admin" && user.value?.realm === "platform") {
user.value = null;
try {
await api("/v1/auth/logout", { method: "POST" });

View file

@ -73,7 +73,7 @@
:drill-concepts="ganttConcepts"
/>
</TabPanel>
<TabPanel v-if="showCostTab" header="Programado vs costos">
<TabPanel header="Programado vs costos">
<WorkProgramCostCompare :by-period="vsCostPeriods" :by-chapter="vsCostChapters" />
</TabPanel>
</TabView>
@ -115,8 +115,6 @@ const desk = useDesktop();
const route = useRoute();
const { api, download } = useApi();
const toast = useToast();
const perms = usePermissions();
const showCostTab = computed(() => perms.can("cost_control.view"));
const projects = ref<Project[]>([]);
const loadedProjectId = ref(0);

View file

@ -1,521 +1,78 @@
<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 v-model="tableQ" placeholder="Buscar usuario">
<Chip :label="`${users.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" />
<DataTable :value="listed" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
<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="role_code" header="Rol" style="width: 9rem" />
<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>
<Panel header="Permisos del rol operativo (user)" class="mt-4">
<div class="perm-grid">
<div v-for="perm in permissions" :key="perm.code" class="perm-item">
<Checkbox v-model="rolePerms" :input-id="perm.code" :value="perm.code" />
<label :for="perm.code">{{ perm.label }}</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>
<Button label="Guardar permisos" icon="pi pi-save" class="mt-3" @click="saveRolePerms" />
</Panel>
</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 };
type UserRow = { id: number; username: string; display_name: string; role_code: string; email: string };
type PermRow = { code: string; label: 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 listed = 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));
return users.value.filter((u) => !q || [u.username, u.display_name, u.email].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[] })),
const [u, p, rp] = await Promise.all([
api<{ data?: { users?: UserRow[] } }>("/v1/iam/users"),
api<{ data?: { permissions?: PermRow[] } }>("/v1/iam/permissions"),
api<{ data?: { permissions?: string[] } }>("/v1/iam/roles/user/permissions"),
]);
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;
}
users.value = u.data?.users ?? [];
permissions.value = p.data?.permissions ?? [];
rolePerms.value = rp.data?.permissions ?? [];
}
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`, {
const r = await api<{ message?: string }>("/v1/iam/roles/user/permissions", {
method: "PUT",
json: { permissions: rolePerms.value },
});
toast.add({ severity: "success", summary: "Permisos guardados", life: 2500 });
toast.add({ severity: "success", summary: r.message || "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";
desk.configure({ tab: "Usuarios IAM", ribbon: [] });
await load();
});
</script>
<style scoped>
.form-grid { display: flex; flex-direction: column; gap: 12px; }
.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; }
.perm-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
</style>