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
+2
View File
@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./prisma/prisma.module";
import { CommonModule } from "./common/common.module";
import { UsersModule } from "./users/users.module";
import { AuthModule } from "./auth/auth.module";
import { CustomersModule } from "./customers/customers.module";
@@ -14,6 +15,7 @@ import { AppController } from "./app.controller";
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
CommonModule,
UsersModule,
AuthModule,
CustomersModule,
+67
View File
@@ -0,0 +1,67 @@
// Server-authoritative permission matrix. Roles form an ordered rank
// (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every
// write action carries a minimum rank. VIEWER holds rank 0 and is the
// read-only role. Reads are not listed here — they stay on AuthenticatedGuard
// alone, so any logged-in user (including VIEWER) can read.
//
// This is the single source of truth: the API enforces it via AbilityGuard and
// ships the resolved per-user map to the web through /auth/me, so the UI never
// keeps its own copy of the rules.
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
export const ROLE_RANK: Record<Role, number> = {
VIEWER: 0,
STAFF: 1,
MANAGER: 2,
ADMIN: 3,
};
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";
/** Minimum role required for each ability. */
export const ABILITY_MIN: Record<Ability, Role> = {
"customer:create": "STAFF",
"customer:update": "STAFF",
"customer:delete": "ADMIN",
"policy:create": "STAFF",
"policy:update": "STAFF",
"policy:delete": "MANAGER",
"property:create": "STAFF",
"property:update": "STAFF",
"property:delete": "MANAGER",
"ledger:create": "STAFF",
"ledger:void": "MANAGER",
"bank:create": "STAFF",
"bank:void": "MANAGER",
"lookup:manage": "MANAGER",
"user:manage": "ADMIN",
};
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
export function can(role: Role, ability: Ability): boolean {
return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]];
}
/** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */
export function abilitiesFor(role: Role): Record<Ability, boolean> {
return Object.fromEntries(
ALL_ABILITIES.map((a) => [a, can(role, a)]),
) as Record<Ability, boolean>;
}
+36
View File
@@ -0,0 +1,36 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { ABILITY_KEY } from "./require-ability.decorator";
import { Ability, Role, can } from "./abilities";
/**
* Enforces the ability matrix (abilities.ts) against req.user.role. A route
* with no @RequireAbility passes through untouched — this guard only gates the
* routes that declare one. It does NOT check authentication; always list it
* after AuthenticatedGuard so an unauthenticated request is rejected first.
*/
@Injectable()
export class AbilityGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const ability = this.reflector.getAllAndOverride<Ability | undefined>(
ABILITY_KEY,
[context.getHandler(), context.getClass()],
);
if (!ability) return true;
const req = context.switchToHttp().getRequest<Request>();
const user = req.user as { role?: Role } | undefined;
if (!user?.role || !can(user.role, ability)) {
throw new ForbiddenException("No tiene permisos para esta acción");
}
return true;
}
}
+10 -2
View File
@@ -3,6 +3,14 @@ import { Request, Response } from "express";
import { LocalAuthGuard } from "./local-auth.guard";
import { AuthenticatedGuard } from "./authenticated.guard";
import { LoginDto } from "./login.dto";
import { abilitiesFor, Role } from "./abilities";
/** Attach the resolved ability map so the web can gate its UI off one payload. */
function withAbilities(user: unknown) {
const u = user as { role?: Role } | undefined;
if (!u?.role) return u;
return { ...u, abilities: abilitiesFor(u.role) };
}
@Controller("auth")
export class AuthController {
@@ -13,13 +21,13 @@ export class AuthController {
@Post("login")
@HttpCode(200)
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
return req.user;
return withAbilities(req.user);
}
@UseGuards(AuthenticatedGuard)
@Get("me")
me(@Req() req: Request) {
return req.user;
return withAbilities(req.user);
}
@Post("logout")
@@ -0,0 +1,12 @@
import { SetMetadata } from "@nestjs/common";
import type { Ability } from "./abilities";
export const ABILITY_KEY = "required_ability";
/**
* Tags a write route with the ability it requires. Pair with
* `@UseGuards(AuthenticatedGuard, AbilityGuard)` — AuthenticatedGuard proves
* the session, AbilityGuard checks this ability against the user's role.
*/
export const RequireAbility = (ability: Ability) =>
SetMetadata(ABILITY_KEY, ability);
+34
View File
@@ -0,0 +1,34 @@
import { Injectable } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
/**
* Thin writer over the existing ActivityLog model. Every mutating route calls
* this so who-did-what is recorded — the structural replacement for the old
* PHP app's scattered Logger calls. Best-effort: a logging failure must never
* fail the underlying write, so callers `void audit.log(...)` without awaiting.
*/
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(
userId: string | null | undefined,
event: string,
message?: Record<string, unknown>,
level = "info",
): Promise<void> {
try {
await this.prisma.activityLog.create({
data: {
userId: userId ?? undefined,
event,
level,
message: (message as Prisma.InputJsonValue) ?? undefined,
},
});
} catch {
/* never let audit logging break a real write */
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { AuditService } from "./audit.service";
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class CommonModule {}
+21
View File
@@ -0,0 +1,21 @@
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
import { UserRole } from "@jorgecuadros/database";
export class CreateUserDto {
@IsString()
@MinLength(1)
name!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
@IsEnum(UserRole)
role!: UserRole;
@IsOptional()
active?: boolean;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class ResetPasswordDto {
@IsString()
@MinLength(8)
password!: string;
}
+22
View File
@@ -0,0 +1,22 @@
import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
import { UserRole } from "@jorgecuadros/database";
/** Password changes go through the dedicated reset-password route, not here. */
export class UpdateUserDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@IsOptional()
@IsBoolean()
active?: boolean;
}
+72
View File
@@ -0,0 +1,72 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import { UsersService } from "./users.service";
import { CreateUserDto } from "./create-user.dto";
import { UpdateUserDto } from "./update-user.dto";
import { ResetPasswordDto } from "./reset-password.dto";
/** Every route here is ADMIN-only (ability "user:manage"). */
@UseGuards(AuthenticatedGuard, AbilityGuard)
@RequireAbility("user:manage")
@Controller("users")
export class UsersController {
constructor(
private readonly users: UsersService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get()
list() {
return this.users.list();
}
@Post()
async create(@Body() dto: CreateUserDto, @Req() req: Request) {
const user = await this.users.create(dto);
void this.audit.log(this.actingId(req), "user.create", {
userId: user.id,
email: user.email,
role: user.role,
});
return user;
}
@Patch(":id")
async update(
@Param("id") id: string,
@Body() dto: UpdateUserDto,
@Req() req: Request,
) {
const user = await this.users.update(id, dto, this.actingId(req));
void this.audit.log(this.actingId(req), "user.update", { userId: id, changes: dto });
return user;
}
@Post(":id/reset-password")
async resetPassword(
@Param("id") id: string,
@Body() dto: ResetPasswordDto,
@Req() req: Request,
) {
const user = await this.users.resetPassword(id, dto.password);
void this.audit.log(this.actingId(req), "user.reset_password", { userId: id });
return user;
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";
import { UsersController } from "./users.controller";
@Module({
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}
+111 -2
View File
@@ -1,11 +1,35 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import * as argon2 from "argon2";
import { Prisma } from "@jorgecuadros/database";
import type { User } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateUserDto } from "./create-user.dto";
import { UpdateUserDto } from "./update-user.dto";
/** Shape returned to the UI — never carries passwordHash. */
const safeSelect = {
id: true,
name: true,
email: true,
role: true,
active: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.UserSelect;
export type SafeUserRow = Prisma.UserGetPayload<{ select: typeof safeSelect }>;
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
// --- used by auth (need the hash / full row) -----------------------------
findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
@@ -13,4 +37,89 @@ export class UsersService {
findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
// --- admin CRUD (safe rows only) -----------------------------------------
list(): Promise<SafeUserRow[]> {
return this.prisma.user.findMany({
orderBy: [{ active: "desc" }, { name: "asc" }],
select: safeSelect,
});
}
async create(dto: CreateUserDto): Promise<SafeUserRow> {
const passwordHash = await argon2.hash(dto.password);
try {
return await this.prisma.user.create({
data: {
name: dto.name,
email: dto.email,
passwordHash,
role: dto.role,
active: dto.active ?? true,
},
select: safeSelect,
});
} catch (e) {
throw this.mapError(e);
}
}
/**
* `actingUserId` is the admin making the change — used to stop an admin from
* locking themselves out (deactivating or demoting their own account).
*/
async update(
id: string,
dto: UpdateUserDto,
actingUserId: string,
): Promise<SafeUserRow> {
await this.ensureExists(id);
if (id === actingUserId) {
if (dto.active === false) {
throw new BadRequestException("No puede desactivar su propia cuenta");
}
if (dto.role && dto.role !== "ADMIN") {
throw new BadRequestException("No puede quitarse su propio rol de administrador");
}
}
try {
return await this.prisma.user.update({
where: { id },
data: {
name: dto.name,
email: dto.email,
role: dto.role,
active: dto.active,
},
select: safeSelect,
});
} catch (e) {
throw this.mapError(e);
}
}
async resetPassword(id: string, password: string): Promise<SafeUserRow> {
await this.ensureExists(id);
const passwordHash = await argon2.hash(password);
return this.prisma.user.update({
where: { id },
data: { passwordHash },
select: safeSelect,
});
}
private async ensureExists(id: string): Promise<void> {
const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`);
}
private mapError(e: unknown): Error {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
return new ConflictException("Ya existe un usuario con ese correo");
}
return e as Error;
}
}
+34
View File
@@ -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
========================================================================== */
+327
View File
@@ -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 . 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>
</>
);
}
+28 -19
View File
@@ -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>
);
}
+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 {
+5
View File
@@ -37,9 +37,14 @@ enum ServiceKind {
OTHER
}
/// Ordered access tier (rank): ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the
/// read-only role; STAFF and above can write. Enforced by the API's ability
/// matrix (apps/api/src/auth/abilities.ts), not by the enum itself.
enum UserRole {
ADMIN
MANAGER
STAFF
VIEWER
}
// ---------------------------------------------------------------------------