mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 18:03:18 +00:00
Compare commits
3 commits
7bcef22d59
...
0d660c4549
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d660c4549 | ||
|
|
aed2f4531b | ||
|
|
25ceb6a6bd |
31 changed files with 2872 additions and 323 deletions
59
api/auth.ts
59
api/auth.ts
|
|
@ -7,7 +7,6 @@ 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;
|
||||
|
|
@ -18,7 +17,10 @@ export type AuthUser = {
|
|||
company_name: string | null;
|
||||
company_kind: string | null;
|
||||
tenant_id: number | null;
|
||||
role: AuthRole;
|
||||
role_id: number | null;
|
||||
role_code: string;
|
||||
is_owner: boolean;
|
||||
status: string;
|
||||
realm: AuthRealm;
|
||||
must_change_password: boolean;
|
||||
};
|
||||
|
|
@ -26,6 +28,14 @@ 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,
|
||||
|
|
@ -48,8 +58,13 @@ 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),
|
||||
|
|
@ -59,7 +74,10 @@ 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: role === "tenant_admin" ? "tenant_admin" : "user",
|
||||
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"),
|
||||
realm: "app",
|
||||
must_change_password: Boolean(row.must_change_password),
|
||||
};
|
||||
|
|
@ -75,7 +93,10 @@ function asPlatformUser(row: { id: number; username: string; display_name: strin
|
|||
company_name: null,
|
||||
company_kind: null,
|
||||
tenant_id: null,
|
||||
role: "platform_admin",
|
||||
role_id: null,
|
||||
role_code: "platform_admin",
|
||||
is_owner: true,
|
||||
status: "activo",
|
||||
realm: "platform",
|
||||
must_change_password: false,
|
||||
};
|
||||
|
|
@ -103,12 +124,10 @@ 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(
|
||||
`SELECT id, username, password_hash, display_name, company_id, tenant_id, role_code,
|
||||
must_change_password, email FROM users WHERE id = ?`,
|
||||
).get(userId);
|
||||
return await db.prepare(USER_SELECT).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 });
|
||||
}
|
||||
|
|
@ -124,10 +143,6 @@ 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) {
|
||||
|
|
@ -142,7 +157,10 @@ export async function requireAuth(c: Context, next: Next) {
|
|||
company_name: null,
|
||||
company_kind: null,
|
||||
tenant_id: tenantId,
|
||||
role: "tenant_admin",
|
||||
role_id: null,
|
||||
role_code: "api_key",
|
||||
is_owner: false,
|
||||
status: "activo",
|
||||
realm: "app",
|
||||
must_change_password: false,
|
||||
} satisfies AuthUser);
|
||||
|
|
@ -158,7 +176,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 !== "platform_admin") {
|
||||
if (user.realm !== "platform" || user.role_code !== "platform_admin") {
|
||||
return c.json({ error: "Solo administradores SaaS" }, 403);
|
||||
}
|
||||
c.set("user", user);
|
||||
|
|
@ -175,14 +193,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 (db) => {
|
||||
async () => {
|
||||
const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null);
|
||||
return asAppUser({ ...appRow, ...company });
|
||||
},
|
||||
|
|
@ -248,9 +266,6 @@ 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 {};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { requireCoreAuth } from "./scope.ts";
|
|||
import type { Db } from "./db.ts";
|
||||
import { callCoreFn } from "./rpc.ts";
|
||||
import { respondRpc, routeLabel } from "./http_errors.ts";
|
||||
import { requireAnyPermission } from "./permissions.ts";
|
||||
import { requirePermission } from "./permissions.ts";
|
||||
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
|
||||
|
||||
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||
|
||||
|
|
@ -14,38 +15,48 @@ function tid(c: { get: (k: "user") => AuthUser }): number {
|
|||
}
|
||||
|
||||
export function registerCostControlRoutes(app: App) {
|
||||
app.get("/v1/projects/:id/cost-control/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn(db, "core.fn_cost_control_project_summary", {
|
||||
tenant_id: tid(c),
|
||||
project_id: Number(c.req.param("id")),
|
||||
project_id: projectId,
|
||||
}, { route: routeLabel(c) });
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/cost-control/items", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||
app.get("/v1/projects/:id/cost-control/items", ...auth, async (c) => {
|
||||
const projectId = Number(c.req.param("id"));
|
||||
if (!await denyUnlessProjectScope(c, projectId)) return;
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn(db, "core.fn_cost_control_by_item", {
|
||||
tenant_id: tid(c),
|
||||
project_id: Number(c.req.param("id")),
|
||||
project_id: projectId,
|
||||
}, { route: routeLabel(c) });
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/cost-control/chapters", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||
app.get("/v1/projects/:id/cost-control/chapters", ...auth, async (c) => {
|
||||
const projectId = Number(c.req.param("id"));
|
||||
if (!await denyUnlessProjectScope(c, projectId)) return;
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn(db, "core.fn_cost_control_by_chapter", {
|
||||
tenant_id: tid(c),
|
||||
project_id: Number(c.req.param("id")),
|
||||
project_id: projectId,
|
||||
}, { route: routeLabel(c) });
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/cost-control/deviations", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||
app.get("/v1/projects/:id/cost-control/deviations", ...auth, async (c) => {
|
||||
const projectId = Number(c.req.param("id"));
|
||||
if (!await denyUnlessProjectScope(c, projectId)) return;
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn(db, "core.fn_cost_control_top_deviations", {
|
||||
tenant_id: tid(c),
|
||||
project_id: Number(c.req.param("id")),
|
||||
project_id: projectId,
|
||||
limit: c.req.query("limit"),
|
||||
}, { route: routeLabel(c) });
|
||||
return respondRpc(c, env);
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ Deno.test("RPC control presupuestal: summary, items, chapters, deviations", asyn
|
|||
});
|
||||
});
|
||||
|
||||
Deno.test("RPC IAM: usuarios y permisos", async () => {
|
||||
Deno.test("RPC IAM v2: roles y permisos por role_id", 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,16 +254,21 @@ Deno.test("RPC IAM: usuarios y permisos", 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_code: "user",
|
||||
role_id: roleRow.id,
|
||||
}) 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_code: "user",
|
||||
permissions: ["view_expenses", "view_warehouse", "manage_workers", "manage_documents", "view_reports"],
|
||||
role_id: roleRow.id,
|
||||
permissions: ["workers.view", "warehouse.view", "documents.view", "reports.view"],
|
||||
}) as { result: { ok: boolean } };
|
||||
assertEquals(roleSave.result.ok, true);
|
||||
}
|
||||
|
||||
throw ROLLBACK;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import { tenantScope } from "./auth.ts";
|
|||
import { requireCoreAuth } from "./scope.ts";
|
||||
import type { Db } from "./db.ts";
|
||||
import { callCoreFn } from "./rpc.ts";
|
||||
import { respondRpc, routeLabel } from "./http_errors.ts";
|
||||
import { requireAnyPermission, requirePermission } from "./permissions.ts";
|
||||
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
|
||||
import { 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 } }>;
|
||||
|
|
@ -22,11 +23,13 @@ function actor(c: Context): { id: number; name: string } {
|
|||
}
|
||||
|
||||
export function registerExpenseRoutes(app: App) {
|
||||
app.get("/v1/expenses", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn(db, "core.fn_expense_list", {
|
||||
tenant_id: tid(c),
|
||||
project_id: c.req.query("project_id"),
|
||||
project_id: projectId,
|
||||
company_id: c.req.query("company_id"),
|
||||
date_from: c.req.query("date_from"),
|
||||
date_to: c.req.query("date_to"),
|
||||
|
|
@ -36,14 +39,20 @@ export function registerExpenseRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/expenses/:id", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||
app.get("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.view"), 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("manage_expenses"), async (c) => {
|
||||
app.post("/v1/expenses", ...requireCoreAuth, requirePermission("expenses.create"), 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", {
|
||||
|
|
@ -55,9 +64,14 @@ export function registerExpenseRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
|
||||
app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.update"), 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")),
|
||||
|
|
@ -66,18 +80,29 @@ export function registerExpenseRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
|
||||
app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.delete"), 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("manage_expenses"), async (c) => {
|
||||
app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("expenses.update"), 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(c.get("db"), "core.fn_expense_attachment_add", {
|
||||
const env = await callCoreFn(db, "core.fn_expense_attachment_add", {
|
||||
expense_id: expenseId,
|
||||
...JSON.parse(String(form.get("meta") ?? "{}")),
|
||||
}, { route: routeLabel(c) });
|
||||
|
|
@ -89,7 +114,6 @@ 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,
|
||||
|
|
@ -105,10 +129,15 @@ export function registerExpenseRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||
app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requirePermission("expenses.view"), 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;
|
||||
|
|
@ -120,7 +149,7 @@ export function registerExpenseRoutes(app: App) {
|
|||
return c.body(plain.buffer as ArrayBuffer);
|
||||
});
|
||||
|
||||
app.get("/v1/iva/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||
app.get("/v1/iva/summary", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => {
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn(db, "core.fn_iva_period_summary", {
|
||||
tenant_id: tid(c),
|
||||
|
|
@ -131,13 +160,13 @@ export function registerExpenseRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/cost-settings", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_cost_settings"), async (c) => {
|
||||
app.get("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.view"), 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("manage_cost_settings"), async (c) => {
|
||||
app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.update"), 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) });
|
||||
|
|
|
|||
135
api/iam_catalog.ts
Normal file
135
api/iam_catalog.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/** 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",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -41,9 +41,12 @@ export async function pingIamDb(): Promise<void> {
|
|||
* qué hacer con tenant_id/role_code. */
|
||||
export async function findUserByUsernameAnyTenant(username: string) {
|
||||
const rows = await getAuthPool()`
|
||||
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}`;
|
||||
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}`;
|
||||
return rows[0] as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
279
api/iam_http.ts
279
api/iam_http.ts
|
|
@ -2,63 +2,302 @@ import type { Hono } from "hono";
|
|||
import type { AuthUser } from "./auth.ts";
|
||||
import { requireAuth, tenantScope } from "./auth.ts";
|
||||
import type { Db } from "./db.ts";
|
||||
import { respondRpc } from "./http_errors.ts";
|
||||
import { requirePermission, callIamFn } from "./permissions.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";
|
||||
|
||||
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("manage_users"), async (c) => {
|
||||
const tid = tenantScope(c.get("user"));
|
||||
const row = await callIamFn(tid, "iam.fn_user_list", { tenant_id: tid });
|
||||
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 });
|
||||
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("manage_users"), async (c) => {
|
||||
const tid = tenantScope(c.get("user"));
|
||||
const row = await callIamFn(tid, "iam.fn_permission_list", {});
|
||||
app.get("/v1/iam/permissions", requireAuth, requirePermission("users.view"), async (c) => {
|
||||
const row = await callIamFn(tid(c), "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/: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") });
|
||||
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")) });
|
||||
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/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
|
||||
app.put("/v1/iam/roles/:id/permissions", requireAuth, requirePermission("users.update"), async (c) => {
|
||||
const user = c.get("user");
|
||||
const body = await c.req.json<{ permissions?: string[] }>();
|
||||
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 ?? [],
|
||||
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,
|
||||
});
|
||||
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 { userPermissions } = await import("./permissions.ts");
|
||||
const role = user.role === "tenant_admin" ? "tenant_admin" : "user";
|
||||
const perms = await userPermissions(user.tenant_id, role);
|
||||
const { userPermissionsByRoleId } = await import("./permissions.ts");
|
||||
const perms = await userPermissionsByRoleId(user.tenant_id, user.role_id, user.is_owner);
|
||||
return respondRpc(c, {
|
||||
ok: true,
|
||||
code: "OK",
|
||||
layer: "api",
|
||||
message: `Permisos del usuario ${user.username}`,
|
||||
data: { permissions: [...perms], role: user.role },
|
||||
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,
|
||||
},
|
||||
errors: null,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
72
api/iam_test.ts
Normal file
72
api/iam_test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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"));
|
||||
});
|
||||
138
api/main.ts
138
api/main.ts
|
|
@ -36,6 +36,14 @@ 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";
|
||||
|
|
@ -82,6 +90,8 @@ type StorageDoc = {
|
|||
iv: string;
|
||||
original_name: string;
|
||||
mime?: string;
|
||||
type_code?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
async function rpcDocumentForDownload(
|
||||
|
|
@ -275,7 +285,7 @@ app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) =>
|
|||
});
|
||||
});
|
||||
|
||||
app.get("/v1/catalogs", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/catalogs", ...requireCoreAuth, requirePermission("reports.view"), async (c) => {
|
||||
const db = c.get("db");
|
||||
const user = c.get("user");
|
||||
const tid = tenantScope(user);
|
||||
|
|
@ -294,20 +304,15 @@ app.get("/v1/catalogs", ...requireCoreAuth, async (c) => {
|
|||
|
||||
// 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, async (c) => {
|
||||
app.get("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.view"), 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, async (c) => {
|
||||
app.put("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.update"), 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) {
|
||||
|
|
@ -332,7 +337,7 @@ app.put("/v1/configuracion", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.get("/v1/companies", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/companies", ...requireCoreAuth, requirePermission("companies.view"), async (c) => {
|
||||
const db = c.get("db");
|
||||
const tid = tenantScope(c.get("user"));
|
||||
return respondRpc(c, await callCoreFn(
|
||||
|
|
@ -343,7 +348,7 @@ app.get("/v1/companies", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.post("/v1/companies", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/companies", ...requireCoreAuth, requirePermission("companies.create"), async (c) => {
|
||||
const body = await c.req.json();
|
||||
const db = c.get("db");
|
||||
const tid = tenantScope(c.get("user"));
|
||||
|
|
@ -361,7 +366,7 @@ app.post("/v1/companies", ...requireCoreAuth, async (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.update"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = await c.req.json();
|
||||
const db = c.get("db");
|
||||
|
|
@ -380,7 +385,7 @@ app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.view"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const tid = tenantScope(c.get("user"));
|
||||
|
|
@ -414,7 +419,7 @@ app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.post("/v1/companies/:id/documents", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/companies/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const form = await c.req.formData();
|
||||
const type = String(form.get("type") || "");
|
||||
|
|
@ -431,7 +436,7 @@ app.post("/v1/companies/:id/documents", ...requireCoreAuth, async (c) => {
|
|||
return c.json({ ok: true, checklist: await companyChecklistFor(db, id) });
|
||||
});
|
||||
|
||||
app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
|
||||
const companyId = Number(c.req.param("id"));
|
||||
const docId = Number(c.req.param("docId"));
|
||||
const db = c.get("db");
|
||||
|
|
@ -454,7 +459,7 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => {
|
|||
return c.body(plain.buffer as ArrayBuffer);
|
||||
});
|
||||
|
||||
app.get("/v1/projects", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/projects", ...requireCoreAuth, requirePermission("projects.view"), 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) : [];
|
||||
|
|
@ -464,12 +469,13 @@ app.get("/v1/projects", ...requireCoreAuth, async (c) => {
|
|||
status,
|
||||
});
|
||||
}
|
||||
return respondRpc(c, await callCoreFn(
|
||||
const env = 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 = {
|
||||
|
|
@ -519,7 +525,7 @@ function projectFields(body: ProjectInput, cur?: Record<string, unknown>) {
|
|||
};
|
||||
}
|
||||
|
||||
app.post("/v1/projects", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/projects", ...requireCoreAuth, requirePermission("projects.create"), async (c) => {
|
||||
const body = await c.req.json<ProjectInput>();
|
||||
if (!body.name?.trim()) {
|
||||
return respondApiError(c, "VALIDATION", `Nombre de proyecto obligatorio en ${routeLabel(c)}`, {
|
||||
|
|
@ -567,8 +573,9 @@ app.post("/v1/projects", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.update"), 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);
|
||||
|
|
@ -617,8 +624,9 @@ app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.view"), 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 });
|
||||
|
|
@ -634,6 +642,12 @@ app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
|
|||
{ 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",
|
||||
|
|
@ -641,20 +655,22 @@ app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
|
|||
message: `Proyecto ${project.code} cargado`,
|
||||
data: {
|
||||
project,
|
||||
documents: docsEnv.data?.documents ?? [],
|
||||
documents,
|
||||
checklist: await projectChecklistFor(db, id),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/documents", ...requireCoreAuth, async (c) => {
|
||||
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) => {
|
||||
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);
|
||||
|
|
@ -664,8 +680,9 @@ app.post("/v1/projects/:id/documents", ...requireCoreAuth, async (c) => {
|
|||
return c.json({ ok: true, checklist: await projectChecklistFor(db, id) });
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermission("projects.view"), 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);
|
||||
|
|
@ -676,6 +693,8 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => {
|
|||
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");
|
||||
|
|
@ -684,22 +703,24 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => {
|
|||
return c.body(plain.buffer as ArrayBuffer);
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/budget", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/projects/:id/budget", ...requireCoreAuth, requirePermission("budget.view"), 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, (c) => {
|
||||
app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, requirePermission("budget.view"), (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, async (c) => {
|
||||
app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, requirePermission("budget.view"), 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);
|
||||
|
|
@ -710,7 +731,7 @@ app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, async (c) => {
|
|||
return c.body(bytes.slice());
|
||||
});
|
||||
|
||||
app.post("/v1/budget/preview", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), 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);
|
||||
|
|
@ -720,8 +741,9 @@ app.post("/v1/budget/preview", ...requireCoreAuth, async (c) => {
|
|||
return c.json(preview);
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), 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);
|
||||
|
|
@ -733,8 +755,9 @@ app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, async (c) => {
|
|||
return c.json(preview);
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), 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);
|
||||
|
|
@ -746,8 +769,9 @@ app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => {
|
|||
return c.json(report);
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, requirePermission("budget.create"), 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)}`, {
|
||||
|
|
@ -769,8 +793,9 @@ app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, requirePermission("budget.update"), 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");
|
||||
|
|
@ -787,8 +812,9 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c)
|
|||
));
|
||||
});
|
||||
|
||||
app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => {
|
||||
app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, requirePermission("budget.delete"), 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(
|
||||
|
|
@ -808,8 +834,9 @@ type BudgetItemInput = {
|
|||
unit_price?: number;
|
||||
};
|
||||
|
||||
app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, requirePermission("budget.create"), 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)}`, {
|
||||
|
|
@ -834,8 +861,9 @@ app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePermission("budget.update"), 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");
|
||||
|
|
@ -856,8 +884,9 @@ app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) =>
|
|||
));
|
||||
});
|
||||
|
||||
app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => {
|
||||
app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePermission("budget.delete"), 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(
|
||||
|
|
@ -868,7 +897,7 @@ app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) =
|
|||
));
|
||||
});
|
||||
|
||||
app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/workers/validate", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
|
||||
const body = await c.req.json<WorkerInput & { id?: number }>();
|
||||
const errors = validateWorkerFields(body);
|
||||
const db = c.get("db");
|
||||
|
|
@ -899,12 +928,13 @@ app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => {
|
|||
return c.json({ ok: Object.keys(errors).length === 0, errors });
|
||||
});
|
||||
|
||||
app.get("/v1/workers", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/workers", ...requireCoreAuth, requirePermission("workers.view"), 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",
|
||||
|
|
@ -918,7 +948,7 @@ app.get("/v1/workers", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const tid = tenantScope(c.get("user"));
|
||||
|
|
@ -966,7 +996,7 @@ async function findExistingWorker(
|
|||
return env.data.worker ?? undefined;
|
||||
}
|
||||
|
||||
app.post("/v1/workers", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/workers", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
|
||||
const body = await c.req.json<WorkerInput & { project_id?: number }>();
|
||||
const errors = validateWorkerFields(body);
|
||||
const db = c.get("db");
|
||||
|
|
@ -990,6 +1020,7 @@ app.post("/v1/workers", ...requireCoreAuth, async (c) => {
|
|||
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(
|
||||
|
|
@ -1000,7 +1031,7 @@ app.post("/v1/workers", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.update"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const tid = tenantScope(c.get("user"));
|
||||
|
|
@ -1031,7 +1062,7 @@ app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, requirePermission("workers.update"), 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"];
|
||||
|
|
@ -1057,7 +1088,7 @@ app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/workers/:id/assign", ...requireCoreAuth, requirePermission("workers.update"), 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");
|
||||
|
|
@ -1093,7 +1124,7 @@ app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => {
|
|||
));
|
||||
});
|
||||
|
||||
app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/workers/import/template", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
|
||||
const db = c.get("db");
|
||||
const bytes = buildImportTemplate(await listCompanies(db));
|
||||
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
|
|
@ -1101,7 +1132,7 @@ app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => {
|
|||
return c.body(bytes.buffer as ArrayBuffer);
|
||||
});
|
||||
|
||||
app.post("/v1/workers/import", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/workers/import", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const file = form.get("file");
|
||||
const projectId = Number(form.get("project_id") || 0) || null;
|
||||
|
|
@ -1120,7 +1151,7 @@ app.post("/v1/workers/import", ...requireCoreAuth, async (c) => {
|
|||
return c.json(report);
|
||||
});
|
||||
|
||||
app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const form = await c.req.formData();
|
||||
const type = String(form.get("type") || "");
|
||||
|
|
@ -1198,7 +1229,7 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
|
||||
const workerId = Number(c.req.param("id"));
|
||||
const docId = Number(c.req.param("docId"));
|
||||
const db = c.get("db");
|
||||
|
|
@ -1218,7 +1249,7 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => {
|
|||
return c.body(plain.buffer as ArrayBuffer);
|
||||
});
|
||||
|
||||
app.get("/v1/workers/:id/photo", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/workers/:id/photo", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const bytes = await loadCurrentPhoto(db, id);
|
||||
|
|
@ -1234,7 +1265,7 @@ app.get("/v1/workers/:id/photo", ...requireCoreAuth, async (c) => {
|
|||
return c.body(bytes.buffer as ArrayBuffer);
|
||||
});
|
||||
|
||||
app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/badge-qr", ...requireCoreAuth, requirePermission("documents.view"), 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);
|
||||
|
|
@ -1243,8 +1274,9 @@ app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => {
|
|||
return c.body(png.buffer as ArrayBuffer);
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, requirePermission("documents.create"), 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");
|
||||
|
|
@ -1302,18 +1334,18 @@ app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/badge-jobs", ...requireCoreAuth, requirePermission("documents.view"), 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, async (c) => {
|
||||
app.get("/v1/badge-jobs/:id", ...requireCoreAuth, requirePermission("documents.view"), 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, async (c) => {
|
||||
app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const route = routeLabel(c);
|
||||
|
|
@ -1331,7 +1363,7 @@ app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => {
|
|||
return c.body(bytes.buffer as ArrayBuffer);
|
||||
});
|
||||
|
||||
app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
|
||||
const jobId = Number(c.req.param("id"));
|
||||
const workerId = Number(c.req.param("workerId"));
|
||||
const { delivered } = await c.req.json<{ delivered: boolean }>();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ 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";
|
||||
|
|
@ -44,7 +46,7 @@ function pdfBody(c: Context, bytes: Uint8Array, filename: string) {
|
|||
}
|
||||
|
||||
export function registerPayrollRoutes(app: App) {
|
||||
app.get("/v1/attendance", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/attendance", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||
const projectId = Number(c.req.query("project_id"));
|
||||
const from = c.req.query("from") ?? "";
|
||||
const to = c.req.query("to") ?? "";
|
||||
|
|
@ -58,7 +60,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.put("/v1/attendance", ...requireCoreAuth, async (c) => {
|
||||
app.put("/v1/attendance", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||
const body = await c.req.json<{
|
||||
project_id: number;
|
||||
worker_id: number;
|
||||
|
|
@ -86,13 +88,13 @@ export function registerPayrollRoutes(app: App) {
|
|||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/settings", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/settings", ...requireCoreAuth, requirePermission("payroll.view"), 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, async (c) => {
|
||||
app.patch("/v1/payroll/settings", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||
const db = c.get("db");
|
||||
const body = await c.req.json<{
|
||||
loan_commission_enabled?: number | boolean;
|
||||
|
|
@ -109,7 +111,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return c.json({ settings });
|
||||
});
|
||||
|
||||
app.post("/v1/destajo/units", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/destajo/units", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
|
||||
const body = await c.req.json<{ code: string; label?: string }>();
|
||||
const db = c.get("db");
|
||||
try {
|
||||
|
|
@ -120,7 +122,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.get("/v1/loans", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/loans", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||
const db = c.get("db");
|
||||
const workerId = c.req.query("worker_id");
|
||||
const env = await callCoreFn<{ loans: unknown[] }>(
|
||||
|
|
@ -132,7 +134,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.post("/v1/loans", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/loans", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
|
||||
const body = await c.req.json<{
|
||||
worker_id: number;
|
||||
amount?: number;
|
||||
|
|
@ -162,7 +164,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/loans/:id/recibo", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn<{ loan: {
|
||||
|
|
@ -213,7 +215,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`);
|
||||
});
|
||||
|
||||
app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn<{ payment: {
|
||||
|
|
@ -248,7 +250,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/weeks", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/weeks", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||
const db = c.get("db");
|
||||
const tenantId = tid(c);
|
||||
const start = c.req.query("week_start") || weekContaining(await resolveToday(db, tenantId)).weekStart;
|
||||
|
|
@ -256,12 +258,12 @@ export function registerPayrollRoutes(app: App) {
|
|||
return c.json(bundle);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/weeks/open", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/weeks/open", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||
const db = c.get("db");
|
||||
return c.json({ weeks: await listOpenWeeks(db, tid(c)) });
|
||||
});
|
||||
|
||||
app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||
const db = c.get("db");
|
||||
try {
|
||||
const nowIso = await resolveToday(db, tid(c));
|
||||
|
|
@ -273,7 +275,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||
const db = c.get("db");
|
||||
try {
|
||||
const nowIso = await resolveToday(db, tid(c));
|
||||
|
|
@ -285,7 +287,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const csv = await weekCsv(db, id);
|
||||
|
|
@ -294,7 +296,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return c.body(csv);
|
||||
});
|
||||
|
||||
app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||
const body = await c.req.json<{ project_id: number; worker_id: number }>();
|
||||
const db = c.get("db");
|
||||
try {
|
||||
|
|
@ -306,7 +308,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||
const body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>();
|
||||
const db = c.get("db");
|
||||
try {
|
||||
|
|
@ -318,7 +320,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||
const body = await c.req.json<{ amount: number }>();
|
||||
const db = c.get("db");
|
||||
try {
|
||||
|
|
@ -330,7 +332,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => {
|
||||
app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, requirePermission("payroll.delete"), async (c) => {
|
||||
const db = c.get("db");
|
||||
try {
|
||||
await removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")));
|
||||
|
|
@ -341,7 +343,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/v1/destajo/jobs", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/destajo/jobs", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
|
||||
const body = await c.req.json<{
|
||||
week_id: number;
|
||||
project_id: number;
|
||||
|
|
@ -363,7 +365,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||
const body = await c.req.json<{
|
||||
week_id: number;
|
||||
qty_planned?: number;
|
||||
|
|
@ -380,7 +382,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
|
||||
app.post("/v1/payroll/periods", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
|
||||
const body = await c.req.json<{
|
||||
project_id: number;
|
||||
week_start: string;
|
||||
|
|
@ -415,7 +417,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/periods", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||
const db = c.get("db");
|
||||
const projectId = c.req.query("project_id");
|
||||
const env = await callCoreFn<{ periods: unknown[] }>(
|
||||
|
|
@ -427,7 +429,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn<{ period: unknown; lines: unknown[] }>(
|
||||
|
|
@ -439,7 +441,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => {
|
||||
app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, requirePermission("payroll.view"), 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>> }>(
|
||||
|
|
@ -460,7 +462,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
return c.body(csv);
|
||||
});
|
||||
|
||||
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
|
||||
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const { status } = await c.req.json<{ status: string }>();
|
||||
const db = c.get("db");
|
||||
|
|
|
|||
|
|
@ -3,44 +3,62 @@ 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 async function userPermissions(
|
||||
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(
|
||||
tenantId: number | null,
|
||||
roleCode: string,
|
||||
roleId: number | null,
|
||||
isOwner: boolean,
|
||||
): Promise<Set<string>> {
|
||||
const key = `${tenantId ?? 0}:${roleCode}`;
|
||||
if (isOwner) return new Set(ALL_PERMISSION_CODES);
|
||||
if (roleId == null) return new Set();
|
||||
const key = `${roleId}:${tenantId ?? 0}`;
|
||||
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_code = ?",
|
||||
).all(roleCode) as { permission_code: string }[],
|
||||
"SELECT permission_code FROM role_permissions WHERE role_id = ?",
|
||||
).all(roleId) as { permission_code: string }[],
|
||||
);
|
||||
const perms = new Set(rows.map((r) => r.permission_code));
|
||||
permissionCache.set(key, { perms, at: Date.now() });
|
||||
return perms;
|
||||
}
|
||||
|
||||
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);
|
||||
/** @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);
|
||||
return perms.has(code);
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +74,7 @@ export function requirePermission(code: string) {
|
|||
c,
|
||||
"FORBIDDEN",
|
||||
`Permiso requerido: ${code}`,
|
||||
{ route: routeLabel(c), permission: code, role: user.role },
|
||||
{ route: routeLabel(c), permission: code, role: user.role_code },
|
||||
);
|
||||
}
|
||||
await next();
|
||||
|
|
@ -85,6 +103,16 @@ 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,
|
||||
|
|
@ -99,3 +127,19 @@ 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")));
|
||||
}
|
||||
|
|
|
|||
30
api/saas.ts
30
api/saas.ts
|
|
@ -236,10 +236,12 @@ export async function getTenantDetail(
|
|||
|
||||
const admins = await withIamOwner(async (iam) => {
|
||||
const rows = await iam.prepare(
|
||||
`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`,
|
||||
`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`,
|
||||
).all(tenantId) as Array<Record<string, unknown>>;
|
||||
return rows.map((a) => ({
|
||||
id: Number(a.id),
|
||||
|
|
@ -344,16 +346,20 @@ export async function createTenant(
|
|||
).then(() => core.lastInsertId())
|
||||
);
|
||||
|
||||
// Paso 3: admin del tenant en iam.
|
||||
// Paso 3: admin del tenant en iam (rol sistema tenant_admin).
|
||||
const hash = await hashPassword(temporaryPassword);
|
||||
await withIamOwner((iam) =>
|
||||
iam.prepare(
|
||||
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(
|
||||
`INSERT INTO users (
|
||||
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)
|
||||
);
|
||||
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);
|
||||
});
|
||||
} catch (e) {
|
||||
// Compensación: limpiar en orden inverso lo que sí se alcanzó a crear.
|
||||
if (companyId != null) {
|
||||
|
|
|
|||
116
api/scope_enforcement.ts
Normal file
116
api/scope_enforcement.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -74,18 +74,27 @@ 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_code = 'tenant_admin', must_change_password = true WHERE username = ?`,
|
||||
).run(hash, displayName, company.id, tenantId, username);
|
||||
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);
|
||||
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_code, must_change_password, email)
|
||||
VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, '')`,
|
||||
).run(username, hash, displayName, company.id, tenantId);
|
||||
`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);
|
||||
console.log(`[bootstrap] iam.users.${username} creado (tenant_id=${tenantId})`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
77
api/user_scope.ts
Normal file
77
api/user_scope.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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";
|
||||
}
|
||||
|
|
@ -4,8 +4,10 @@ import { tenantScope } from "./auth.ts";
|
|||
import { requireCoreAuth } from "./scope.ts";
|
||||
import type { Db } from "./db.ts";
|
||||
import { callCoreFn } from "./rpc.ts";
|
||||
import { respondRpc, routeLabel } from "./http_errors.ts";
|
||||
import { requireAnyPermission, requirePermission } from "./permissions.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";
|
||||
|
||||
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||
|
||||
|
|
@ -18,37 +20,59 @@ 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, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
|
||||
app.get("/v1/warehouses", ...requireCoreAuth, requirePermission("warehouse.view"), 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, env);
|
||||
return respondRpc(c, await filterWarehouseList(c, env));
|
||||
});
|
||||
|
||||
app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
|
||||
app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
|
||||
if (!await denyUnlessWarehouseCentral(c)) return;
|
||||
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("manage_warehouse"), async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn(db, "core.fn_warehouse_open_project", {
|
||||
tenant_id: tid(c),
|
||||
project_id: Number(c.req.param("id")),
|
||||
project_id: projectId,
|
||||
}, { route: routeLabel(c) });
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("close_warehouse"), async (c) => {
|
||||
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;
|
||||
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: Number(c.req.param("id")),
|
||||
project_id: projectId,
|
||||
force: body.force,
|
||||
closed_by_name: a.name,
|
||||
close_notes: body.close_notes,
|
||||
|
|
@ -56,17 +80,26 @@ export function registerWarehouseRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/warehouses/:id/stock", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
|
||||
app.get("/v1/warehouses/:id/stock", ...requireCoreAuth, requirePermission("warehouse.view"), 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: Number(c.req.param("id")),
|
||||
warehouse_id: whId,
|
||||
global: false,
|
||||
}, { route: routeLabel(c) });
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/warehouses/stock/global", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
|
||||
app.get("/v1/warehouses/stock/global", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
|
||||
if (!await denyUnlessWarehouseCentral(c)) return;
|
||||
const db = c.get("db");
|
||||
const env = await callCoreFn(db, "core.fn_warehouse_stock_list", {
|
||||
tenant_id: tid(c),
|
||||
|
|
@ -75,51 +108,75 @@ export function registerWarehouseRoutes(app: App) {
|
|||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/warehouses/:id/movements", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
|
||||
app.get("/v1/warehouses/:id/movements", ...requireCoreAuth, requirePermission("warehouse.view"), 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: Number(c.req.param("id")),
|
||||
warehouse_id: whId,
|
||||
material_id: c.req.query("material_id"),
|
||||
}, { route: routeLabel(c) });
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
|
||||
app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.create"), 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("manage_warehouse"), async (c) => {
|
||||
const body = await c.req.json<Record<string, unknown>>();
|
||||
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;
|
||||
const body = await c.req.json<Record<string, unknown>>();
|
||||
const a = actor(c);
|
||||
const env = await callCoreFn(db, "core.fn_warehouse_entry_create", {
|
||||
...body,
|
||||
tenant_id: tid(c),
|
||||
warehouse_id: Number(c.req.param("id")),
|
||||
warehouse_id: whId,
|
||||
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("manage_warehouse"), async (c) => {
|
||||
const body = await c.req.json<Record<string, unknown>>();
|
||||
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;
|
||||
const body = await c.req.json<Record<string, unknown>>();
|
||||
const a = actor(c);
|
||||
const env = await callCoreFn(db, "core.fn_warehouse_exit_create", {
|
||||
...body,
|
||||
tenant_id: tid(c),
|
||||
warehouse_id: Number(c.req.param("id")),
|
||||
warehouse_id: whId,
|
||||
created_by_id: a.id,
|
||||
created_by_name: a.name,
|
||||
}, { route: routeLabel(c) });
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
|
||||
app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
|
||||
const body = await c.req.json<Record<string, unknown>>();
|
||||
const db = c.get("db");
|
||||
const a = actor(c);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { Hono } from "hono";
|
||||
import type { Context, 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 { requirePermission } from "./permissions.ts";
|
||||
import { requireAnyPermission, requirePermission } from "./permissions.ts";
|
||||
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
|
||||
import {
|
||||
createEmptyWorkProgram,
|
||||
exportWorkProgram,
|
||||
|
|
@ -22,7 +23,11 @@ import {
|
|||
|
||||
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||
|
||||
const auth = [...requireCoreAuth, requirePermission("manage_budget")] as const;
|
||||
const viewAuth = [...requireCoreAuth, requirePermission("work_program.view")] as const;
|
||||
|
||||
async function ensureProject(c: Context, id: number): Promise<boolean> {
|
||||
return await denyUnlessProjectScope(c, id);
|
||||
}
|
||||
|
||||
async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Promise<Uint8Array> {
|
||||
const form = await c.req.formData();
|
||||
|
|
@ -32,70 +37,90 @@ async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Pr
|
|||
}
|
||||
|
||||
export function registerWorkProgramRoutes(app: App) {
|
||||
app.get("/v1/projects/:id/work-program", ...auth, async (c) => {
|
||||
app.get("/v1/projects/:id/work-program", ...viewAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
if (!await ensureProject(c, id)) return;
|
||||
const db = c.get("db");
|
||||
const data = await getWorkProgram(db, Number(c.req.param("id")));
|
||||
const data = await getWorkProgram(db, id);
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/work-program/concepts", ...auth, async (c) => {
|
||||
app.get("/v1/projects/:id/work-program/concepts", ...viewAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
if (!await ensureProject(c, id)) return;
|
||||
const db = c.get("db");
|
||||
const data = await listConcepts(db, Number(c.req.param("id")));
|
||||
const data = await listConcepts(db, id);
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/work-program/partidas", ...auth, async (c) => {
|
||||
app.get("/v1/projects/:id/work-program/partidas", ...viewAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
if (!await ensureProject(c, id)) return;
|
||||
const db = c.get("db");
|
||||
const data = await listPartidas(db, Number(c.req.param("id")));
|
||||
const data = await listPartidas(db, id);
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/work-program/vs-cost", ...auth, async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const data = await getVsCost(db, Number(c.req.param("id")));
|
||||
const data = await getVsCost(db, id);
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program/preview", ...auth, async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const bytes = await readUpload(c);
|
||||
const preview = await previewWorkProgramExcel(bytes, Number(c.req.param("id")), db);
|
||||
const preview = await previewWorkProgramExcel(bytes, id, db);
|
||||
return c.json({ ok: true, data: preview });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program/import", ...auth, async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const bytes = await readUpload(c);
|
||||
const createChapters = c.req.query("create_chapters") !== "false";
|
||||
const data = await importWorkProgramExcel(db, Number(c.req.param("id")), bytes, { createChapters });
|
||||
const data = await importWorkProgramExcel(db, id, bytes, { createChapters });
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program", ...auth, async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const data = await createEmptyWorkProgram(db, Number(c.req.param("id")));
|
||||
const data = await createEmptyWorkProgram(db, id);
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program/generate", ...auth, async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const body = await c.req.json().catch(() => ({})) as { strategy?: string };
|
||||
const data = await generateWorkProgram(db, Number(c.req.param("id")), body.strategy || "keyword");
|
||||
const data = await generateWorkProgram(db, id, body.strategy || "keyword");
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program/recalculate-partidas", ...auth, async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const env = await recalculatePartidas(db, Number(c.req.param("id")));
|
||||
const env = await recalculatePartidas(db, id);
|
||||
return c.json({ ok: true, data: env });
|
||||
});
|
||||
|
||||
app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...auth, async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const body = await c.req.json() as { start_pct?: number; end_pct?: number };
|
||||
const data = await updateConceptSlot(
|
||||
db,
|
||||
Number(c.req.param("id")),
|
||||
id,
|
||||
Number(c.req.param("itemId")),
|
||||
Number(c.req.param("periodId")),
|
||||
Number(body.start_pct ?? 0),
|
||||
|
|
@ -104,12 +129,14 @@ export function registerWorkProgramRoutes(app: App) {
|
|||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...auth, async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const body = await c.req.json() as { amount?: number };
|
||||
const data = await updatePartidaAmount(
|
||||
db,
|
||||
Number(c.req.param("id")),
|
||||
id,
|
||||
Number(c.req.param("chapterId")),
|
||||
Number(c.req.param("periodId")),
|
||||
Number(body.amount ?? 0),
|
||||
|
|
@ -117,12 +144,14 @@ export function registerWorkProgramRoutes(app: App) {
|
|||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...auth, async (c) => {
|
||||
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;
|
||||
const db = c.get("db");
|
||||
const body = await c.req.json() as { pct_complete?: number };
|
||||
const data = await updateProgress(
|
||||
db,
|
||||
Number(c.req.param("id")),
|
||||
id,
|
||||
Number(c.req.param("itemId")),
|
||||
Number(c.req.param("periodId")),
|
||||
Number(body.pct_complete ?? 0),
|
||||
|
|
@ -130,9 +159,10 @@ export function registerWorkProgramRoutes(app: App) {
|
|||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/work-program/export", ...auth, async (c) => {
|
||||
app.get("/v1/projects/:id/work-program/export", ...viewAuth, 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);
|
||||
|
|
|
|||
|
|
@ -37,5 +37,6 @@
|
|||
<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>
|
||||
|
|
|
|||
12
db/core/changesets/031-project-doc-types-technical.sql
Normal file
12
db/core/changesets/031-project-doc-types-technical.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
--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;
|
||||
|
|
@ -10,5 +10,6 @@
|
|||
<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>
|
||||
|
|
|
|||
972
db/iam/changesets/005-iam-v2-roles-crud.sql
Normal file
972
db/iam/changesets/005-iam-v2-roles-crud.sql
Normal file
|
|
@ -0,0 +1,972 @@
|
|||
--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;
|
||||
110
web-panel/components/IamDetail.vue
Normal file
110
web-panel/components/IamDetail.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<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>
|
||||
|
|
@ -48,7 +48,10 @@ export const useAuth = () => {
|
|||
company_name?: string | null;
|
||||
company_kind?: string | null;
|
||||
tenant_id?: number | null;
|
||||
role?: "platform_admin" | "tenant_admin" | "user";
|
||||
role_id?: number | null;
|
||||
role_code?: string;
|
||||
is_owner?: boolean;
|
||||
status?: string;
|
||||
realm?: "app" | "platform";
|
||||
must_change_password?: boolean;
|
||||
} | null>("auth-user", () => null);
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ export type RibbonGroup = {
|
|||
};
|
||||
|
||||
export type DeskDetail = {
|
||||
kind: "none" | "worker" | "project" | "budget";
|
||||
kind: "none" | "worker" | "project" | "budget" | "iam-user" | "iam-role";
|
||||
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 = {
|
||||
|
|
@ -114,6 +116,16 @@ 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" };
|
||||
}
|
||||
|
|
@ -155,6 +167,8 @@ export function useDesktop() {
|
|||
openWorker,
|
||||
openProject,
|
||||
openBudgetItem,
|
||||
openIamUser,
|
||||
openIamRole,
|
||||
clearDetail,
|
||||
bumpDetail,
|
||||
loanPrompt,
|
||||
|
|
|
|||
39
web-panel/composables/usePermissions.ts
Normal file
39
web-panel/composables/usePermissions.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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 };
|
||||
}
|
||||
35
web-panel/composables/useScope.ts
Normal file
35
web-panel/composables/useScope.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@
|
|||
: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>
|
||||
|
|
@ -114,8 +115,10 @@
|
|||
|
||||
<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();
|
||||
|
|
@ -161,42 +164,43 @@ function nav(label: string, icon: string, to: string): NavItem {
|
|||
return { label, icon, to, command: () => navigateTo(to) };
|
||||
}
|
||||
|
||||
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 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;
|
||||
});
|
||||
|
||||
function isActive(to: string) {
|
||||
if (!to) return false;
|
||||
|
|
@ -256,6 +260,7 @@ 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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,20 @@
|
|||
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();
|
||||
|
|
@ -14,7 +31,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
}
|
||||
if (!user.value) return navigateTo("/login");
|
||||
|
||||
if (user.value.role === "platform_admin" && user.value.realm === "platform") {
|
||||
if (user.value.role_code === "platform_admin" && user.value.realm === "platform") {
|
||||
user.value = null;
|
||||
try {
|
||||
await api("/v1/auth/logout", { method: "POST" });
|
||||
|
|
@ -34,4 +51,17 @@ 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("/");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 === "platform_admin" && user.value?.realm === "platform") {
|
||||
if (user.value?.role_code === "platform_admin" && user.value?.realm === "platform") {
|
||||
user.value = null;
|
||||
try {
|
||||
await api("/v1/auth/logout", { method: "POST" });
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
:drill-concepts="ganttConcepts"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel header="Programado vs costos">
|
||||
<TabPanel v-if="showCostTab" header="Programado vs costos">
|
||||
<WorkProgramCostCompare :by-period="vsCostPeriods" :by-chapter="vsCostChapters" />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
|
|
@ -115,6 +115,8 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,78 +1,521 @@
|
|||
<template>
|
||||
<div class="usuarios-page">
|
||||
<DeskTableBar v-model="tableQ" placeholder="Buscar usuario">
|
||||
<Chip :label="`${users.length} usuarios`" icon="pi pi-users" />
|
||||
<DeskTableBar v-model="tableQ" :placeholder="treeKey.startsWith('roles') ? 'Buscar rol' : 'Buscar usuario'">
|
||||
<Chip
|
||||
:label="treeKey.startsWith('roles') ? `${roles.length} roles` : `${listedUsers.length} usuarios`"
|
||||
icon="pi pi-users"
|
||||
/>
|
||||
</DeskTableBar>
|
||||
|
||||
<DataTable :value="listed" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
|
||||
<DataTable
|
||||
v-if="!treeKey.startsWith('roles')"
|
||||
v-model:selection="selectedUser"
|
||||
:value="listedUsers"
|
||||
selection-mode="single"
|
||||
data-key="id"
|
||||
paginator
|
||||
:rows="25"
|
||||
scrollable
|
||||
scroll-height="flex"
|
||||
@row-select="onUserSelect"
|
||||
>
|
||||
<Column selection-mode="single" style="width: 3rem" />
|
||||
<Column field="username" header="Usuario" style="width: 10rem" />
|
||||
<Column field="display_name" header="Nombre" />
|
||||
<Column field="role_code" header="Rol" style="width: 9rem" />
|
||||
<Column field="role_label" header="Rol" style="width: 11rem" />
|
||||
<Column field="status" header="Estado" style="width: 7rem" />
|
||||
<Column field="email" header="Correo" style="width: 14rem" />
|
||||
</DataTable>
|
||||
|
||||
<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>
|
||||
<DataTable
|
||||
v-else
|
||||
v-model:selection="selectedRole"
|
||||
:value="listedRoles"
|
||||
selection-mode="single"
|
||||
data-key="id"
|
||||
paginator
|
||||
:rows="25"
|
||||
scrollable
|
||||
scroll-height="flex"
|
||||
@row-select="onRoleSelect"
|
||||
>
|
||||
<Column selection-mode="single" style="width: 3rem" />
|
||||
<Column field="code" header="Código" style="width: 10rem" />
|
||||
<Column field="label" header="Etiqueta" />
|
||||
<Column field="is_system" header="Sistema" style="width: 7rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.is_system ? 'Sí' : 'No'" :severity="data.is_system ? 'info' : 'secondary'" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<Dialog v-model:visible="showUserDialog" :header="userDialogTitle" modal class="iam-dialog" style="width: 34rem">
|
||||
<div class="form-grid">
|
||||
<FormField v-if="!editingUser" label="Usuario">
|
||||
<InputText v-model="userForm.username" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Nombre">
|
||||
<InputText v-model="userForm.display_name" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Correo">
|
||||
<InputText v-model="userForm.email" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Rol">
|
||||
<Select
|
||||
v-model="userForm.role_id"
|
||||
:options="assignableRoles"
|
||||
option-label="label"
|
||||
option-value="id"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField v-if="perms.isOwner.value" label="Alcance obras">
|
||||
<div class="flex flex-column gap-2">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<Checkbox v-model="userForm.scope_all_projects" input-id="scope-all" binary />
|
||||
<label for="scope-all">Todas las obras</label>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-if="!userForm.scope_all_projects"
|
||||
v-model="userForm.project_ids"
|
||||
:options="projects"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
placeholder="Obras asignadas"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Almacén">
|
||||
<div class="flex flex-column gap-2">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<Checkbox v-model="userForm.warehouse_central" input-id="wh-central" binary />
|
||||
<label for="wh-central">Almacén central</label>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<Checkbox v-model="userForm.warehouse_projects" input-id="wh-projects" binary />
|
||||
<label for="wh-projects">Almacenes de obra</label>
|
||||
</div>
|
||||
</div>
|
||||
<Button label="Guardar permisos" icon="pi pi-save" class="mt-3" @click="saveRolePerms" />
|
||||
</Panel>
|
||||
</FormField>
|
||||
</div>
|
||||
<Message v-if="tempPassword" severity="success" :closable="false" class="mt-3">
|
||||
Contraseña temporal (cópiela ahora): <strong>{{ tempPassword }}</strong>
|
||||
</Message>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="showUserDialog = false" />
|
||||
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="saveUser" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showRoleDialog" header="Nuevo rol" modal style="width: 28rem">
|
||||
<div class="form-grid">
|
||||
<FormField label="Plantilla">
|
||||
<Select v-model="roleForm.template" :options="templateOptions" option-label="label" option-value="value" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Código">
|
||||
<InputText v-model="roleForm.code" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Etiqueta">
|
||||
<InputText v-model="roleForm.label" class="w-full" />
|
||||
</FormField>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="showRoleDialog = false" />
|
||||
<Button label="Crear" icon="pi pi-check" :loading="saving" @click="saveRole" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FormField from "~/components/padron/FormField.vue";
|
||||
import { displayErrorMessage } from "~/composables/api-response";
|
||||
|
||||
type UserRow = { id: number; username: string; display_name: string; role_code: string; email: string };
|
||||
type PermRow = { code: string; label: string };
|
||||
type UserRow = {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role_id: number;
|
||||
role_code: string;
|
||||
role_label?: string;
|
||||
email: string;
|
||||
status: string;
|
||||
is_owner?: boolean;
|
||||
scope_all_projects?: boolean;
|
||||
warehouse_central?: boolean;
|
||||
warehouse_projects?: boolean;
|
||||
project_ids?: number[];
|
||||
};
|
||||
type RoleRow = { id: number; code: string; label: string; is_system?: boolean };
|
||||
type PermRow = { code: string; label: string; module: string; verb: string; group?: string };
|
||||
type ProjectRow = { id: number; name: string; code?: string };
|
||||
|
||||
const desk = useDesktop();
|
||||
const { api } = useApi();
|
||||
const toast = useToast();
|
||||
const perms = usePermissions();
|
||||
|
||||
const tableQ = ref("");
|
||||
const treeKey = ref("users-active");
|
||||
const users = ref<UserRow[]>([]);
|
||||
const roles = ref<RoleRow[]>([]);
|
||||
const permissions = ref<PermRow[]>([]);
|
||||
const rolePerms = ref<string[]>([]);
|
||||
const projects = ref<ProjectRow[]>([]);
|
||||
const selectedUser = ref<UserRow | null>(null);
|
||||
const selectedRole = ref<RoleRow | null>(null);
|
||||
const showUserDialog = ref(false);
|
||||
const showRoleDialog = ref(false);
|
||||
const editingUser = ref(false);
|
||||
const saving = ref(false);
|
||||
const tempPassword = ref("");
|
||||
|
||||
const listed = computed(() => {
|
||||
const q = tableQ.value.toLowerCase();
|
||||
return users.value.filter((u) => !q || [u.username, u.display_name, u.email].join(" ").toLowerCase().includes(q));
|
||||
const userForm = reactive({
|
||||
username: "",
|
||||
display_name: "",
|
||||
email: "",
|
||||
role_id: null as number | null,
|
||||
scope_all_projects: false,
|
||||
warehouse_central: false,
|
||||
warehouse_projects: true,
|
||||
project_ids: [] as number[],
|
||||
});
|
||||
|
||||
const roleForm = reactive({
|
||||
template: "operativo",
|
||||
code: "",
|
||||
label: "",
|
||||
});
|
||||
|
||||
const templateOptions = [
|
||||
{ label: "Administrador operativo", value: "admin_operativo" },
|
||||
{ label: "Supervisor de obra", value: "supervisor_obra" },
|
||||
{ label: "Contador", value: "contador" },
|
||||
{ label: "Operativo", value: "operativo" },
|
||||
];
|
||||
|
||||
const assignableRoles = computed(() => roles.value.filter((r) => !r.is_system || r.code !== "tenant_admin"));
|
||||
|
||||
const listedUsers = computed(() => {
|
||||
const q = tableQ.value.toLowerCase();
|
||||
let rows = users.value;
|
||||
if (treeKey.value === "users-active") rows = rows.filter((u) => u.status === "activo" && !u.is_owner);
|
||||
else if (treeKey.value === "users-baja") rows = rows.filter((u) => u.status === "baja");
|
||||
else if (treeKey.value === "users-owner") rows = rows.filter((u) => u.is_owner);
|
||||
if (!q) return rows;
|
||||
return rows.filter((u) => [u.username, u.display_name, u.email, u.role_code].join(" ").toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const listedRoles = computed(() => {
|
||||
const q = tableQ.value.toLowerCase();
|
||||
const rows = roles.value.filter((r) => !r.is_system);
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => [r.code, r.label].join(" ").toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const userDialogTitle = computed(() => (editingUser.value ? "Editar usuario" : "Nuevo usuario"));
|
||||
const canCreateUser = computed(() => perms.can("users.create"));
|
||||
const canUpdateUser = computed(() => perms.can("users.update"));
|
||||
const canDeleteUser = computed(() => perms.can("users.delete"));
|
||||
|
||||
function onUserSelect(e: { data: UserRow }) {
|
||||
selectedUser.value = e.data;
|
||||
desk.openIamUser(e.data);
|
||||
if (!treeKey.value.startsWith("roles")) loadRoleMatrixForUser(e.data);
|
||||
}
|
||||
|
||||
function onRoleSelect(e: { data: RoleRow }) {
|
||||
selectedRole.value = e.data;
|
||||
desk.openIamRole(e.data);
|
||||
loadRolePermissions(e.data.id);
|
||||
}
|
||||
|
||||
async function loadRolePermissions(roleId: number) {
|
||||
const rp = await api<{ permissions?: string[] }>(`/v1/iam/roles/${roleId}/permissions`);
|
||||
rolePerms.value = rp.permissions ?? [];
|
||||
}
|
||||
|
||||
async function loadRoleMatrixForUser(user: UserRow) {
|
||||
if (user.role_id) await loadRolePermissions(user.role_id);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [u, p, 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"),
|
||||
const [u, p, r, proj] = await Promise.all([
|
||||
api<{ users?: UserRow[] }>("/v1/iam/users"),
|
||||
api<{ permissions?: PermRow[] }>("/v1/iam/permissions"),
|
||||
api<{ roles?: RoleRow[] }>("/v1/iam/roles"),
|
||||
api<{ projects?: ProjectRow[] }>("/v1/projects").catch(() => ({ projects: [] as ProjectRow[] })),
|
||||
]);
|
||||
users.value = u.data?.users ?? [];
|
||||
permissions.value = p.data?.permissions ?? [];
|
||||
rolePerms.value = rp.data?.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;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const r = await api<{ message?: string }>("/v1/iam/roles/user/permissions", {
|
||||
await api(`/v1/iam/roles/${role.id}/permissions`, {
|
||||
method: "PUT",
|
||||
json: { permissions: rolePerms.value },
|
||||
});
|
||||
toast.add({ severity: "success", summary: r.message || "Permisos guardados", life: 2500 });
|
||||
toast.add({ severity: "success", summary: "Permisos guardados", life: 2500 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRole() {
|
||||
if (!selectedRole.value || selectedRole.value.is_system) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api(`/v1/iam/roles/${selectedRole.value.id}`, { method: "DELETE" });
|
||||
toast.add({ severity: "success", summary: "Rol eliminado", life: 2500 });
|
||||
selectedRole.value = null;
|
||||
desk.clearDetail();
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTreeSelect(key: string) {
|
||||
treeKey.value = key;
|
||||
selectedUser.value = null;
|
||||
selectedRole.value = null;
|
||||
desk.clearDetail();
|
||||
syncChrome();
|
||||
}
|
||||
|
||||
function syncChrome() {
|
||||
desk.configure({
|
||||
tab: "Usuarios IAM",
|
||||
treeTitle: "IAM",
|
||||
showTree: true,
|
||||
showDetail: true,
|
||||
treeKey: treeKey.value,
|
||||
onTreeSelect,
|
||||
tree: [
|
||||
{
|
||||
key: "users",
|
||||
label: "Usuarios",
|
||||
icon: "pi pi-users",
|
||||
selectable: false,
|
||||
children: [
|
||||
{ key: "users-active", label: "Activos", icon: "pi pi-user" },
|
||||
{ key: "users-baja", label: "Bajas", icon: "pi pi-user-minus" },
|
||||
{ key: "users-owner", label: "Propietario", icon: "pi pi-shield" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "roles",
|
||||
label: "Roles",
|
||||
icon: "pi pi-key",
|
||||
selectable: false,
|
||||
children: [{ key: "roles-list", label: "Roles del tenant", icon: "pi pi-list" }],
|
||||
},
|
||||
],
|
||||
ribbon: [
|
||||
{
|
||||
key: "usuarios",
|
||||
label: "Usuarios",
|
||||
items: [
|
||||
{ key: "new-user", label: "Nuevo", icon: "pi pi-plus", disabled: !canCreateUser.value, command: openNewUser },
|
||||
{ key: "edit-user", label: "Editar", icon: "pi pi-pencil", disabled: !canUpdateUser.value || !selectedUser.value || selectedUser.value.is_owner, command: openEditUser },
|
||||
{ key: "baja-user", label: "Baja", icon: "pi pi-user-minus", disabled: !canDeleteUser.value || !selectedUser.value || selectedUser.value.is_owner || selectedUser.value.status === "baja", command: deactivateUser },
|
||||
{ key: "reactivate-user", label: "Reactivar", icon: "pi pi-user-plus", disabled: !canUpdateUser.value || !selectedUser.value || selectedUser.value.is_owner || selectedUser.value.status !== "baja", command: reactivateUser },
|
||||
{ key: "reset-user", label: "Reset acceso", icon: "pi pi-key", disabled: !canUpdateUser.value || !selectedUser.value || selectedUser.value.is_owner, command: resetAccess },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "roles",
|
||||
label: "Roles",
|
||||
items: [
|
||||
{ key: "new-role", label: "Nuevo rol", icon: "pi pi-plus", disabled: !canCreateUser.value, command: openNewRole },
|
||||
{ key: "delete-role", label: "Borrar rol", icon: "pi pi-trash", disabled: !canDeleteUser.value || !selectedRole.value || selectedRole.value.is_system, command: deleteRole },
|
||||
{ key: "save-perms", label: "Guardar matriz", icon: "pi pi-save", disabled: !canUpdateUser.value, command: saveRolePerms },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "vista",
|
||||
label: "Vista",
|
||||
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: () => load() }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
watch([selectedUser, selectedRole, treeKey], syncChrome);
|
||||
|
||||
onMounted(async () => {
|
||||
desk.configure({ tab: "Usuarios IAM", ribbon: [] });
|
||||
if (!treeKey.value) treeKey.value = "users-active";
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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; }
|
||||
.form-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue