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:
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
|
import { CommonModule } from "./common/common.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { CustomersModule } from "./customers/customers.module";
|
import { CustomersModule } from "./customers/customers.module";
|
||||||
@@ -14,6 +15,7 @@ import { AppController } from "./app.controller";
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
CommonModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,14 @@ import { Request, Response } from "express";
|
|||||||
import { LocalAuthGuard } from "./local-auth.guard";
|
import { LocalAuthGuard } from "./local-auth.guard";
|
||||||
import { AuthenticatedGuard } from "./authenticated.guard";
|
import { AuthenticatedGuard } from "./authenticated.guard";
|
||||||
import { LoginDto } from "./login.dto";
|
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")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -13,13 +21,13 @@ export class AuthController {
|
|||||||
@Post("login")
|
@Post("login")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
|
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
|
||||||
return req.user;
|
return withAbilities(req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard)
|
||||||
@Get("me")
|
@Get("me")
|
||||||
me(@Req() req: Request) {
|
me(@Req() req: Request) {
|
||||||
return req.user;
|
return withAbilities(req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("logout")
|
@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);
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { AuditService } from "./audit.service";
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class CommonModule {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
|
import { UsersController } from "./users.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
|
controllers: [UsersController],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@@ -1,11 +1,35 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import {
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import * as argon2 from "argon2";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import type { User } 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()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// --- used by auth (need the hash / full row) -----------------------------
|
||||||
|
|
||||||
findByEmail(email: string): Promise<User | null> {
|
findByEmail(email: string): Promise<User | null> {
|
||||||
return this.prisma.user.findUnique({ where: { email } });
|
return this.prisma.user.findUnique({ where: { email } });
|
||||||
}
|
}
|
||||||
@@ -13,4 +37,89 @@ export class UsersService {
|
|||||||
findById(id: string): Promise<User | null> {
|
findById(id: string): Promise<User | null> {
|
||||||
return this.prisma.user.findUnique({ where: { id } });
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,12 +267,46 @@ button {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(242, 239, 231, 0.7);
|
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) {
|
@media (max-width: 640px) {
|
||||||
.appbar-user .appbar-user-name {
|
.appbar-user .appbar-user-name {
|
||||||
display: none;
|
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
|
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 { usePathname, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { logout, me } from "@/lib/api";
|
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
|
* Authenticated shell: gates on /auth/me, redirects to /login when the
|
||||||
* session is missing, renders the brand header + logout, and wraps page
|
* 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: "/clientes", label: "Clientes" },
|
||||||
{ href: "/servicios", label: "Propiedades" },
|
{ href: "/servicios", label: "Propiedades" },
|
||||||
{ href: "/polizas", label: "Pólizas" },
|
{ href: "/polizas", label: "Pólizas" },
|
||||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||||
{ href: "/banco", label: "Chequera" },
|
{ href: "/banco", label: "Chequera" },
|
||||||
|
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
@@ -69,7 +73,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AuthContext.Provider value={user}>
|
||||||
<header className="appbar">
|
<header className="appbar">
|
||||||
<div className="appbar-inner">
|
<div className="appbar-inner">
|
||||||
<Link href="/clientes" className="brand">
|
<Link href="/clientes" className="brand">
|
||||||
@@ -82,7 +86,8 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="appbar-nav" aria-label="Principal">
|
<nav className="appbar-nav" aria-label="Principal">
|
||||||
{NAV.map((item) => {
|
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
||||||
|
(item) => {
|
||||||
const active = pathname?.startsWith(item.href) ?? false;
|
const active = pathname?.startsWith(item.href) ?? false;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -94,12 +99,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<span className="appbar-spacer" />
|
<span className="appbar-spacer" />
|
||||||
<div className="appbar-user">
|
<div className="appbar-user">
|
||||||
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -113,6 +122,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="shell-main">{children}</main>
|
<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,
|
PropertyListResponse,
|
||||||
PropertySort,
|
PropertySort,
|
||||||
PropertyStats,
|
PropertyStats,
|
||||||
|
Role,
|
||||||
ServiceKind,
|
ServiceKind,
|
||||||
Statement,
|
Statement,
|
||||||
TransactionDomain,
|
TransactionDomain,
|
||||||
TrustFilter,
|
TrustFilter,
|
||||||
|
UserRow,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const API_ORIGIN =
|
export const API_ORIGIN =
|
||||||
@@ -341,3 +343,45 @@ export function getBankFacets(): Promise<BankFacets> {
|
|||||||
export function getBankSummary(year?: number): Promise<BankSummary> {
|
export function getBankSummary(year?: number): Promise<BankSummary> {
|
||||||
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
|
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,
|
BankDirection,
|
||||||
LedgerDirection,
|
LedgerDirection,
|
||||||
PolicyStatus,
|
PolicyStatus,
|
||||||
|
Role,
|
||||||
ServiceKind,
|
ServiceKind,
|
||||||
TransactionDomain,
|
TransactionDomain,
|
||||||
TrustStatus,
|
TrustStatus,
|
||||||
} from "./types";
|
} 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
|
* Placeholder the migration writes when a legacy record had no name and none
|
||||||
* could be recovered from a secondary table (migration/transform_customers.py).
|
* could be recovered from a secondary table (migration/transform_customers.py).
|
||||||
|
|||||||
@@ -1,12 +1,44 @@
|
|||||||
// TypeScript types for the Jorge Cuadros & Asociados API responses.
|
// TypeScript types for the Jorge Cuadros & Asociados API responses.
|
||||||
// Decimals arrive as strings, dates as ISO strings.
|
// 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 {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: Role;
|
||||||
active: boolean;
|
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 {
|
export interface CustomerStats {
|
||||||
|
|||||||
@@ -37,9 +37,14 @@ enum ServiceKind {
|
|||||||
OTHER
|
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 {
|
enum UserRole {
|
||||||
ADMIN
|
ADMIN
|
||||||
|
MANAGER
|
||||||
STAFF
|
STAFF
|
||||||
|
VIEWER
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user