From 74e2ad8bcd92c1f75c6051eea0da698864547243 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 23 Jul 2026 12:02:00 -0700 Subject: [PATCH 01/10] 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 --- apps/api/src/app.module.ts | 2 + apps/api/src/auth/abilities.ts | 67 ++++ apps/api/src/auth/ability.guard.ts | 36 ++ apps/api/src/auth/auth.controller.ts | 12 +- .../api/src/auth/require-ability.decorator.ts | 12 + apps/api/src/common/audit.service.ts | 34 ++ apps/api/src/common/common.module.ts | 9 + apps/api/src/users/create-user.dto.ts | 21 ++ apps/api/src/users/reset-password.dto.ts | 7 + apps/api/src/users/update-user.dto.ts | 22 ++ apps/api/src/users/users.controller.ts | 72 ++++ apps/api/src/users/users.module.ts | 2 + apps/api/src/users/users.service.ts | 113 +++++- apps/web/src/app/globals.css | 34 ++ apps/web/src/app/usuarios/page.tsx | 327 ++++++++++++++++++ apps/web/src/components/AppShell.tsx | 47 ++- apps/web/src/lib/abilities.ts | 24 ++ apps/web/src/lib/api.ts | 44 +++ apps/web/src/lib/labels.ts | 12 + apps/web/src/lib/types.ts | 34 +- packages/database/prisma/schema.prisma | 5 + 21 files changed, 912 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/auth/abilities.ts create mode 100644 apps/api/src/auth/ability.guard.ts create mode 100644 apps/api/src/auth/require-ability.decorator.ts create mode 100644 apps/api/src/common/audit.service.ts create mode 100644 apps/api/src/common/common.module.ts create mode 100644 apps/api/src/users/create-user.dto.ts create mode 100644 apps/api/src/users/reset-password.dto.ts create mode 100644 apps/api/src/users/update-user.dto.ts create mode 100644 apps/api/src/users/users.controller.ts create mode 100644 apps/web/src/app/usuarios/page.tsx create mode 100644 apps/web/src/lib/abilities.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ce04fa0..2ecc723 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/auth/abilities.ts b/apps/api/src/auth/abilities.ts new file mode 100644 index 0000000..c7395aa --- /dev/null +++ b/apps/api/src/auth/abilities.ts @@ -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 = { + 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 = { + "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 { + return Object.fromEntries( + ALL_ABILITIES.map((a) => [a, can(role, a)]), + ) as Record; +} diff --git a/apps/api/src/auth/ability.guard.ts b/apps/api/src/auth/ability.guard.ts new file mode 100644 index 0000000..ee28951 --- /dev/null +++ b/apps/api/src/auth/ability.guard.ts @@ -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_KEY, + [context.getHandler(), context.getClass()], + ); + if (!ability) return true; + + const req = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 197f09c..f0403e1 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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") diff --git a/apps/api/src/auth/require-ability.decorator.ts b/apps/api/src/auth/require-ability.decorator.ts new file mode 100644 index 0000000..062d04a --- /dev/null +++ b/apps/api/src/auth/require-ability.decorator.ts @@ -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); diff --git a/apps/api/src/common/audit.service.ts b/apps/api/src/common/audit.service.ts new file mode 100644 index 0000000..c1fce00 --- /dev/null +++ b/apps/api/src/common/audit.service.ts @@ -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, + level = "info", + ): Promise { + 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 */ + } + } +} diff --git a/apps/api/src/common/common.module.ts b/apps/api/src/common/common.module.ts new file mode 100644 index 0000000..cd5a037 --- /dev/null +++ b/apps/api/src/common/common.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { AuditService } from "./audit.service"; + +@Global() +@Module({ + providers: [AuditService], + exports: [AuditService], +}) +export class CommonModule {} diff --git a/apps/api/src/users/create-user.dto.ts b/apps/api/src/users/create-user.dto.ts new file mode 100644 index 0000000..06a5db8 --- /dev/null +++ b/apps/api/src/users/create-user.dto.ts @@ -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; +} diff --git a/apps/api/src/users/reset-password.dto.ts b/apps/api/src/users/reset-password.dto.ts new file mode 100644 index 0000000..c103899 --- /dev/null +++ b/apps/api/src/users/reset-password.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from "class-validator"; + +export class ResetPasswordDto { + @IsString() + @MinLength(8) + password!: string; +} diff --git a/apps/api/src/users/update-user.dto.ts b/apps/api/src/users/update-user.dto.ts new file mode 100644 index 0000000..e9e4b41 --- /dev/null +++ b/apps/api/src/users/update-user.dto.ts @@ -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; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..9f641cf --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -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; + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 82b5091..29bec3f 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -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 {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index e7c8927..59f8465 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 { return this.prisma.user.findUnique({ where: { email } }); } @@ -13,4 +37,89 @@ export class UsersService { findById(id: string): Promise { return this.prisma.user.findUnique({ where: { id } }); } + + // --- admin CRUD (safe rows only) ----------------------------------------- + + list(): Promise { + return this.prisma.user.findMany({ + orderBy: [{ active: "desc" }, { name: "asc" }], + select: safeSelect, + }); + } + + async create(dto: CreateUserDto): Promise { + 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 { + 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 { + 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 { + 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; + } } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f91f699..765b09e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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 ========================================================================== */ diff --git a/apps/web/src/app/usuarios/page.tsx b/apps/web/src/app/usuarios/page.tsx new file mode 100644 index 0000000..466e927 --- /dev/null +++ b/apps/web/src/app/usuarios/page.tsx @@ -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 ( + + + + ); +} + +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(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + // null = create mode; a user id = editing that row. + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + + // Inline "reset password" target + value. + const [pwTarget, setPwTarget] = useState(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 ( +
+

Usuarios

+
+ No tiene permisos para administrar usuarios. +
+
+ ); + } + + 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 ( + <> +
+

Usuarios

+
+ + {error &&
{error}
} + {notice &&
{notice}
} + + {/* Create / edit form */} +
+

+ {editingId ? "Editar usuario" : "Nuevo usuario"} +

+

+ El rol define el acceso: Solo lectura no puede escribir; Personal y + superior sí. Administrador gestiona usuarios. +

+
+
+ + + {!editingId && ( + + )} + + +
+
+ + {editingId && ( + + )} +
+
+
+ + {/* List */} +
+ {users === null ? ( +
+ +
+ ) : users.length === 0 ? ( +
Sin usuarios.
+ ) : ( +
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
NombreCorreoRolEstadoAcciones
+ {u.name} + {u.id === me?.id && ( + (usted) + )} + {u.email} + {ROLE_LABEL[u.role]} + + + {u.active ? "Activo" : "Inactivo"} + + + {pwTarget === u.id ? ( +
+ setPwValue(e.target.value)} + style={{ maxWidth: 180 }} + /> + + +
+ ) : ( +
+ + +
+ )} +
+
+ )} +
+ + ); +} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 485e45c..6151c2f 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -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 ( - <> +
@@ -82,24 +86,29 @@ export function AppShell({ children }: { children: ReactNode }) {
{user && ( - {user.name} + + {user.name} + {ROLE_LABEL[user.role]} + )}
{children}
- +
); } diff --git a/apps/web/src/lib/abilities.ts b/apps/web/src/lib/abilities.ts new file mode 100644 index 0000000..7c86f09 --- /dev/null +++ b/apps/web/src/lib/abilities.ts @@ -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(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; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index e65d1b4..571344c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 { export function getBankSummary(year?: number): Promise { return apiFetch(`/bank/summary${year ? `?year=${year}` : ""}`); } + +/* ------------------------------------------------- Users / administration */ + +export function listUsers(): Promise { + return apiFetch("/users"); +} + +export interface CreateUserInput { + name: string; + email: string; + password: string; + role: Role; + active?: boolean; +} + +export function createUser(input: CreateUserInput): Promise { + return apiFetch("/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 { + return apiFetch(`/users/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export function resetUserPassword(id: string, password: string): Promise { + return apiFetch(`/users/${id}/reset-password`, { + method: "POST", + body: JSON.stringify({ password }), + }); +} diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index dea0cf5..3134373 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -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 = { + ADMIN: "Administrador", + MANAGER: "Gerente", + STAFF: "Personal", + VIEWER: "Solo lectura", +}; + +/** Roles in descending rank — for populating role set("name", e.target.value)} /> + + + set("email", e.target.value)} /> + + + set("phone", e.target.value)} /> + + + set("mobile", e.target.value)} /> + + + set("fax", e.target.value)} /> + + + set("customerSince", e.target.value)} /> + + + + +
+

Domicilio

+
+ + set("addressLine1", e.target.value)} /> + + + set("addressLine2", e.target.value)} /> + + + set("city", e.target.value)} /> + + + set("state", e.target.value)} /> + + + set("zipCode", e.target.value)} /> + + + set("country", e.target.value)} /> + +
+
+ +
+

+ Identificación y cuenta +

+
+ + set("identificationType", e.target.value)} /> + + + set("identificationNumber", e.target.value)} /> + + + set("identificationExpiration", e.target.value)} /> + + + + + + set("minimumBalance", e.target.value)} /> + + + set("feeAmount", e.target.value)} /> + + + set("status", e.target.checked)} /> + +
+