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:
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user