Saltar a contenido

Las pantallas: el cuadro de luces

Los pasos 3 y 4 del protocolo. La declaración de pantallas es el sitio donde se decide qué pantallas tiene la aplicación: su ruta, cómo se llama en el menú, su icono, si está activa o en construcción y qué permiso hace falta para entrar. Se declara cada pantalla una vez, y de esa declaración salen su protección, el menú y las migas.

El cliente trae la maquinaria; la declaración es tuya: tai-api generate no la toca.

1. Declarar las pantallas

La declaración es la misma en todos los frameworks. Lo único que cambia es si lleva el componente de cada pantalla: en React sí, porque de ella salen las rutas de react-router; en SvelteKit no, porque las pantallas son ficheros de src/routes/.

// src/routes/routes.tsx
import { defineRoutes } from '@/lib/api/react'
import { PERMISSIONS } from '@/lib/api'
import { Boxes, Factory, Home, Users } from 'lucide-react'

export const routes = defineRoutes([
  { path: '/login', element: <LoginPage />, protected: false },
  { path: '/', label: 'Inicio', icon: Home, element: <Inicio /> },
  { path: '/lotes', label: 'Lotes', icon: Boxes, element: <LotesPage /> },
  { path: '/lotes/:id', element: <LoteDetailPage /> },
  { path: '/ordenes', label: 'Órdenes de fabricación', icon: Factory, element: <OrdenesPage />, enabled: false },
  {
    path: '/gestion-usuarios',
    label: 'Gestión usuarios',
    icon: Users,
    element: <GestionUsuariosPage />,
    permission: PERMISSIONS.GESTION_USUARIOS.ACCESS,
  },
  { path: '*', element: <Page404 />, protected: false },
])
// src/lib/routes.ts
import { defineRoutes } from '$lib/api/svelte'
import { PERMISSIONS } from '$lib/api'
import { Boxes, Factory, Home, Users } from 'lucide-svelte'

export const routes = defineRoutes([
  { path: '/login', protected: false },
  { path: '/', label: 'Inicio', icon: Home },
  { path: '/lotes', label: 'Lotes', icon: Boxes },
  { path: '/lotes/:id' },                            // src/routes/(app)/lotes/[id]
  { path: '/ordenes', label: 'Órdenes de fabricación', icon: Factory, enabled: false },
  { path: '/gestion-usuarios', label: 'Gestión usuarios', icon: Users, permission: PERMISSIONS.GESTION_USUARIOS.ACCESS },
  { path: '*', protected: false },                   // la 404 de SvelteKit es pública
])

Las rutas se escriben como en react-router —:id, no [id]—: la declaración es la misma en los dos frameworks.

import { defineRoutes } from './api/routes'
import { PERMISSIONS } from './api'

export const routes = defineRoutes([
  { path: '/login', protected: false },
  { path: '/lotes', label: 'Lotes', icon: 'boxes' },
  { path: '/gestion-usuarios', label: 'Gestión usuarios', permission: PERMISSIONS.GESTION_USUARIOS.ACCESS },
])

element e icon son de cualquier tipo: lo que tu framework use para pintar.

Campo Qué hace Si no se pone
path La ruta. Arriba, completa (/lotes); dentro de children, relativa al padre (:id, edit) —
index La pantalla por defecto de un grupo: la que se ve en la ruta del propio grupo false
element El componente de la pantalla (React) un <Outlet /> para un grupo
label El nombre en el menú y en las migas no sale en el menú
icon El icono del menú: un componente, como los de lucide-react o lucide-svelte sin icono
enabled false: la ruta existe pero enseña «en construcción», y en el menú sale apagada true
protected false: se entra sin sesión (el login, la 404) true: hace falta sesión
permission El permiso para entrar. Con una lista, basta con uno de ellos ninguno: con sesión basta
nav false: tiene label pero no sale en el menú (sí en las migas) true
children Las pantallas que cuelgan de esta: ver abajo —
  • protected y enabled son true por defecto: solo se escribe la excepción —protected: false en las públicas, enabled: false para apagar una—.
  • El permiso no es un texto: es uno de PERMISSIONS, que se genera de los rbac/<realm>.py de la API. Un permiso mal escrito, o uno que se borró del RBAC, no compila.
  • Un segmento opcional se escribe como en react-router 7: /ordenes/:id/ciclos/:numero? (o avanzada?, uno fijo). La guarda, el título y las migas reconocen la ruta con y sin él, y entre las que encajan eligen con la misma puntuación que react-router: /ordenes/:id/ciclos/nuevo gana a :numero?. Una pantalla cuyos parámetros son todos opcionales sale en el menú, enlazada sin ellos (/informes/:anio? → /informes).

2. Proteger las pantallas

Al llegar a una URL, el cliente decide qué toca, con la misma regla en todos los frameworks:

Si… Qué toca
Es protegida y se está comprobando la sesión (al recargar) esperar: loading
Es protegida y no hay sesión ir al login, recordando de dónde venía
Está apagada (enabled: false) «en construcción»
Pide un permiso que el usuario no tiene forbidden: lo habitual es enseñar la 404, para no decir que la pantalla existe
Todo bien la pantalla, dentro del layout

renderRoutes convierte la declaración en las <Route> de react-router con esa regla, y recibe lo que se pinta en cada caso:

<Routes>
  {renderRoutes(routes, {
    layout: <AppLayout />,                  // envuelve a todas las protegidas
    notFound: <Page404 />,
    underConstruction: <UnderConstruction />,
    loading: <FullPageLoader />,
    // forbidden: <SinPermiso />,           // si queréis distinguirlo de la 404
    // loginPath: '/acceso',                // si el login no está en /login
  })}
</Routes>

La vuelta tras el login queda en location.state.from.

accessStore(routes, page) da, para la URL actual, uno de loading, login, underConstruction, forbidden o allowed, y el layout raíz pinta lo que toca (ver Montar la aplicación):

<script lang="ts">
  const acceso = accessStore(routes, page, { loginPath: '/login' })
  $effect(() => {
    if ($acceso.status === 'login') goto($acceso.redirectTo, { replaceState: true })
  })
</script>

{#if $acceso.status === 'allowed'}{@render children()}
{:else if $acceso.status === 'forbidden'}<Page404 />
{:else if $acceso.status === 'underConstruction'}<UnderConstruction />
{:else}<FullPageLoader />{/if}

La vuelta tras el login queda en ?from= (/login?from=%2Flotes%2F7).

Con SvelteKit puede haber pantallas en src/routes/ que no estén en la declaración: piden sesión, como una declarada sin permission. Es lo que no se olvida. Para dejarlas pasar sin sesión, accessStore(routes, page, { undeclared: 'public' }).

page es el store de $app/stores, y la vuelta del login conserva su búsqueda y su ancla. Con otro router, cualquier store con la ruta (/lotes/7?pagina=2).

Con $app/state (SvelteKit 2.12 o posterior)

$app/stores está obsoleto desde SvelteKit 2.12, y el page de $app/state no es un store. Se convierte en uno con toStore, de svelte/store (Svelte 5), leyendo page.url dentro para que siga a la navegación:

import { page } from '$app/state'
import { toStore } from 'svelte/store'

const url = toStore(() => page.url.pathname + page.url.search + page.url.hash)
const acceso = accessStore(routes, url)
const migas = breadcrumbsStore(routes, url)

toStore(() => page) no sirve: devuelve siempre el mismo objeto, y el store no se entera de que la URL ha cambiado.

access() es la regla, como función:

import { access, resolveRoutes } from './api/routes'
import { sessionView } from './api'

const resueltas = resolveRoutes(routes)
const acceso = access(resueltas, location.pathname, sessionView(api.session))
// acceso.status: 'loading' | 'login' | 'underConstruction' | 'forbidden' | 'allowed'
if (acceso.status === 'login') navegar(acceso.redirectTo)

Un ejemplo con el guard de un router, en Otro framework.

Esto oculta, no protege

El cuadro de luces decide qué se ve. Quien protege los datos es la API: un endpoint pide su permiso aunque el frontend se equivoque al ocultar una pantalla o un botón.

Dos formas de escribirlo

Las dos valen, y se pueden mezclar. Los ejemplos son de React; en Svelte, lo mismo sin element.

En lista: cada pantalla con su ruta completa

Todas al mismo nivel. Es lo más directo cuando las pantallas no comparten nada.

export const routes = defineRoutes([
  { path: '/lotes', label: 'Lotes', icon: Boxes, element: <LotesPage /> },
  { path: '/lotes/:id', element: <LoteDetailPage /> },
  { path: '/recetas', label: 'Recetas', icon: CookingPot, element: <RecetasPage /> },
  { path: '/recetas/:id', element: <RecetaDetailPage /> },
])

En árbol: con children

Un grupo y sus pantallas. No se repite el principio de la ruta, y lo que se pone en el grupo lo heredan sus pantallas.

export const routes = defineRoutes([
  { path: '/login', element: <LoginPage />, protected: false },
  {
    path: '/',
    permission: PERMISSIONS.APLICACION.ACCESO,           // toda la aplicación pide este permiso…
    children: [
      { index: true, label: 'Inicio', icon: Home, element: <PanelControlPage /> },
      {
        path: 'gestion-lotes',
        label: 'Gestión de lotes',
        icon: Package,
        children: [
          { index: true, element: <LotesPage /> },                           // /gestion-lotes
          { path: 'new', element: <NuevoLotePage />,
            permission: PERMISSIONS.GESTION_LOTES.CREATE },                    // …este, otro
          {
            path: ':batchId',
            label: 'Lote',
            children: [
              { index: true, element: <LotePage /> },                        // /gestion-lotes/7
              { path: 'edit', label: 'Editar', element: <EditarLotePage /> },  // /gestion-lotes/7/edit
            ],
          },
        ],
      },
      { path: 'ayuda', label: 'Ayuda', element: <AyudaPage />, permission: null },  // …y este, ninguno
      { path: 'liquidaciones', label: 'Liquidaciones', icon: ReceiptEuro, element: <Liquidaciones />, enabled: false },
    ],
  },
  { path: '*', element: <Page404 />, protected: false },
])

Lo que se hereda:

Campo Cómo
permission Si la pantalla no dice nada, el del padre. Con otro valor, lo sustituye. Con null, se lo quita
protected El del padre. Una pública dentro de un grupo protegido, con protected: false
enabled Si el grupo está apagado, todo lo que cuelga de él también

El permiso se comprueba en cada pantalla, no en el grupo

En la URL de una de sus pantallas, un grupo solo exige sesión; el permiso lo comprueba la pantalla con el que hereda. Así un hijo puede cambiarlo o quitárselo. Si el grupo tiene su propia página, va como su hija con index: true, que la protege igual que a las demás; sin ella, en su propia URL el grupo es la pantalla y pide su permiso.

Cuando dos rutas encajan con la misma URL —/lotes/nuevo y /lotes/:id— gana la más concreta, como en react-router, se declaren en el orden que se declaren: una ruta fija antes que una con parámetros, y una con parámetros antes que la comodín (*). Es la que decide el permiso, el título y las migas.

Una pública dentro de un grupo protegido, en React

react-router no pinta una pantalla sin su grupo, y el grupo pide sesión: en React, una pantalla con protected: false dentro de un grupo protegido sigue pidiéndola. Una pantalla pública va en el primer nivel, o dentro de un grupo público.

El menú y las migas

El sidebar y la cabecera no repiten las rutas: las leen de la declaración.

El sidebar

Las pantallas con label que el usuario puede abrir, anidadas como en la declaración. Una que pide un permiso que no tiene no sale; una apagada sale con enabled: false.

import { NavLink } from 'react-router-dom'
import { useNavigation } from '@/lib/api/react'
import { routes } from '@/routes/routes'

export function Sidebar() {
  const items = useNavigation(routes)
  return (
    <nav>
      {items.map(({ id, path, label, icon: Icon, enabled }) => (
        <NavLink key={id} to={path} className={enabled ? '' : 'opacity-50'}>
          {Icon && <Icon size={20} />} {label}
        </NavLink>
      ))}
    </nav>
  )
}
<!-- src/lib/components/shared/Sidebar.svelte -->
<script lang="ts">
  import { navigationStore } from '$lib/api/svelte'
  import { routes } from '$lib/routes'

  const items = navigationStore(routes)
</script>

<nav>
  {#each $items as { id, path, label, icon: Icon, enabled } (id)}
    <a href={path} class:opacity-50={!enabled}>
      {#if Icon}<Icon size={20} />{/if} {label}
    </a>
  {/each}
</nav>
import { navigation, resolveRoutes } from './api/routes'

const vista = sessionView(api.session)
const items = navigation(resolveRoutes(routes), vista.hasPermission, vista.isAuthenticated)

Se vuelve a calcular cuando cambia la sesión (api.session.subscribe).

Campo de cada item Qué es
path Adónde enlaza
label, icon Los de la pantalla
enabled false si está en construcción: pintadla apagada, o con un aviso
children Las pantallas con label que cuelgan de esta, para un menú de dos niveles

Las pantallas con parámetros en la ruta (/lotes/:id) no salen nunca: no se puede enlazar a ellas sin saber el parámetro.

El título y las migas

import { Link } from 'react-router-dom'
import { useBreadcrumbs } from '@/lib/api/react'
import { routes } from '@/routes/routes'

export function Header() {
  const migas = useBreadcrumbs(routes)
  const título = migas.at(-1)?.label ?? 'Panel'
  return (
    <header>
      <h1>{título}</h1>
      {migas.map((m) => (m.current ? <span key={m.path}>{m.label}</span> : <Link key={m.path} to={m.path}>{m.label}</Link>))}
    </header>
  )
}

Y la pantalla actual entera, con useCurrentRoute(routes).

<!-- src/lib/components/shared/Header.svelte -->
<script lang="ts">
  import { page } from '$app/stores'
  import { breadcrumbsStore } from '$lib/api/svelte'
  import { routes } from '$lib/routes'

  const migas = breadcrumbsStore(routes, page)
</script>

<header>
  <h1>{$migas.at(-1)?.label ?? 'Panel'}</h1>
  {#each $migas as miga (miga.path)}
    {#if miga.current}<span>{miga.label}</span>{:else}<a href={miga.path}>{miga.label}</a>{/if}
  {/each}
</header>

Y la pantalla actual entera, con currentRouteStore(routes, page).

import { breadcrumbs, currentRoute } from './api/routes'

const migas = breadcrumbs(resolveRoutes(routes), location.pathname)

En /gestion-lotes/7/edit, las migas son Gestión de lotes › Lote › Editar, y cada una enlaza con los parámetros de la URL ya puestos (/gestion-lotes/7). En una lista plana, una pantalla sin label (/lotes/:id) toma las migas de la que es su prefijo (/lotes).

Ocultar un botón

Para lo que hay dentro de una pantalla:

import { Can } from '@/lib/api/react'

<Can permission={PERMISSIONS.GESTION_USUARIOS.CREATE}>
  <Button onClick={nuevo}>Nuevo usuario</Button>
</Can>

O con el hook: const { hasPermission } = useSession().

{#if $session.hasPermission(PERMISSIONS.GESTION_USUARIOS.CREATE)}
  <Button onclick={nuevo}>Nuevo usuario</Button>
{/if}

Con const session = getSession(). Se vuelve a evaluar sola cuando cambia la sesión.

import { hasPermission } from './api'

hasPermission(api.session.state.user, PERMISSIONS.GESTION_USUARIOS.CREATE)

Ver La sesión y los permisos.

Los permisos: de dónde salen

PERMISSIONS lo genera el cliente desde los rbac/<realm>.py de la API. Un permiso de aplicación se declara allí:

# api/rbac/main.py
app_permission("gestion_usuarios-access", description="Entrar en la gestión de usuarios")
app_permission("gestion_usuarios-create")

role("administrador", app_permissions=["gestion_usuarios-access", "gestion_usuarios-create"])

y tras tai-api generate está en el frontend como PERMISSIONS.GESTION_USUARIOS.ACCESS. Se agrupan por lo que va antes del último guion. tai-api rbac push los lleva a la base de datos (o a Keycloak), y al usuario le llegan en el login.

Están también los de la API —PERMISSIONS.LOTE.CREATE, PERMISSIONS.LOTE.READ…—, los mismos que exige cada endpoint, por si una pantalla depende de poder escribir en una tabla.