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