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 | — |
protectedyenabledsontruepor defecto: solo se escribe la excepción —protected: falseen las públicas,enabled: falsepara apagar una—.- El permiso no es un texto: es uno de
PERMISSIONS, que se genera de losrbac/<realm>.pyde 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?(oavanzada?, 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/nuevogana 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>
| 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).
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().
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.