feat(auth): role-based permissions + user management (plan phase 1)

Adds the RBAC foundation the CRUD phases build on, and the first write
module (users). The platform was read-only: every controller was guarded
only by AuthenticatedGuard and UserRole was ADMIN|STAFF. The old PHP app
stored level+role but enforced neither, so this is a fresh design.

Permission model (server-authoritative):
- UserRole expanded to an ordered rank ADMIN > MANAGER > STAFF > VIEWER.
  VIEWER is the read-only role; STAFF+ can write.
- auth/abilities.ts: ROLE_RANK + ABILITY_MIN matrix + can()/abilitiesFor().
- @RequireAbility decorator + AbilityGuard enforce it on write routes;
  reads stay on AuthenticatedGuard so any logged-in user can read.
- /auth/login and /auth/me now return the resolved abilities map, so the
  web gates its UI off one payload instead of duplicating the rules.

User management (ADMIN-only, ability "user:manage"):
- UsersService gains list/create/update/resetPassword (argon2), never
  returns passwordHash; blocks self-deactivation and self-demotion;
  maps duplicate email to 409.
- UsersController: GET/POST /users, PATCH /users/:id,
  POST /users/:id/reset-password.
- Every mutation logged via new AuditService over the existing
  ActivityLog model (global CommonModule).

Web:
- AuthContext + useAuth/useCan; AppShell provides the user and gates the
  new "Usuarios" nav entry on user:manage; shows the user's role.
- /usuarios admin page: list + create/edit form + password reset +
  active toggle, Spanish-first, reusing existing card/table/field styles.

Schema pushed to dev (enum only, non-destructive). Verified end-to-end
against dev: admin CRUD works, VIEWER writes 403 while reads 200,
self-lockout guards and duplicate-email 409 all hold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:02:00 -07:00
co-authored by Claude Opus 4.8
parent d9f9e8a920
commit 74e2ad8bcd
21 changed files with 912 additions and 24 deletions
+24
View File
@@ -0,0 +1,24 @@
// UI-side permission helpers. The rules themselves live server-side
// (apps/api/src/auth/abilities.ts) and arrive resolved on `user.abilities` via
// /auth/me — this module just reads that map. Gating here is cosmetic (show or
// hide a control); the API enforces every write regardless.
import { createContext, useContext } from "react";
import type { Ability, AuthUser } from "./types";
export const AuthContext = createContext<AuthUser | null>(null);
/** The signed-in user (or null while loading). */
export function useAuth(): AuthUser | null {
return useContext(AuthContext);
}
/** Whether the current user may perform `ability`. False when not loaded. */
export function useCan(ability: Ability): boolean {
const user = useAuth();
return user?.abilities?.[ability] ?? false;
}
export function can(user: AuthUser | null, ability: Ability): boolean {
return user?.abilities?.[ability] ?? false;
}
+44
View File
@@ -34,10 +34,12 @@ import type {
PropertyListResponse,
PropertySort,
PropertyStats,
Role,
ServiceKind,
Statement,
TransactionDomain,
TrustFilter,
UserRow,
} from "./types";
export const API_ORIGIN =
@@ -341,3 +343,45 @@ export function getBankFacets(): Promise<BankFacets> {
export function getBankSummary(year?: number): Promise<BankSummary> {
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
}
/* ------------------------------------------------- Users / administration */
export function listUsers(): Promise<UserRow[]> {
return apiFetch<UserRow[]>("/users");
}
export interface CreateUserInput {
name: string;
email: string;
password: string;
role: Role;
active?: boolean;
}
export function createUser(input: CreateUserInput): Promise<UserRow> {
return apiFetch<UserRow>("/users", {
method: "POST",
body: JSON.stringify(input),
});
}
export interface UpdateUserInput {
name?: string;
email?: string;
role?: Role;
active?: boolean;
}
export function updateUser(id: string, input: UpdateUserInput): Promise<UserRow> {
return apiFetch<UserRow>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function resetUserPassword(id: string, password: string): Promise<UserRow> {
return apiFetch<UserRow>(`/users/${id}/reset-password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
+12
View File
@@ -4,11 +4,23 @@ import type {
BankDirection,
LedgerDirection,
PolicyStatus,
Role,
ServiceKind,
TransactionDomain,
TrustStatus,
} from "./types";
/** Access tiers, high → low. VIEWER is read-only; STAFF+ can write. */
export const ROLE_LABEL: Record<Role, string> = {
ADMIN: "Administrador",
MANAGER: "Gerente",
STAFF: "Personal",
VIEWER: "Solo lectura",
};
/** Roles in descending rank — for populating role <select>s. */
export const ROLES_DESC: Role[] = ["ADMIN", "MANAGER", "STAFF", "VIEWER"];
/**
* Placeholder the migration writes when a legacy record had no name and none
* could be recovered from a secondary table (migration/transform_customers.py).
+33 -1
View File
@@ -1,12 +1,44 @@
// TypeScript types for the Jorge Cuadros & Asociados API responses.
// Decimals arrive as strings, dates as ISO strings.
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
export type Ability =
| "customer:create"
| "customer:update"
| "customer:delete"
| "policy:create"
| "policy:update"
| "policy:delete"
| "property:create"
| "property:update"
| "property:delete"
| "ledger:create"
| "ledger:void"
| "bank:create"
| "bank:void"
| "lookup:manage"
| "user:manage";
export interface AuthUser {
id: string;
name: string;
email: string;
role: string;
role: Role;
active: boolean;
// Resolved server-side from role (abilitiesFor in the API); the UI only ever
// reads this map, never re-derives the rules. Server still enforces.
abilities: Record<Ability, boolean>;
}
export interface UserRow {
id: string;
name: string;
email: string;
role: Role;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface CustomerStats {