Qué cambió — paso 06
Qué cambió · paso-05 → paso-06

Cambios de la lección 06

Todo lo que cambia en tienda/pasos/paso-06 respecto al paso anterior. Vuelve a la lección: 06. Autorización por roles y scopes.

14 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.

ArchivoEstadoLíneas
infra/realm/tienda-realm.jsonmodificado+31 −2
internal/api/api.gomodificado+87 −10
internal/apiauth/authz.gonuevo+49 −0
internal/auth/auth.gomodificado+8 −5
internal/pedidos/catalogo.gonuevo+28 −0
internal/pedidos/pedidos.gomodificado+62 −2
internal/pedidosclient/client.gomodificado+56 −12
internal/session/session.gomodificado+6 −0
internal/web/compras.gonuevo+115 −0
internal/web/templates/admin.htmlnuevo+30 −0
internal/web/templates/home.htmlmodificado+8 −1
internal/web/templates/layout.htmlmodificado+3 −0
internal/web/templates/pedidos.htmlmodificado+1 −0
internal/web/web.gomodificado+17 −18
infra/realm/tienda-realm.json modificado · +31 −2 · generado, plegado
@@ -118,6 +118,23 @@
         "pkce.code.challenge.method": "S256",
         "post.logout.redirect.uris": "http://localhost:3000/"
       },
+      "protocolMappers": [
+        {
+          "name": "roles de realm en el ID token",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-realm-role-mapper",
+          "consentRequired": false,
+          "config": {
+            "claim.name": "roles",
+            "jsonType.label": "String",
+            "multivalued": "true",
+            "id.token.claim": "true",
+            "access.token.claim": "false",
+            "userinfo.token.claim": "false",
+            "introspection.token.claim": "false"
+          }
+        }
+      ],
       "defaultClientScopes": [
         "web-origins",
         "acr",
@@ -132,7 +149,8 @@
         "phone",
         "organization",
         "offline_access",
-        "microprofile-jwt"
+        "microprofile-jwt",
+        "pedidos:escribir"
       ]
     },
     {
@@ -163,7 +181,8 @@
         "phone",
         "organization",
         "offline_access",
-        "microprofile-jwt"
+        "microprofile-jwt",
+        "pedidos:escribir"
       ]
     },
     {
@@ -221,6 +240,16 @@
       ]
     },
     {
+      "name": "pedidos:escribir",
+      "description": "Permite crear pedidos y cambiar su estado",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "true",
+        "display.on.consent.screen": "true",
+        "consent.screen.text": "Crear y modificar pedidos"
+      }
+    },
+    {
       "name": "email",
       "description": "OpenID Connect built-in scope: email",
       "protocol": "openid-connect",
internal/api/api.go modificado · +87 −10
@@ -2,6 +2,8 @@
 package api
 
 import (
+	"encoding/json"
+	"errors"
 	"net/http"
 	"strconv"
 
@@ -14,11 +16,28 @@
 	store *pedidos.Store
 }
 
-// Register añade las rutas de la API. Todas exigen un access token válido.
+// Register añade las rutas de la API. Cada ruta declara, de un vistazo,
+// qué hace falta para llamarla: token válido + roles + scope.
 func Register(mux *http.ServeMux, v *apiauth.Verifier, store *pedidos.Store) {
 	h := &handlers{store: store}
-	mux.Handle("GET /pedidos", v.Middleware(http.HandlerFunc(h.list)))
-	mux.Handle("GET /pedidos/{id}", v.Middleware(http.HandlerFunc(h.get)))
+	cliente := apiauth.RequireRole("cliente", "admin")
+	admin := apiauth.RequireRole("admin")
+	escribir := apiauth.RequireScope("pedidos:escribir")
+
+	mux.Handle("GET /pedidos", protect(v, h.list, cliente))
+	mux.Handle("GET /pedidos/{id}", protect(v, h.get, cliente))
+	mux.Handle("POST /pedidos", protect(v, h.create, cliente, escribir))
+	mux.Handle("GET /admin/pedidos", protect(v, h.listAll, admin))
+	mux.Handle("PATCH /admin/pedidos/{id}", protect(v, h.setStatus, admin, escribir))
+}
+
+// protect encadena: token válido (v.Middleware) → cada comprobación → handler.
+func protect(v *apiauth.Verifier, h http.HandlerFunc, checks ...func(http.Handler) http.Handler) http.Handler {
+	var next http.Handler = h
+	for i := len(checks) - 1; i >= 0; i-- {
+		next = checks[i](next)
+	}
+	return v.Middleware(next)
 }
 
 // list devuelve los pedidos de quien llama, identificado por el «sub» del token.
@@ -27,19 +46,77 @@
 	jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.ByOwner(p.Subject)})
 }
 
-// get devuelve un pedido si pertenece a quien llama. Si es de otro usuario
-// respondemos 404, no 403: así no revelamos qué números de pedido existen.
+// get devuelve un pedido si pertenece a quien llama (o si es admin). Si es de
+// otro usuario respondemos 404, no 403: así no revelamos qué pedidos existen.
 func (h *handlers) get(w http.ResponseWriter, r *http.Request) {
 	p := apiauth.FromContext(r.Context())
-	id, err := strconv.Atoi(r.PathValue("id"))
-	if err != nil {
-		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "el id debe ser un número")
+	id, ok := pathID(w, r)
+	if !ok {
 		return
 	}
-	o, ok := h.store.Get(id)
-	if !ok || o.Owner != p.Subject {
+	o, found := h.store.Get(id)
+	if !found || (o.Owner != p.Subject && !p.HasRole("admin")) {
 		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
 		return
 	}
 	jsonhttp.Write(w, http.StatusOK, o)
 }
+
+// create registra un pedido a nombre de quien llama. El dueño sale del token
+// y el precio del catálogo: el cuerpo solo dice qué y cuánto.
+func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
+	p := apiauth.FromContext(r.Context())
+	var body struct {
+		Producto string `json:"producto"`
+		Cantidad int    `json:"cantidad"`
+	}
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "JSON inválido")
+		return
+	}
+	o, err := h.store.Create(p.Subject, body.Producto, body.Cantidad)
+	if err != nil {
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
+		return
+	}
+	jsonhttp.Write(w, http.StatusCreated, o)
+}
+
+// listAll devuelve todos los pedidos (solo admin).
+func (h *handlers) listAll(w http.ResponseWriter, r *http.Request) {
+	jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.All()})
+}
+
+// setStatus cambia el estado de un pedido (solo admin con pedidos:escribir).
+func (h *handlers) setStatus(w http.ResponseWriter, r *http.Request) {
+	id, ok := pathID(w, r)
+	if !ok {
+		return
+	}
+	var body struct {
+		Status string `json:"status"`
+	}
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "JSON inválido")
+		return
+	}
+	o, err := h.store.SetStatus(id, body.Status)
+	switch {
+	case errors.Is(err, pedidos.ErrNotFound):
+		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
+	case err != nil:
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
+	default:
+		jsonhttp.Write(w, http.StatusOK, o)
+	}
+}
+
+// pathID lee {id} de la ruta; si no es un número, responde 400.
+func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
+	id, err := strconv.Atoi(r.PathValue("id"))
+	if err != nil {
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "el id debe ser un número")
+		return 0, false
+	}
+	return id, true
+}
internal/apiauth/authz.go nuevo · +49 −0
@@ -0,0 +1,49 @@
+package apiauth
+
+import (
+	"fmt"
+	"net/http"
+	"slices"
+
+	"tienda/internal/jsonhttp"
+)
+
+// HasRole indica si el token trae el rol de realm r.
+func (p *Principal) HasRole(r string) bool { return slices.Contains(p.Roles, r) }
+
+// HasScope indica si el token trae el scope s.
+func (p *Principal) HasScope(s string) bool { return slices.Contains(p.Scopes, s) }
+
+// RequireRole deja pasar solo si el usuario tiene alguno de los roles.
+// Va siempre DESPUÉS de Middleware (necesita el Principal en el contexto).
+func RequireRole(roles ...string) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			p := FromContext(r.Context())
+			if p == nil || !slices.ContainsFunc(roles, p.HasRole) {
+				jsonhttp.Error(w, http.StatusForbidden, "forbidden",
+					fmt.Sprintf("hace falta uno de estos roles: %v", roles))
+				return
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+// RequireScope deja pasar solo si el token trae el scope. Si no, responde
+// 403 insufficient_scope (RFC 6750) indicando qué scope pedir.
+func RequireScope(scope string) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			p := FromContext(r.Context())
+			if p == nil || !p.HasScope(scope) {
+				w.Header().Set("WWW-Authenticate",
+					fmt.Sprintf(`Bearer realm="api-pedidos", error="insufficient_scope", scope=%q`, scope))
+				jsonhttp.Error(w, http.StatusForbidden, "insufficient_scope",
+					fmt.Sprintf("el token no incluye el scope %q", scope))
+				return
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}
internal/auth/auth.go modificado · +8 −5
@@ -84,7 +84,8 @@
 			ClientSecret: cfg.ClientSecret,
 			RedirectURL:  cfg.RedirectURL,
 			Endpoint:     provider.Endpoint(), // URLs de /auth y /token, sacadas del descubrimiento
-			Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
+			// pedidos:escribir es un scope opcional: si no se pide, no llega al token.
+			Scopes: []string{oidc.ScopeOpenID, "profile", "email", "pedidos:escribir"},
 		},
 		provider: provider,
 		// El verificador comprueba firma (con el JWKS), iss, aud == ClientID y exp.
@@ -198,10 +199,11 @@
 
 	// 6. Leemos los claims que nos interesan y creamos la sesión, ahora con los tokens.
 	var claims struct {
-		Username string `json:"preferred_username"`
-		Name     string `json:"name"`
-		Email    string `json:"email"`
-		SID      string `json:"sid"`
+		Username string   `json:"preferred_username"`
+		Name     string   `json:"name"`
+		Email    string   `json:"email"`
+		SID      string   `json:"sid"`
+		Roles    []string `json:"roles"`
 	}
 	var all map[string]any
 	if err := idToken.Claims(&claims); err != nil {
@@ -218,6 +220,7 @@
 		Username: claims.Username,
 		Name:     claims.Name,
 		Email:    claims.Email,
+		Roles:    claims.Roles,
 	}, all, rawIDToken, claims.SID, tok)
 
 	http.SetCookie(w, &http.Cookie{
internal/pedidos/catalogo.go nuevo · +28 −0
@@ -0,0 +1,28 @@
+package pedidos
+
+// Product es un artículo del catálogo.
+type Product struct {
+	ID    string  `json:"id"`
+	Name  string  `json:"name"`
+	Desc  string  `json:"desc"`
+	Price float64 `json:"price"`
+}
+
+// Catalog es el catálogo de la tienda. tienda-web lo muestra y api-pedidos lo
+// usa para calcular los totales: el precio nunca lo decide quien compra.
+var Catalog = []Product{
+	{"gopher", "Gopher de peluche", "El compañero ideal para depurar.", 19.90},
+	{"taza", "Taza «go fmt»", "Formatea tu café automáticamente.", 9.50},
+	{"camiseta", "Camiseta Keycloak", "Algodón 100 %, talla única de realm.", 15.00},
+	{"pegatinas", "Pegatinas OIDC", "Pack de 10: iss, sub, aud, exp…", 4.99},
+}
+
+// FindProduct busca un artículo por su id.
+func FindProduct(id string) (Product, bool) {
+	for _, p := range Catalog {
+		if p.ID == id {
+			return p, true
+		}
+	}
+	return Product{}, false
+}
internal/pedidos/pedidos.go modificado · +62 −2
@@ -3,6 +3,9 @@
 package pedidos
 
 import (
+	"errors"
+	"fmt"
+	"slices"
 	"sort"
 	"sync"
 	"time"
@@ -15,6 +18,9 @@
 	CarlosID = "00000000-0000-4000-8000-0000000000c1"
 )
 
+// ErrNotFound indica que el pedido no existe.
+var ErrNotFound = errors.New("pedido no encontrado")
+
 // Order es un pedido.
 type Order struct {
 	ID        int       `json:"id"`
@@ -25,16 +31,20 @@
 	CreatedAt time.Time `json:"created_at"`
 }
 
+// Statuses son los estados válidos de un pedido.
+var Statuses = []string{"Pendiente", "Enviado", "Entregado", "Cancelado"}
+
 // Store guarda los pedidos en memoria, seguro para uso concurrente.
 type Store struct {
 	mu     sync.Mutex
 	orders map[int]Order
+	nextID int
 }
 
 // NewStore crea un almacén con los pedidos de ejemplo de ana y carlos.
 func NewStore() *Store {
 	day := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
-	s := &Store{orders: make(map[int]Order)}
+	s := &Store{orders: make(map[int]Order), nextID: 1004}
 	for _, o := range []Order{
 		{1001, AnaID, "Gopher de peluche ×1, Pegatinas OIDC ×2", 29.88, "Enviado", day},
 		{1002, AnaID, "Taza «go fmt» ×1", 9.50, "Pendiente", day.Add(48 * time.Hour)},
@@ -47,16 +57,66 @@
 
 // ByOwner devuelve los pedidos de un usuario, ordenados por número.
 func (s *Store) ByOwner(sub string) []Order {
+	return s.filter(func(o Order) bool { return o.Owner == sub })
+}
+
+// All devuelve todos los pedidos, ordenados por número.
+func (s *Store) All() []Order {
+	return s.filter(func(Order) bool { return true })
+}
+
+func (s *Store) filter(keep func(Order) bool) []Order {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	out := []Order{}
 	for _, o := range s.orders {
-		if o.Owner == sub {
+		if keep(o) {
 			out = append(out, o)
 		}
 	}
 	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
 	return out
+}
+
+// Create registra un pedido nuevo de owner, calculando el total con los
+// precios del catálogo.
+func (s *Store) Create(owner, productID string, qty int) (Order, error) {
+	p, ok := FindProduct(productID)
+	if !ok {
+		return Order{}, fmt.Errorf("producto desconocido %q", productID)
+	}
+	if qty < 1 || qty > 10 {
+		return Order{}, fmt.Errorf("la cantidad debe estar entre 1 y 10")
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	o := Order{
+		ID:        s.nextID,
+		Owner:     owner,
+		Items:     fmt.Sprintf("%s ×%d", p.Name, qty),
+		Total:     p.Price * float64(qty),
+		Status:    "Pendiente",
+		CreatedAt: time.Now().UTC(),
+	}
+	s.orders[o.ID] = o
+	s.nextID++
+	return o, nil
+}
+
+// SetStatus cambia el estado de un pedido.
+func (s *Store) SetStatus(id int, status string) (Order, error) {
+	if !slices.Contains(Statuses, status) {
+		return Order{}, fmt.Errorf("estado inválido %q", status)
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	o, ok := s.orders[id]
+	if !ok {
+		return Order{}, ErrNotFound
+	}
+	o.Status = status
+	s.orders[id] = o
+	return o, nil
 }
 
 // Get devuelve un pedido por su número.
internal/pedidosclient/client.go modificado · +56 −12
@@ -3,12 +3,14 @@
 package pedidosclient
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"net/http"
+	"strconv"
 	"time"
 
 	"golang.org/x/oauth2"
@@ -24,8 +26,12 @@
 	CreatedAt time.Time `json:"created_at"`
 }
 
-// ErrUnauthorized indica que la API rechazó el access token (401).
-var ErrUnauthorized = errors.New("api-pedidos rechazó el access token")
+var (
+	// ErrUnauthorized indica que la API rechazó el access token (401).
+	ErrUnauthorized = errors.New("api-pedidos rechazó el access token")
+	// ErrForbidden indica que el token es válido pero no basta (403).
+	ErrForbidden = errors.New("api-pedidos denegó la operación")
+)
 
 // Client llama a api-pedidos.
 type Client struct {
@@ -43,16 +49,51 @@
 	var out struct {
 		Pedidos []Order `json:"pedidos"`
 	}
-	err := c.do(ctx, tok, http.MethodGet, "/pedidos", &out)
+	err := c.do(ctx, tok, http.MethodGet, "/pedidos", nil, &out)
 	return out.Pedidos, err
 }
 
-// do hace la petición con «Authorization: Bearer <access token>» y decodifica
-// la respuesta JSON en out.
-func (c *Client) do(ctx context.Context, tok *oauth2.Token, method, path string, out any) error {
-	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
+// Create compra cantidad unidades del producto.
+func (c *Client) Create(ctx context.Context, tok *oauth2.Token, producto string, cantidad int) (Order, error) {
+	var o Order
+	body := map[string]any{"producto": producto, "cantidad": cantidad}
+	err := c.do(ctx, tok, http.MethodPost, "/pedidos", body, &o)
+	return o, err
+}
+
+// AllOrders devuelve todos los pedidos (requiere rol admin).
+func (c *Client) AllOrders(ctx context.Context, tok *oauth2.Token) ([]Order, error) {
+	var out struct {
+		Pedidos []Order `json:"pedidos"`
+	}
+	err := c.do(ctx, tok, http.MethodGet, "/admin/pedidos", nil, &out)
+	return out.Pedidos, err
+}
+
+// SetStatus cambia el estado de un pedido (requiere rol admin y pedidos:escribir).
+func (c *Client) SetStatus(ctx context.Context, tok *oauth2.Token, id int, status string) (Order, error) {
+	var o Order
+	err := c.do(ctx, tok, http.MethodPatch, "/admin/pedidos/"+strconv.Itoa(id), map[string]string{"status": status}, &o)
+	return o, err
+}
+
+// do hace la petición con «Authorization: Bearer <access token>», enviando
+// body como JSON si no es nil, y decodifica la respuesta en out.
+func (c *Client) do(ctx context.Context, tok *oauth2.Token, method, path string, body, out any) error {
+	var rd io.Reader
+	if body != nil {
+		b, err := json.Marshal(body)
+		if err != nil {
+			return err
+		}
+		rd = bytes.NewReader(b)
+	}
+	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rd)
 	if err != nil {
 		return err
+	}
+	if body != nil {
+		req.Header.Set("Content-Type", "application/json")
 	}
 	tok.SetAuthHeader(req) // añade la cabecera Authorization: Bearer …
 	resp, err := c.http.Do(req)
@@ -61,16 +102,19 @@
 	}
 	defer resp.Body.Close()
 
-	if resp.StatusCode == http.StatusUnauthorized {
-		return fmt.Errorf("%w: %s", ErrUnauthorized, resp.Header.Get("WWW-Authenticate"))
-	}
 	if resp.StatusCode >= 400 {
 		var e struct {
 			Code        string `json:"error"`
 			Description string `json:"error_description"`
 		}
-		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
-		_ = json.Unmarshal(body, &e)
+		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+		_ = json.Unmarshal(raw, &e)
+		switch resp.StatusCode {
+		case http.StatusUnauthorized:
+			return fmt.Errorf("%w: %s", ErrUnauthorized, e.Description)
+		case http.StatusForbidden:
+			return fmt.Errorf("%w: %s", ErrForbidden, e.Description)
+		}
 		return fmt.Errorf("api-pedidos respondió %s: %s", resp.Status, e.Description)
 	}
 	return json.NewDecoder(resp.Body).Decode(out)
internal/session/session.go modificado · +6 −0
@@ -8,6 +8,7 @@
 
 import (
 	"crypto/rand"
+	"slices"
 	"sync"
 	"time"
 
@@ -20,7 +21,12 @@
 	Username string // claim "preferred_username"
 	Name     string
 	Email    string
+	Roles    []string // claim "roles" del ID token (mapper del client tienda-web)
 }
+
+// HasRole indica si el usuario tiene el rol de realm r. Solo para decidir qué
+// mostrar en la interfaz: quien autoriza de verdad es api-pedidos.
+func (u User) HasRole(r string) bool { return slices.Contains(u.Roles, r) }
 
 // Session es lo que recordamos de un usuario que ha iniciado sesión.
 type Session struct {
internal/web/compras.go nuevo · +115 −0
@@ -0,0 +1,115 @@
+package web
+
+import (
+	"errors"
+	"log"
+	"net/http"
+	"strconv"
+
+	"golang.org/x/oauth2"
+
+	"tienda/internal/auth"
+	"tienda/internal/pedidos"
+	"tienda/internal/pedidosclient"
+	"tienda/internal/session"
+)
+
+// apiToken devuelve la sesión y un access token vigente para llamar a la API.
+// Si la sesión de Keycloak terminó, redirige al login (que volverá a «next»)
+// y devuelve ok=false.
+func (h *handlers) apiToken(w http.ResponseWriter, r *http.Request, next string) (*session.Session, *oauth2.Token, bool) {
+	sess, _ := h.auth.CurrentSession(r)
+	tok, err := h.auth.Token(r.Context(), sess, false)
+	if errors.Is(err, auth.ErrSessionEnded) {
+		http.Redirect(w, r, "/login?next="+next, http.StatusSeeOther)
+		return nil, nil, false
+	}
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadGateway)
+		return nil, nil, false
+	}
+	return sess, tok, true
+}
+
+// comprar crea un pedido con un artículo del catálogo (botón «Comprar»).
+func (h *handlers) comprar(w http.ResponseWriter, r *http.Request) {
+	_, tok, ok := h.apiToken(w, r, "%2F")
+	if !ok {
+		return
+	}
+	o, err := h.api.Create(r.Context(), tok, r.PostFormValue("producto"), 1)
+	if err != nil {
+		log.Printf("comprar: %v", err)
+		http.Redirect(w, r, "/?error="+errorCode(err), http.StatusSeeOther)
+		return
+	}
+	http.Redirect(w, r, "/pedidos?nuevo="+strconv.Itoa(o.ID), http.StatusSeeOther)
+}
+
+// admin lista todos los pedidos. No comprobamos aquí el rol: si el usuario no
+// es admin, api-pedidos responde 403 y lo mostramos. El enlace del menú solo
+// se ve para admins, pero quien decide de verdad es la API.
+func (h *handlers) admin(w http.ResponseWriter, r *http.Request) {
+	sess, tok, ok := h.apiToken(w, r, "%2Fadmin")
+	if !ok {
+		return
+	}
+	data := pageData{Active: "admin", Session: sess, Statuses: pedidos.Statuses}
+	if id := r.URL.Query().Get("ok"); id != "" {
+		data.Flash = "Pedido #" + id + " actualizado."
+	}
+	data.Error = errorMessage(r.URL.Query().Get("error"))
+	if orders, err := h.api.AllOrders(r.Context(), tok); err != nil {
+		log.Printf("admin: %v", err)
+		data.Error = errorMessage(errorCode(err))
+	} else {
+		data.Orders = orders
+	}
+	h.render(w, "admin.html", data)
+}
+
+// cambiarEstado cambia el estado de un pedido desde /admin.
+func (h *handlers) cambiarEstado(w http.ResponseWriter, r *http.Request) {
+	_, tok, ok := h.apiToken(w, r, "%2Fadmin")
+	if !ok {
+		return
+	}
+	id, err := strconv.Atoi(r.PathValue("id"))
+	if err != nil {
+		http.Error(w, "id inválido", http.StatusBadRequest)
+		return
+	}
+	if _, err := h.api.SetStatus(r.Context(), tok, id, r.PostFormValue("status")); err != nil {
+		log.Printf("cambiar estado: %v", err)
+		http.Redirect(w, r, "/admin?error="+errorCode(err), http.StatusSeeOther)
+		return
+	}
+	http.Redirect(w, r, "/admin?ok="+strconv.Itoa(id), http.StatusSeeOther)
+}
+
+// errorCode resume un error de la API en un código corto para la URL.
+func errorCode(err error) string {
+	switch {
+	case errors.Is(err, pedidosclient.ErrForbidden):
+		return "forbidden"
+	case errors.Is(err, pedidosclient.ErrUnauthorized):
+		return "unauthorized"
+	default:
+		return "api"
+	}
+}
+
+// errorMessage traduce el código a un mensaje. Nunca mostramos en la página
+// texto que venga de la URL: alguien podría enviar un enlace con un mensaje falso.
+func errorMessage(code string) string {
+	switch code {
+	case "":
+		return ""
+	case "forbidden":
+		return "api-pedidos ha denegado la operación (403): tu token no tiene el rol o el scope necesarios."
+	case "unauthorized":
+		return "api-pedidos ha rechazado tu token (401). Vuelve a iniciar sesión."
+	default:
+		return "No se pudo completar la operación. Mira el log de tienda-web."
+	}
+}
internal/web/templates/admin.html nuevo · +30 −0
@@ -0,0 +1,30 @@
+{{define "content"}}
+<h1>Todos los pedidos</h1>
+<p class="who">Solo para el rol <code>admin</code>. Lo comprueba <code>api-pedidos</code>, no esta página.</p>
+{{if .Flash}}<div class="note">✔ {{.Flash}}</div>{{end}}
+{{if .Error}}<div class="note">⚠ {{.Error}}</div>{{end}}
+{{if .Orders}}
+<table>
+  <thead><tr><th>Nº</th><th>Cliente (sub)</th><th>Artículos</th><th>Total</th><th>Estado</th></tr></thead>
+  <tbody>
+  {{range .Orders}}
+    <tr>
+      <td>#{{.ID}}</td>
+      <td><code>{{.Owner}}</code></td>
+      <td>{{.Items}}</td>
+      <td>$ {{printf "%.2f" .Total}}</td>
+      <td>
+        <form class="inline" method="post" action="/admin/pedidos/{{.ID}}">
+          <select name="status">
+            {{$current := .Status}}
+            {{range $.Statuses}}<option {{if eq . $current}}selected{{end}}>{{.}}</option>{{end}}
+          </select>
+          <button class="btn ghost" type="submit">Guardar</button>
+        </form>
+      </td>
+    </tr>
+  {{end}}
+  </tbody>
+</table>
+{{end}}
+{{end}}
internal/web/templates/home.html modificado · +8 −1
@@ -1,7 +1,8 @@
 {{define "content"}}
 <h1>Catálogo</h1>
+{{if .Error}}<div class="note">⚠ {{.Error}}</div>{{end}}
 {{if not .Session}}
-<div class="note">El catálogo es público. Para ver tus pedidos, <a href="/login">inicia sesión</a>.</div>
+<div class="note">El catálogo es público. Para comprar y ver tus pedidos, <a href="/login">inicia sesión</a>.</div>
 {{end}}
 <div class="grid">
   {{range .Products}}
@@ -9,6 +10,12 @@
     <h3>{{.Name}}</h3>
     <p>{{.Desc}}</p>
     <span class="price">$ {{printf "%.2f" .Price}}</span>
+    {{if $.Session}}
+    <form method="post" action="/comprar" style="margin-top:.6rem">
+      <input type="hidden" name="producto" value="{{.ID}}">
+      <button class="btn" type="submit">Comprar</button>
+    </form>
+    {{end}}
   </div>
   {{end}}
 </div>
internal/web/templates/layout.html modificado · +3 −0
@@ -31,6 +31,8 @@
     td code { font:.85rem ui-monospace, Consolas, monospace; word-break:break-all; }
     .note { background:var(--brand-2); border-radius:10px; padding:.8rem 1rem; margin:1rem 0; }
     .badge { font-size:.8rem; padding:.1rem .5rem; border-radius:99px; background:#eef2f6; }
+    select { font:inherit; padding:.35rem .5rem; border:1px solid var(--line); border-radius:8px; background:#fff; }
+    .inline { display:flex; gap:.5rem; align-items:center; margin:0; }
   </style>
 </head>
 <body>
@@ -41,6 +43,7 @@
       <a href="/" {{if eq .Active "catalogo"}}class="on"{{end}}>Catálogo</a>
       <a href="/pedidos" {{if eq .Active "pedidos"}}class="on"{{end}}>Mis pedidos</a>
       {{if .Session}}<a href="/perfil" {{if eq .Active "perfil"}}class="on"{{end}}>Perfil</a>{{end}}
+      {{if and .Session (.Session.User.HasRole "admin")}}<a href="/admin" {{if eq .Active "admin"}}class="on"{{end}}>Admin</a>{{end}}
     </nav>
     <span class="spacer"></span>
     {{if .Session}}
internal/web/templates/pedidos.html modificado · +1 −0
@@ -1,6 +1,7 @@
 {{define "content"}}
 <h1>Mis pedidos</h1>
 <p class="who">Pedidos de <strong>{{.Session.User.Username}}</strong> ({{.Session.User.Email}}), servidos por <code>api-pedidos</code></p>
+{{if .Flash}}<div class="note">✔ {{.Flash}}</div>{{end}}
 {{if .Error}}
 <div class="note">⚠ {{.Error}}</div>
 {{else if .Orders}}
internal/web/web.go modificado · +17 −18
@@ -1,4 +1,5 @@
-// Package web contiene las páginas de tienda-web: catálogo, pedidos y perfil.
+// Package web contiene las páginas de tienda-web: catálogo, pedidos, perfil y
+// administración.
 package web
 
 import (
@@ -12,26 +13,13 @@
 	"time"
 
 	"tienda/internal/auth"
+	"tienda/internal/pedidos"
 	"tienda/internal/pedidosclient"
 	"tienda/internal/session"
 )
 
 //go:embed templates/*.html
 var templateFS embed.FS
-
-// Product es un artículo del catálogo (público).
-type Product struct {
-	Name  string
-	Desc  string
-	Price float64
-}
-
-var catalog = []Product{
-	{"Gopher de peluche", "El compañero ideal para depurar.", 19.90},
-	{"Taza «go fmt»", "Formatea tu café automáticamente.", 9.50},
-	{"Camiseta Keycloak", "Algodón 100 %, talla única de realm.", 15.00},
-	{"Pegatinas OIDC", "Pack de 10: iss, sub, aud, exp…", 4.99},
-}
 
 // claim es una fila de la tabla de /perfil.
 type claim struct {
@@ -50,11 +38,13 @@
 type pageData struct {
 	Active   string // pestaña activa del menú
 	Session  *session.Session
-	Products []Product
+	Products []pedidos.Product
 	Orders   []pedidosclient.Order
+	Statuses []string
 	Claims   []claim
 	UserInfo []claim
 	Token    tokenInfo
+	Flash    string // mensaje de éxito
 	Error    string
 }
 
@@ -67,7 +57,7 @@
 // Register añade las páginas al mux. /pedidos y /perfil exigen sesión.
 func Register(mux *http.ServeMux, a *auth.Auth, api *pedidosclient.Client) {
 	h := &handlers{auth: a, api: api, pages: make(map[string]*template.Template)}
-	for _, name := range []string{"home.html", "pedidos.html", "perfil.html"} {
+	for _, name := range []string{"home.html", "pedidos.html", "perfil.html", "admin.html"} {
 		h.pages[name] = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/"+name))
 	}
 
@@ -75,11 +65,17 @@
 	mux.Handle("GET /pedidos", a.RequireLogin(http.HandlerFunc(h.pedidos)))
 	mux.Handle("GET /perfil", a.RequireLogin(http.HandlerFunc(h.perfil)))
 	mux.Handle("POST /perfil/renovar", a.RequireLogin(http.HandlerFunc(h.renovar)))
+	mux.Handle("POST /comprar", a.RequireLogin(http.HandlerFunc(h.comprar)))
+	mux.Handle("GET /admin", a.RequireLogin(http.HandlerFunc(h.admin)))
+	mux.Handle("POST /admin/pedidos/{id}", a.RequireLogin(http.HandlerFunc(h.cambiarEstado)))
 }
 
 func (h *handlers) home(w http.ResponseWriter, r *http.Request) {
 	sess, _ := h.auth.CurrentSession(r)
-	h.render(w, "home.html", pageData{Active: "catalogo", Session: sess, Products: catalog})
+	h.render(w, "home.html", pageData{
+		Active: "catalogo", Session: sess, Products: pedidos.Catalog,
+		Error: errorMessage(r.URL.Query().Get("error")),
+	})
 }
 
 // pedidos pide a api-pedidos los pedidos del usuario, con su access token.
@@ -91,6 +87,9 @@
 		return
 	}
 	data := pageData{Active: "pedidos", Session: sess}
+	if id := r.URL.Query().Get("nuevo"); id != "" {
+		data.Flash = "Pedido #" + id + " creado."
+	}
 	if err != nil {
 		data.Error = err.Error()
 	} else if orders, err := h.api.MyOrders(r.Context(), tok); err != nil {

← Volver a la lección 06