{# Panel de usuarios + roles + audit. Contrato con el router `make_users_router`: mount_prefix → prefijo del router (ej: "/admin/users") catalog → dict {permission_key: human_label} directory_caps → capacidades del directorio de identidades (opcional) Todos los endpoints son relativos a `mount_prefix`: GET {mount_prefix}/data → JSON users POST {mount_prefix}/invite → form invite POST {mount_prefix}/create → form crear con password PATCH {mount_prefix}/{user_id}/roles → JSON roles POST {mount_prefix}/{user_id}/password/reset POST {mount_prefix}/{user_id}/password/set DELETE {mount_prefix}/{user_id} GET {mount_prefix}/{user_id}/effective-permissions GET {mount_prefix}/roles POST {mount_prefix}/roles PATCH {mount_prefix}/roles/{slug}/permissions DELETE {mount_prefix}/roles/{slug} GET {mount_prefix}/audit La página carga los datos en JS vía fetch para evitar acoplar el render server-side a un layout específico del host. El host es responsable de envolver este template con su propio layout (sidebar, header, etc.) y de cargar `htmx-events.js` (dgConfirm), `toasts.js` (dgToast; sin la región `#dg-toasts` los avisos van a alert()) y `reveal.js` (mostrar contraseña). `sidebar_shell` ya los carga. Los estilos propios viven en el bloque dg-users de `_tailwind.source.css`; pila, pestañas, diálogos, avisos y campos son componentes del paquete. #} {% from "core/components/page_header.html" import page_header %} {% from "core/components/search_field.html" import search_field %} {% from "core/components/icon.html" import icon, icon_tile %} {% from "core/components/skeleton.html" import skeleton %} {% set base = mount_prefix | default('/admin/users') %} {# Capabilities del directorio de identidades (Supabase/Clerk). El default replica el set completo de Supabase para contextos que no las pasan. #} {% set caps = directory_caps | default(['invite', 'create_password', 'password_reset_email', 'password_set', 'delete']) %} {# `cols` lleva la clase de cada columna: las que se ocultan en anchos estrechos deben ocultarse también en el esqueleto o aparece una columna sin cabecera. La fecha de Actividad lleva ancho fijo: al apilarse, su celda se ajusta al contenido y un porcentaje la dejaría a cero. #} {% macro _skeleton_rows(cols, lead=false) -%} {%- for _ in range(3) %} {%- for col_class in cols %} {%- if loop.first and lead %}{{ skeleton("2rem", "2rem", "dg-users-skel") }}{{ skeleton("60%", "0.75rem", "dg-users-skel") }} {%- elif loop.first %}{{ skeleton("7rem", "0.75rem", "dg-users-skel") }} {%- else %}{{ skeleton("60%", "0.75rem", "dg-users-skel") }}{% endif -%} {%- endfor %} {%- endfor %} {%- endmacro %} {# El subtítulo describe el diálogo (aria-describedby="-subtitle"). #} {% macro _dialog_head(id, tile, title, tone="neutral") -%}
{{ icon_tile(tile, tone=tone) }}

{{ title }}

{{ caller() }}

{%- endmacro %} {% macro _dialog_foot(label, icon_name=none) -%}
{%- endmacro %} {% macro _form_error() -%} {%- endmacro %} {% set _meta = [ {"icon": "users", "label": '… usuarios' | safe}, {"icon": "circle-dashed", "label": '' | safe}, ] %}
{% call page_header(title="Usuarios", subtitle="Cuentas con acceso a la app, sus roles y los permisos que les dan.", icon="users", meta=_meta) %} {% endcall %}
{{ search_field("dg-users-search", placeholder="Buscar por correo o rol", label="Buscar usuario", attrs="data-dg-users-search", shortcut="/") }}
{{ _skeleton_rows(['', 'dg-users-col-roles', 'dg-users-col-act'], lead=true) }}
Usuario Roles Acciones

{# Sin nada enfocable dentro, el panel se enfoca para que el teclado llegue a la tabla (patrón de pestañas de la APG). #} {# Un único menú para todas las filas: el JS lo coloca junto al botón que lo abre y guarda en él a qué usuario se refiere. #} {# ---------- Modales ---------- Anatomía del paquete (.dg-dialog-flush): cabecera con tile, cuerpo con scroll propio y pie tintado fijo, dentro del
(.dg-dialog-form). #} {% call _dialog_head("dg-invite", "user-plus", "Invitar usuario", tone="brand") %}Recibirá un correo con un enlace para activar su cuenta.{% endcall %}
{# Sin autocompletar: el navegador propondría el correo del propio admin. #}
Roles
{{ _form_error() }}
{{ _dialog_foot("Enviar invitación", "send") }}
{% call _dialog_head("dg-create", "lock", "Crear usuario con contraseña", tone="brand") %}La cuenta queda activa al momento y no se envía ningún correo.{% endcall %}
{# El gestor de contraseñas no debe rellenar aquí las credenciales del admin. #}
Mínimo 8 caracteres, una mayúscula y un número.
Roles
{{ _form_error() }}
{{ _dialog_foot("Crear usuario", "user-plus") }}
{% call _dialog_head("dg-edit-roles", "shield-check", "Editar roles") %}{% endcall %}
Roles

Sus permisos son la suma de los de todos sus roles.

{{ _form_error() }}
{{ _dialog_foot("Guardar", "save") }}
{% call _dialog_head("dg-set-pwd", "lock", "Fijar contraseña") %}{% endcall %}

{{ icon("info") }}La contraseña se aplica al momento y no se envía ningún correo: comunícasela por un canal seguro.

Mínimo 8 caracteres, una mayúscula y un número.
{{ _form_error() }}
{{ _dialog_foot("Aplicar contraseña", "check") }}
{% call _dialog_head("dg-effective", "eye", "Permisos efectivos") %}{% endcall %}
    {% call _dialog_head("dg-edit-role-perms", "shield-check", "Editar permisos del rol") %}{% endcall %}
    Permisos
    {{ _form_error() }}
    {{ _dialog_foot("Guardar", "save") }}
    {% call _dialog_head("dg-create-role", "shield-check", "Crear rol", tone="brand") %}Agrupa permisos para asignarlos de una vez.{% endcall %}
    Minúsculas, números, guion y guion bajo. Empieza por una letra.
    Permisos
    {{ _form_error() }}
    {{ _dialog_foot("Crear rol", "plus") }}
    {% set _audit_icons = ["send", "user-plus", "shield-check", "user-minus", "mail", "lock", "trash-2", "plus", "pencil", "activity"] %}