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.
| Archivo | Estado | Líneas |
|---|---|---|
infra/realm/tienda-realm.json | modificado | +31 −2 |
internal/api/api.go | modificado | +87 −10 |
internal/apiauth/authz.go | nuevo | +49 −0 |
internal/auth/auth.go | modificado | +8 −5 |
internal/pedidos/catalogo.go | nuevo | +28 −0 |
internal/pedidos/pedidos.go | modificado | +62 −2 |
internal/pedidosclient/client.go | modificado | +56 −12 |
internal/session/session.go | modificado | +6 −0 |
internal/web/compras.go | nuevo | +115 −0 |
internal/web/templates/admin.html | nuevo | +30 −0 |
internal/web/templates/home.html | modificado | +8 −1 |
internal/web/templates/layout.html | modificado | +3 −0 |
internal/web/templates/pedidos.html | modificado | +1 −0 |
internal/web/web.go | modificado | +17 −18 |
infra/realm/tienda-realm.json
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {