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:
@@ -267,12 +267,46 @@ button {
|
||||
font-size: 13px;
|
||||
color: rgba(242, 239, 231, 0.7);
|
||||
}
|
||||
.appbar-user-name {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.appbar-user-role {
|
||||
font-size: 11px;
|
||||
color: rgba(242, 239, 231, 0.45);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.appbar-user .appbar-user-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Admin form grid (users, and future CRUD forms)
|
||||
========================================================================== */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.inline-form-note {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #6b7280);
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Buttons
|
||||
========================================================================== */
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useAuth, useCan } from "@/lib/abilities";
|
||||
import { ROLE_LABEL, ROLES_DESC } from "@/lib/labels";
|
||||
import {
|
||||
createUser,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
} from "@/lib/api";
|
||||
import type { Role, UserRow } from "@/lib/types";
|
||||
|
||||
export default function UsuariosPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<UsuariosAdmin />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "STAFF",
|
||||
active: true,
|
||||
};
|
||||
|
||||
function UsuariosAdmin() {
|
||||
const me = useAuth();
|
||||
const allowed = useCan("user:manage");
|
||||
|
||||
const [users, setUsers] = useState<UserRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
// null = create mode; a user id = editing that row.
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Inline "reset password" target + value.
|
||||
const [pwTarget, setPwTarget] = useState<string | null>(null);
|
||||
const [pwValue, setPwValue] = useState("");
|
||||
|
||||
function refresh() {
|
||||
listUsers()
|
||||
.then(setUsers)
|
||||
.catch((e) => setError(e?.message ?? "No se pudieron cargar los usuarios."));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (allowed) refresh();
|
||||
}, [allowed]);
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="page-head">
|
||||
<h1 className="page-title">Usuarios</h1>
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para administrar usuarios.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function startEdit(u: UserRow) {
|
||||
setEditingId(u.id);
|
||||
setForm({ name: u.name, email: u.email, password: "", role: u.role, active: u.active });
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (editingId) {
|
||||
await updateUser(editingId, {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
});
|
||||
setNotice("Usuario actualizado.");
|
||||
} else {
|
||||
await createUser({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
});
|
||||
setNotice("Usuario creado.");
|
||||
}
|
||||
startCreate();
|
||||
refresh();
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo guardar el usuario.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPassword(id: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await resetUserPassword(id, pwValue);
|
||||
setPwTarget(null);
|
||||
setPwValue("");
|
||||
setNotice("Contraseña restablecida.");
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo restablecer la contraseña.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<h1 className="page-title">Usuarios</h1>
|
||||
</div>
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
{notice && <div className="state-box">{notice}</div>}
|
||||
|
||||
{/* Create / edit form */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 4 }}>
|
||||
{editingId ? "Editar usuario" : "Nuevo usuario"}
|
||||
</h2>
|
||||
<p className="inline-form-note">
|
||||
El rol define el acceso: Solo lectura no puede escribir; Personal y
|
||||
superior sí. Administrador gestiona usuarios.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Nombre</span>
|
||||
<input
|
||||
className="input"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Correo</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{!editingId && (
|
||||
<label className="field">
|
||||
<span className="field-label">Contraseña (mín. 8)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
<span className="field-label">Rol</span>
|
||||
<select
|
||||
className="select"
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as Role })}
|
||||
>
|
||||
{ROLES_DESC.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{ROLE_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field" style={{ justifyContent: "flex-end" }}>
|
||||
<span className="field-label">Activo</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
disabled={editingId === me?.id}
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? "Guardando…" : editingId ? "Guardar cambios" : "Crear usuario"}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" className="btn btn-outline" onClick={startCreate}>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="card">
|
||||
{users === null ? (
|
||||
<div className="empty-inline">
|
||||
<span className="spinner" aria-label="Cargando" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-inline">Sin usuarios.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Correo</th>
|
||||
<th>Rol</th>
|
||||
<th>Estado</th>
|
||||
<th className="num">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
{u.name}
|
||||
{u.id === me?.id && (
|
||||
<span className="muted"> (usted)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{u.email}</td>
|
||||
<td>
|
||||
<span className="badge badge-neutral">{ROLE_LABEL[u.role]}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${u.active ? "badge-positive" : "badge-negative"}`}
|
||||
>
|
||||
{u.active ? "Activo" : "Inactivo"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{pwTarget === u.id ? (
|
||||
<div className="row-actions">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Nueva contraseña"
|
||||
minLength={8}
|
||||
value={pwValue}
|
||||
onChange={(e) => setPwValue(e.target.value)}
|
||||
style={{ maxWidth: 180 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
disabled={pwValue.length < 8}
|
||||
onClick={() => submitPassword(u.id)}
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPwTarget(null);
|
||||
setPwValue("");
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
onClick={() => startEdit(u)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPwTarget(u.id);
|
||||
setPwValue("");
|
||||
}}
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,23 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { logout, me } from "@/lib/api";
|
||||
import type { AuthUser } from "@/lib/types";
|
||||
import { AuthContext, can } from "@/lib/abilities";
|
||||
import { ROLE_LABEL } from "@/lib/labels";
|
||||
import type { AuthUser, Ability } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Authenticated shell: gates on /auth/me, redirects to /login when the
|
||||
* session is missing, renders the brand header + logout, and wraps page
|
||||
* content. Used by every authenticated page.
|
||||
* content. Provides the AuthContext so any page can read the user's
|
||||
* abilities. Used by every authenticated page.
|
||||
*/
|
||||
const NAV = [
|
||||
const NAV: { href: string; label: string; ability?: Ability }[] = [
|
||||
{ href: "/clientes", label: "Clientes" },
|
||||
{ href: "/servicios", label: "Propiedades" },
|
||||
{ href: "/polizas", label: "Pólizas" },
|
||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||
];
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
@@ -69,7 +73,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthContext.Provider value={user}>
|
||||
<header className="appbar">
|
||||
<div className="appbar-inner">
|
||||
<Link href="/clientes" className="brand">
|
||||
@@ -82,24 +86,29 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="appbar-nav" aria-label="Principal">
|
||||
{NAV.map((item) => {
|
||||
const active = pathname?.startsWith(item.href) ?? false;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`appbar-link${active ? " active" : ""}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
||||
(item) => {
|
||||
const active = pathname?.startsWith(item.href) ?? false;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`appbar-link${active ? " active" : ""}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</nav>
|
||||
<span className="appbar-spacer" />
|
||||
<div className="appbar-user">
|
||||
{user && (
|
||||
<span className="appbar-user-name">{user.name}</span>
|
||||
<span className="appbar-user-name">
|
||||
{user.name}
|
||||
<span className="appbar-user-role">{ROLE_LABEL[user.role]}</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -113,6 +122,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
</header>
|
||||
<main className="shell-main">{children}</main>
|
||||
</>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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