3 Commits

Author SHA1 Message Date
egutierrez ea35c11f19 docs(unibus_admin): bump 0.2.0 — account creation via invites + hard-delete
Document the wallet-model account flow in the Users tab: invite-link account
creation (with the configurable client base URL for the join link), permanent
hard-delete with strong confirmation, and the deploy gap (cluster still on bus
v0.11.0; this branch merges after the bus reaches master).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:29:51 +02:00
egutierrez bfd4a99100 feat(spa): create user via invite link, permanent delete, pending invites
Users tab gains the wallet-model account flow:

- "Crear usuario" button -> modal (handle + role + expiry) -> POST /api/invites
  -> shows the copyable single-use join link (<client-base>/join?token=…). Warns
  when the gateway has no client base URL configured (falls back to the panel's
  own origin).
- Per-row "Eliminar" -> STRONG confirmation modal that requires typing the handle
  and spells out the permanence and the difference from revoke -> DELETE
  /api/users/{pub}.
- Pending invites card: handle, role, partial token, expiry, copy-link.

Includes the rebuilt embedded SPA bundle (web/dist) so the Go binary ships the
new UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:28:44 +02:00
egutierrez f65271dc92 feat(gateway): invite and hard-delete REST endpoints + repo methods
Wire the bus's new account surface into the admin gateway:

- POST /api/invites, GET /api/invites: mint and list single-use registration
  invites (CreateInvite/ListInvites on the Repo). The gateway pre-builds the
  shareable join link (JoinURL) from a configurable end-user client base URL so
  the SPA does not need to know where the client lives.
- DELETE /api/users/{pub}: hard-delete (purge) a user, distinct from the existing
  revoke.
- Both backends covered: signed control-plane (cluster default) via the unibus
  client's CreateInvite/ListInvites/DeleteUser, and the direct membership store
  (single-node --db fallback). For the direct store, ListInvites filters to
  pending (the control plane already does so server-side).
- New --join-base-url flag / UNIBUS_JOIN_BASE_URL env feeds the join link base
  URL (the END-USER client, NOT the panel's own URL); surfaced on /api/me.
- Mock repo gains the same methods for UI iteration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:28:44 +02:00
13 changed files with 804 additions and 182 deletions
+50 -3
View File
@@ -2,8 +2,8 @@
name: unibus_admin name: unibus_admin
lang: go lang: go
domain: infra domain: infra
version: 0.1.0 version: 0.2.0
description: "Panel web de administración de unibus: un binario Go que sirve una SPA Mantine embebida y expone una REST API. Tiene la identidad ADMIN del operador, firma cada petición al plano de control del bus, y gestiona rooms, miembros, claves, usuarios y el estado del cluster." description: "Panel web de administración de unibus: un binario Go que sirve una SPA Mantine embebida y expone una REST API. Tiene la identidad ADMIN del operador, firma cada petición al plano de control del bus, y gestiona rooms, miembros, claves, usuarios (alta por invitación + baja por hard-delete) y el estado del cluster."
tags: [service, messaging, admin, nats, e2e] tags: [service, messaging, admin, nats, e2e]
uses_functions: uses_functions:
- sign_ed25519_go_cybersecurity - sign_ed25519_go_cybersecurity
@@ -78,7 +78,7 @@ no expone. Nunca reimplementa firma ni cripto.
|---|---|---| |---|---|---|
| **Cluster** | up/down + posture (enforce/acl/tls/cluster/store) + latencia de cada nodo | `GET /healthz` (auth-exempt) de los nodos en `--nodes`, TLS pin a la CA del bus | | **Cluster** | up/down + posture (enforce/acl/tls/cluster/store) + latencia de cada nodo | `GET /healthz` (auth-exempt) de los nodos en `--nodes`, TLS pin a la CA del bus |
| **Rooms** | listar (rooms del admin), crear (subject + E2E/persist/firmado), ver miembros, invitar, expulsar+rekey | `pkg/client` (mutaciones) + GET firmado (miembros) | | **Rooms** | listar (rooms del admin), crear (subject + E2E/persist/firmado), ver miembros, invitar, expulsar+rekey | `pkg/client` (mutaciones) + GET firmado (miembros) |
| **Users** | listar/añadir/revocar la allowlist del bus | `pkg/client` (`ListUsers`/`AddUser`/`RevokeUser`) contra la API admin-only del plano de control, firmando como el operador. Funciona en cluster (los nodos escriben al mismo store que las rooms) sin acceso directo al store. `--db` queda como fallback single-node opcional | | **Users** | listar la allowlist; **crear usuario** por enlace de invitación (modelo wallet, sin manejar claves); añadir por clave conocida; **revocar** (status flip, auditable) y **eliminar** (hard-delete permanente, con confirmación fuerte) | `pkg/client` (`ListUsers`/`AddUser`/`RevokeUser`/`DeleteUser`/`CreateInvite`/`ListInvites`) contra la API admin-only del plano de control, firmando como el operador. Funciona en cluster sin acceso directo al store. `--db` queda como fallback single-node opcional |
## Cómo arrancar ## Cómo arrancar
@@ -127,8 +127,36 @@ ofuscado (`admin-<hash>.organic-machine.com`). Credenciales en `pass`
(`unibus/admin-panel-password`, `unibus/admin-panel-url`). Artefactos de deploy en (`unibus/admin-panel-password`, `unibus/admin-panel-url`). Artefactos de deploy en
`deploy/`. `deploy/`.
## Creación de usuarios (modelo wallet) y enlace de invitación
La pestaña Users crea usuarios SIN que el operador maneje claves: «Crear usuario»
acuña un enlace de invitación de un solo uso (`POST /api/invites` → bus
`POST /invites`, admin-only). El gateway construye el enlace
`<<APP_BASE>>/join?token=XXX`, donde `<<APP_BASE>>` es la URL del **cliente
final** (la página que hospeda `/join`), NO la del panel. Se configura en el
gateway con `--join-base-url https://chat.unibus.example` o la variable
`UNIBUS_JOIN_BASE_URL`; el valor se expone en `/api/me` (`join_base_url`). Si no
se configura, la SPA usa su propio origen como respaldo y avisa de configurarlo.
El usuario abre el enlace en su dispositivo, genera ahí su par de claves (la
privada nunca sale del equipo) y se registra (`POST /register`, lo consume la
página `/join` del cliente web — ver el contrato en el report del bus).
«Eliminar» es un **hard-delete permanente** (`DELETE /api/users/{pub}` → bus
`DELETE /users/{signpub}`), distinto de «Revocar» (status flip auditable). La UI
exige teclear el handle para confirmarlo (no se dispara por un clic accidental).
## Gaps conocidos ## Gaps conocidos
- **Cuentas (invites + hard-delete) contra el cluster desplegado**: el gateway y la
SPA ya consumen `POST/GET /api/invites` y `DELETE /api/users/{pub}`, verificados
end-to-end contra un `membershipd` v0.12.0 local (crear invite → registrar por
`/register` sin firma → aparece en `/users` → hard-delete → desaparece) y vía
`--mock`. El gap es de **despliegue**: el cluster corre hoy v0.11.0 (sin
`/invites`/`/register`/`DELETE /users`), así que la pestaña Users sólo creará/
eliminará cuentas en producción cuando el bus se actualice a v0.12.0 (rollout
pendiente). Verificado contra el cluster vivo: `/register` devuelve 401 en
magnus/homer/datardos (ruta aún no desplegada) frente a 400 en el nodo v0.12.0
local. El merge de esta rama del panel a master debe seguir al merge del bus.
- **Users contra el cluster desplegado**: el código del plano de control (unibus - **Users contra el cluster desplegado**: el código del plano de control (unibus
master, v0.10.0) ya expone la API admin-only de users (`GET/POST /users`, master, v0.10.0) ya expone la API admin-only de users (`GET/POST /users`,
`POST /users/{signpub}/revoke`) y el gateway la consume firmando como el operador. `POST /users/{signpub}/revoke`) y el gateway la consume firmando como el operador.
@@ -145,3 +173,22 @@ ofuscado (`admin-<hash>.organic-machine.com`). Credenciales en `pass`
- **Invite a room E2E**: requiere las claves públicas (sign_pub + kex_pub) del - **Invite a room E2E**: requiere las claves públicas (sign_pub + kex_pub) del
invitado en hex, porque la clave de room se sella contra su X25519. La UI las pide invitado en hex, porque la clave de room se sella contra su X25519. La UI las pide
manualmente; no hay directorio de claves públicas todavía. manualmente; no hay directorio de claves públicas todavía.
## Capability growth log
- v0.2.0 (2026-06-07) — capa de CUENTAS estilo WhatsApp sobre el bus v0.12.0. El
gateway gana `POST/GET /api/invites` (acuñar/listar invitaciones de un solo uso,
consumiendo `client.CreateInvite/ListInvites`) y `DELETE /api/users/{pub}`
(hard-delete, `client.DeleteUser`), con la misma doble vía que el resto de users
(plano de control firmado en cluster / store directo en single-node). El enlace de
invitación `<<APP_BASE>>/join?token=…` lo construye el gateway desde
`--join-base-url` / `UNIBUS_JOIN_BASE_URL` (URL del cliente final, no del panel),
expuesto en `/api/me`. La SPA añade a la pestaña Users: botón «Crear usuario»
(modal handle+rol+caducidad → enlace copiable), card de invitaciones pendientes
(handle, rol, token parcial, caducidad, copiar enlace), y botón «Eliminar» con
confirmación FUERTE (teclear el handle) que distingue el borrado permanente del
revoke. Verificado: flujo e2e contra `membershipd` v0.12.0 local (invite → register
por curl sin firma → aparece en /users → re-register 409 → hard-delete → desaparece)
y gateway vía `--mock` (las rutas nuevas + el SPA embebido sirven). El cluster vivo
sigue en v0.11.0 (rollout del bus pendiente del orquestador); esta rama del panel se
mergea a master tras el merge del bus a master. build/vet/web-build verdes.
+40
View File
@@ -84,6 +84,30 @@ type AddUserReq struct {
Role string `json:"role"` Role string `json:"role"`
} }
// CreateInviteReq is the create-invite payload from the SPA. The admin fixes the
// handle and role the future user will receive; TTLSecs is optional (0 uses the
// bus default of 7 days). The admin never supplies a key — the user's client
// generates its own keypair and publishes only its public keys at /register.
type CreateInviteReq struct {
Handle string `json:"handle"`
Role string `json:"role"`
TTLSecs int `json:"ttl_secs"`
}
// InviteView is a single-use registration invite as the admin panel sees it. The
// token is the bearer secret the admin turns into a join link; JoinURL is that
// link, pre-built by the gateway from the configured client base URL so the SPA
// does not have to know where the client lives.
type InviteView struct {
Token string `json:"token"`
Handle string `json:"handle"`
Role string `json:"role"`
ExpiresAt string `json:"expires_at"`
Used bool `json:"used"`
CreatedAt string `json:"created_at"`
JoinURL string `json:"join_url"`
}
// MeInfo describes the gateway's own identity and which capabilities are wired, // MeInfo describes the gateway's own identity and which capabilities are wired,
// so the SPA can render the operator endpoint and label the Users tab's backend. // so the SPA can render the operator endpoint and label the Users tab's backend.
type MeInfo struct { type MeInfo struct {
@@ -91,6 +115,12 @@ type MeInfo struct {
SignPub string `json:"sign_pub"` SignPub string `json:"sign_pub"`
UsersBackend string `json:"users_backend"` // "control-plane" (signed HTTP) | "sqlite" (single-node fallback) UsersBackend string `json:"users_backend"` // "control-plane" (signed HTTP) | "sqlite" (single-node fallback)
Mock bool `json:"mock"` Mock bool `json:"mock"`
// JoinBaseURL is the base URL of the END-USER client (the page that hosts
// /join?token=…), configured on the gateway (--join-base-url / env
// UNIBUS_JOIN_BASE_URL). It is NOT the admin panel's own URL: the join link
// the admin shares points at the user-facing client, a separate app. Empty
// when unconfigured; the SPA then falls back to its own origin and warns.
JoinBaseURL string `json:"join_base_url"`
} }
// Repo is the data source behind the REST API. Two implementations exist: // Repo is the data source behind the REST API. Two implementations exist:
@@ -121,4 +151,14 @@ type Repo interface {
ListUsers(ctx context.Context) ([]UserView, error) ListUsers(ctx context.Context) ([]UserView, error)
AddUser(ctx context.Context, req AddUserReq) error AddUser(ctx context.Context, req AddUserReq) error
RevokeUser(ctx context.Context, signPub string) error RevokeUser(ctx context.Context, signPub string) error
// DeleteUser hard-deletes a user (purge), distinct from RevokeUser's status
// flip. The admin panel maps its "Eliminar (permanente)" action here.
DeleteUser(ctx context.Context, signPub string) error
// Invites (the wallet-model account-creation path). CreateInvite mints a
// single-use registration link the admin shares; the user redeems it from
// their own client without the admin ever handling a private key. ListInvites
// returns the pending links.
CreateInvite(ctx context.Context, req CreateInviteReq) (InviteView, error)
ListInvites(ctx context.Context) ([]InviteView, error)
} }
+120
View File
@@ -45,6 +45,11 @@ type busRepo struct {
// signed control-plane API on r.cli instead — see ListUsers/AddUser/RevokeUser. // signed control-plane API on r.cli instead — see ListUsers/AddUser/RevokeUser.
store membership.Store store membership.Store
storeBackend string // "control-plane" (cli) | "sqlite" (direct store fallback) storeBackend string // "control-plane" (cli) | "sqlite" (direct store fallback)
// joinBaseURL is the base URL of the end-user client that hosts /join?token=…
// (NOT the admin panel). The gateway builds the shareable join link from it so
// the SPA never has to know where the client lives. Empty when unconfigured.
joinBaseURL string
} }
// BusConfig wires a live gateway. // BusConfig wires a live gateway.
@@ -58,6 +63,8 @@ type BusConfig struct {
Nodes []NodeTarget // nodes to probe for /healthz Nodes []NodeTarget // nodes to probe for /healthz
Store membership.Store Store membership.Store
StoreBackend string StoreBackend string
// JoinBaseURL is the end-user client base URL used to build invite join links.
JoinBaseURL string
} }
// NewBusRepo connects the unibus client with the admin identity and builds the // NewBusRepo connects the unibus client with the admin identity and builds the
@@ -108,9 +115,20 @@ func NewBusRepo(cfg BusConfig) (*busRepo, error) {
nodes: cfg.Nodes, nodes: cfg.Nodes,
store: cfg.Store, store: cfg.Store,
storeBackend: backend, storeBackend: backend,
joinBaseURL: strings.TrimRight(cfg.JoinBaseURL, "/"),
}, nil }, nil
} }
// joinURL builds the shareable registration link for a token from the configured
// client base URL. It returns "" when no base URL is configured, so the SPA can
// fall back to its own origin (and warn that the link should be configured).
func (r *busRepo) joinURL(token string) string {
if r.joinBaseURL == "" {
return ""
}
return r.joinBaseURL + "/join?token=" + token
}
// Close releases the bus client connection. // Close releases the bus client connection.
func (r *busRepo) Close() error { func (r *busRepo) Close() error {
if r.cli != nil { if r.cli != nil {
@@ -125,6 +143,7 @@ func (r *busRepo) Me(context.Context) MeInfo {
SignPub: hex.EncodeToString(r.id.SignPub), SignPub: hex.EncodeToString(r.id.SignPub),
UsersBackend: r.storeBackend, UsersBackend: r.storeBackend,
Mock: false, Mock: false,
JoinBaseURL: r.joinBaseURL,
} }
} }
@@ -389,3 +408,104 @@ func (r *busRepo) RevokeUser(_ context.Context, signPub string) error {
} }
return r.store.RevokeUser(signPub) return r.store.RevokeUser(signPub)
} }
// DeleteUser hard-deletes a user (purge), distinct from RevokeUser. Like the
// other user ops it goes through the signed control plane in cluster, or the
// direct store in the single-node fallback.
func (r *busRepo) DeleteUser(_ context.Context, signPub string) error {
if r.store == nil {
return r.cli.DeleteUser(signPub)
}
return r.store.DeleteUser(signPub)
}
// ---- invites --------------------------------------------------------------
// CreateInvite mints a single-use registration invite and returns it with the
// shareable join link pre-built. Cluster path goes through the signed control
// plane; the single-node fallback hits the store directly.
func (r *busRepo) CreateInvite(_ context.Context, req CreateInviteReq) (InviteView, error) {
if r.store == nil {
inv, err := r.cli.CreateInvite(req.Handle, req.Role, req.TTLSecs)
if err != nil {
return InviteView{}, err
}
return InviteView{
Token: inv.Token,
Handle: inv.Handle,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt,
JoinURL: r.joinURL(inv.Token),
}, nil
}
inv, err := r.store.CreateInvite(req.Handle, req.Role, req.TTLSecs)
if err != nil {
return InviteView{}, err
}
return InviteView{
Token: inv.Token,
Handle: inv.Handle,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt,
CreatedAt: inv.CreatedAt,
JoinURL: r.joinURL(inv.Token),
}, nil
}
// ListInvites returns the PENDING invites (not used, not expired) with their join
// links. The control-plane GET /invites already filters to pending; the direct
// store returns everything, so we filter here for parity.
func (r *busRepo) ListInvites(_ context.Context) ([]InviteView, error) {
if r.store == nil {
invs, err := r.cli.ListInvites()
if err != nil {
return nil, err
}
out := make([]InviteView, 0, len(invs))
for _, inv := range invs {
out = append(out, InviteView{
Token: inv.Token,
Handle: inv.Handle,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt,
Used: inv.Used,
CreatedAt: inv.CreatedAt,
JoinURL: r.joinURL(inv.Token),
})
}
return out, nil
}
invs, err := r.store.ListInvites()
if err != nil {
return nil, err
}
out := make([]InviteView, 0, len(invs))
for _, inv := range invs {
if !invitePending(inv.ExpiresAt, inv.Used) {
continue
}
out = append(out, InviteView{
Token: inv.Token,
Handle: inv.Handle,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt,
Used: inv.Used,
CreatedAt: inv.CreatedAt,
JoinURL: r.joinURL(inv.Token),
})
}
return out, nil
}
// invitePending reports whether an invite is live (not used, not past its
// deadline). A malformed deadline is treated as expired (fail closed).
func invitePending(expiresAt string, used bool) bool {
if used {
return false
}
exp, err := time.Parse(time.RFC3339Nano, expiresAt)
if err != nil {
return false
}
return time.Now().UTC().Before(exp)
}
+67 -4
View File
@@ -2,19 +2,26 @@ package admin
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"sync" "sync"
"time"
) )
// mockJoinBaseURL is the sample client base URL the mock uses to build join links.
const mockJoinBaseURL = "https://chat.unibus.example"
// mockRepo serves sample data so the SPA can be iterated and demoed without a // mockRepo serves sample data so the SPA can be iterated and demoed without a
// live bus. It is selected with --mock. All mutations are kept in memory so the // live bus. It is selected with --mock. All mutations are kept in memory so the
// UI feels real during a session (create a room, see it appear) without touching // UI feels real during a session (create a room, see it appear) without touching
// any control plane. // any control plane.
type mockRepo struct { type mockRepo struct {
mu sync.Mutex mu sync.Mutex
rooms []RoomView rooms []RoomView
users []UserView users []UserView
mem map[string][]MemberView mem map[string][]MemberView
invites []InviteView
} }
// NewMockRepo returns a Repo backed by in-memory sample data (--mock). // NewMockRepo returns a Repo backed by in-memory sample data (--mock).
@@ -50,6 +57,7 @@ func (m *mockRepo) Me(context.Context) MeInfo {
SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa", SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa",
UsersBackend: "sqlite", UsersBackend: "sqlite",
Mock: true, Mock: true,
JoinBaseURL: mockJoinBaseURL,
} }
} }
@@ -155,3 +163,58 @@ func (m *mockRepo) RevokeUser(_ context.Context, signPub string) error {
} }
return nil return nil
} }
func (m *mockRepo) DeleteUser(_ context.Context, signPub string) error {
m.mu.Lock()
defer m.mu.Unlock()
kept := m.users[:0]
for _, u := range m.users {
if u.SignPub != signPub {
kept = append(kept, u)
}
}
m.users = kept
return nil
}
func (m *mockRepo) CreateInvite(_ context.Context, req CreateInviteReq) (InviteView, error) {
m.mu.Lock()
defer m.mu.Unlock()
role := req.Role
if role == "" {
role = "member"
}
tokRaw := make([]byte, 32)
if _, err := rand.Read(tokRaw); err != nil {
return InviteView{}, err
}
token := hex.EncodeToString(tokRaw)
ttl := time.Duration(req.TTLSecs) * time.Second
if req.TTLSecs <= 0 {
ttl = 7 * 24 * time.Hour
}
now := time.Now().UTC()
inv := InviteView{
Token: token,
Handle: req.Handle,
Role: role,
ExpiresAt: now.Add(ttl).Format(time.RFC3339Nano),
Used: false,
CreatedAt: now.Format(time.RFC3339Nano),
JoinURL: mockJoinBaseURL + "/join?token=" + token,
}
m.invites = append(m.invites, inv)
return inv, nil
}
func (m *mockRepo) ListInvites(context.Context) ([]InviteView, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]InviteView, 0, len(m.invites))
for _, inv := range m.invites {
if invitePending(inv.ExpiresAt, inv.Used) {
out = append(out, inv)
}
}
return out, nil
}
+45
View File
@@ -54,6 +54,12 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/users", s.handleListUsers) s.mux.HandleFunc("GET /api/users", s.handleListUsers)
s.mux.HandleFunc("POST /api/users", s.handleAddUser) s.mux.HandleFunc("POST /api/users", s.handleAddUser)
s.mux.HandleFunc("POST /api/users/revoke", s.handleRevokeUser) s.mux.HandleFunc("POST /api/users/revoke", s.handleRevokeUser)
// Hard-delete (purge) a user by signing key — distinct from revoke.
s.mux.HandleFunc("DELETE /api/users/{pub}", s.handleDeleteUser)
// Invites — the wallet-model account-creation path.
s.mux.HandleFunc("GET /api/invites", s.handleListInvites)
s.mux.HandleFunc("POST /api/invites", s.handleCreateInvite)
// Everything else is the SPA (and its assets). Registered last as the catch-all. // Everything else is the SPA (and its assets). Registered last as the catch-all.
s.mux.Handle("/", s.spa) s.mux.Handle("/", s.spa)
@@ -179,6 +185,45 @@ func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
} }
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
pub := strings.TrimSpace(r.PathValue("pub"))
if pub == "" {
writeErr(w, http.StatusBadRequest, "sign_pub required")
return
}
if err := s.repo.DeleteUser(r.Context(), pub); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request) {
invites, err := s.repo.ListInvites(r.Context())
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, invites)
}
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
var req CreateInviteReq
if !decode(w, r, &req) {
return
}
if strings.TrimSpace(req.Handle) == "" {
writeErr(w, http.StatusBadRequest, "handle required")
return
}
inv, err := s.repo.CreateInvite(r.Context(), req)
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, inv)
}
// ---- SPA serving ---------------------------------------------------------- // ---- SPA serving ----------------------------------------------------------
// spaHandler serves the embedded SPA. A request for an existing asset is served // spaHandler serves the embedded SPA. A request for an existing asset is served
+16
View File
@@ -37,10 +37,20 @@ func main() {
identityFile = flag.String("identity-file", "", "path to the admin identity JSON file (0600). Mutually exclusive with --identity-pass") identityFile = flag.String("identity-file", "", "path to the admin identity JSON file (0600). Mutually exclusive with --identity-pass")
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the admin identity JSON, e.g. unibus/operator-identity") identityPass = flag.String("identity-pass", "", "pass(1) entry holding the admin identity JSON, e.g. unibus/operator-identity")
dbPath = flag.String("db", "", "OPTIONAL membership SQLite path for single-node user management. Empty (default) = manage users via the signed control-plane API, which works in cluster") dbPath = flag.String("db", "", "OPTIONAL membership SQLite path for single-node user management. Empty (default) = manage users via the signed control-plane API, which works in cluster")
joinBaseURL = flag.String("join-base-url", "", "base URL of the END-USER client that hosts /join?token=… (e.g. https://chat.unibus.example). Used to build shareable invite links. Falls back to env UNIBUS_JOIN_BASE_URL")
mock = flag.Bool("mock", false, "serve sample data instead of talking to the bus (UI iteration)") mock = flag.Bool("mock", false, "serve sample data instead of talking to the bus (UI iteration)")
) )
flag.Parse() flag.Parse()
// The end-user client base URL (for invite join links) comes from the flag or,
// if unset, the env var. It is NOT the admin panel's own URL — the join link
// points at the user-facing client, a separate app. Empty leaves the SPA to
// fall back to its own origin and warn.
joinBase := *joinBaseURL
if joinBase == "" {
joinBase = os.Getenv("UNIBUS_JOIN_BASE_URL")
}
log.SetFlags(log.LstdFlags | log.Lmsgprefix) log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[unibus_admin] ") log.SetPrefix("[unibus_admin] ")
@@ -83,6 +93,7 @@ func main() {
Nodes: nodes, Nodes: nodes,
Store: store, Store: store,
StoreBackend: backend, StoreBackend: backend,
JoinBaseURL: joinBase,
}) })
if err != nil { if err != nil {
log.Fatalf("%v", err) log.Fatalf("%v", err)
@@ -98,6 +109,11 @@ func main() {
tls = "ON (CA " + *caPath + ")" tls = "ON (CA " + *caPath + ")"
} }
log.Printf("bus TLS+nkey: %s", tls) log.Printf("bus TLS+nkey: %s", tls)
if joinBase != "" {
log.Printf("invite join base: %s", joinBase)
} else {
log.Printf("invite join base: (unset; SPA falls back to its own origin — set --join-base-url or UNIBUS_JOIN_BASE_URL)")
}
} }
srv := admin.NewServer(repo, files) srv := admin.NewServer(repo, files)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>unibus · admin</title> <title>unibus · admin</title>
<script type="module" crossorigin src="/assets/index-CGRScjCy.js"></script> <script type="module" crossorigin src="/assets/index-Dg19WJJu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ndvieWwa.css"> <link rel="stylesheet" crossorigin href="/assets/index-ndvieWwa.css">
</head> </head>
<body> <body>
+1 -1
View File
@@ -91,7 +91,7 @@ export function AdminShell({ me }: { me: MeInfo }) {
<AppShell.Main> <AppShell.Main>
{tab === "cluster" && <ClusterPage />} {tab === "cluster" && <ClusterPage />}
{tab === "rooms" && <RoomsPage />} {tab === "rooms" && <RoomsPage />}
{tab === "users" && <UsersPage usersBackend={me.users_backend} />} {tab === "users" && <UsersPage usersBackend={me.users_backend} joinBaseURL={me.join_base_url} />}
</AppShell.Main> </AppShell.Main>
</AppShell> </AppShell>
); );
+13
View File
@@ -4,8 +4,10 @@
// and real, and never signs or speaks NATS itself. // and real, and never signs or speaks NATS itself.
import type { import type {
AddUserReq, AddUserReq,
CreateInviteReq,
CreateRoomReq, CreateRoomReq,
InviteReq, InviteReq,
InviteView,
MeInfo, MeInfo,
MemberView, MemberView,
NodeHealth, NodeHealth,
@@ -76,4 +78,15 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ sign_pub: signPub }), body: JSON.stringify({ sign_pub: signPub }),
}), }),
deleteUser: (signPub: string) =>
req<{ status: string }>(`/api/users/${encodeURIComponent(signPub)}`, {
method: "DELETE",
}),
listInvites: () => req<InviteView[]>("/api/invites"),
createInvite: (r: CreateInviteReq) =>
req<InviteView>("/api/invites", {
method: "POST",
body: JSON.stringify(r),
}),
}; };
+243 -15
View File
@@ -1,12 +1,15 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
ActionIcon, ActionIcon,
Alert,
Badge, Badge,
Button, Button,
Card, Card,
CopyButton,
Group, Group,
Loader, Loader,
Modal, Modal,
NumberInput,
Select, Select,
Stack, Stack,
Table, Table,
@@ -17,26 +20,49 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IconPlus, IconRefresh, IconUserOff } from "@tabler/icons-react"; import {
IconAlertTriangle,
IconCheck,
IconCopy,
IconPlus,
IconRefresh,
IconTicket,
IconTrash,
IconUserOff,
} from "@tabler/icons-react";
import { api, ApiError } from "../api"; import { api, ApiError } from "../api";
import type { UserView } from "../types"; import type { InviteView, UserView } from "../types";
import { fmtTime, trunc } from "../util"; import { fmtTime, trunc } from "../util";
function notifyErr(e: unknown) { function notifyErr(e: unknown) {
notifications.show({ color: "red", title: "Error", message: e instanceof ApiError ? e.message : String(e) }); notifications.show({ color: "red", title: "Error", message: e instanceof ApiError ? e.message : String(e) });
} }
export function UsersPage({ usersBackend }: { usersBackend: string }) { // resolveJoinURL prefers the gateway-built link; when the gateway has no client
// base URL configured it falls back to the panel's own origin so the link is at
// least clickable, and the caller surfaces a warning to configure it properly.
function resolveJoinURL(inv: { join_url: string; token: string }): string {
if (inv.join_url) return inv.join_url;
return `${window.location.origin}/join?token=${inv.token}`;
}
export function UsersPage({ usersBackend, joinBaseURL }: { usersBackend: string; joinBaseURL: string }) {
const [users, setUsers] = useState<UserView[] | null>(null); const [users, setUsers] = useState<UserView[] | null>(null);
const [invites, setInvites] = useState<InviteView[]>([]);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [addOpen, addCtl] = useDisclosure(false); const [addOpen, addCtl] = useDisclosure(false);
const [createOpen, createCtl] = useDisclosure(false);
const [toDelete, setToDelete] = useState<UserView | null>(null);
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
api Promise.all([api.listUsers(), api.listInvites().catch(() => [] as InviteView[])])
.listUsers() .then(([u, inv]) => {
.then((u) => { setUsers(u); setErr(null); }) setUsers(u);
setInvites(inv);
setErr(null);
})
.catch((e: ApiError) => setErr(e.message)) .catch((e: ApiError) => setErr(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -44,7 +70,7 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const revoke = async (u: UserView) => { const revoke = async (u: UserView) => {
if (!window.confirm(`¿Revocar a "${u.handle}"? Pierde acceso al bus en AMBOS planos de inmediato (control y datos).`)) return; if (!window.confirm(`¿Revocar a "${u.handle}"? Pierde acceso al bus en AMBOS planos de inmediato (control y datos). La identidad permanece en la lista como revocada (auditable).`)) return;
try { try {
await api.revokeUser(u.sign_pub); await api.revokeUser(u.sign_pub);
notifications.show({ color: "teal", title: "Revocado", message: u.handle }); notifications.show({ color: "teal", title: "Revocado", message: u.handle });
@@ -72,8 +98,11 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
<IconRefresh size={18} /> <IconRefresh size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Button leftSection={<IconPlus size={16} />} onClick={addCtl.open}> <Button variant="default" leftSection={<IconPlus size={16} />} onClick={addCtl.open}>
Añadir user Añadir user (clave conocida)
</Button>
<Button leftSection={<IconTicket size={16} />} onClick={createCtl.open}>
Crear usuario
</Button> </Button>
</Group> </Group>
</Group> </Group>
@@ -81,6 +110,48 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
{err && <Text c="red">{err}</Text>} {err && <Text c="red">{err}</Text>}
{!users && !err && <Loader color="brand" />} {!users && !err && <Loader color="brand" />}
{invites.length > 0 && (
<Card withBorder bg="dark.7" radius="md">
<Stack gap="sm">
<Group gap="sm">
<IconTicket size={18} />
<Text fw={600}>Invitaciones pendientes</Text>
<Badge color="brand" variant="light">{invites.length}</Badge>
</Group>
<Table verticalSpacing="xs" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Handle</Table.Th>
<Table.Th>Rol</Table.Th>
<Table.Th>Token</Table.Th>
<Table.Th>Caduca</Table.Th>
<Table.Th>Enlace</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{invites.map((inv) => (
<Table.Tr key={inv.token}>
<Table.Td><Text fw={600}>{inv.handle}</Text></Table.Td>
<Table.Td><Badge variant="dot" color={inv.role === "admin" ? "brand" : "gray"}>{inv.role}</Badge></Table.Td>
<Table.Td><Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>{trunc(inv.token, 10, 6)}</Text></Table.Td>
<Table.Td><Text size="xs" c="dimmed">{fmtTime(inv.expires_at)}</Text></Table.Td>
<Table.Td>
<CopyButton value={resolveJoinURL(inv)}>
{({ copied, copy }) => (
<Button size="xs" variant="light" color={copied ? "teal" : "brand"} leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />} onClick={copy}>
{copied ? "Copiado" : "Copiar enlace"}
</Button>
)}
</CopyButton>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Card>
)}
{users && ( {users && (
<Card withBorder bg="dark.7" p={0} radius="md"> <Card withBorder bg="dark.7" p={0} radius="md">
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover> <Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
@@ -109,13 +180,20 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
</Table.Td> </Table.Td>
<Table.Td><Text size="xs" c="dimmed">{fmtTime(u.created_at)}</Text></Table.Td> <Table.Td><Text size="xs" c="dimmed">{fmtTime(u.created_at)}</Text></Table.Td>
<Table.Td> <Table.Td>
{u.status === "active" && ( <Group gap={4} justify="flex-end" wrap="nowrap">
<Tooltip label="Revocar acceso"> {u.status === "active" && (
<ActionIcon variant="subtle" color="red" onClick={() => revoke(u)}> <Tooltip label="Revocar acceso (deja rastro auditable)">
<IconUserOff size={16} /> <ActionIcon variant="subtle" color="orange" onClick={() => revoke(u)}>
<IconUserOff size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Eliminar (borrado permanente)">
<ActionIcon variant="subtle" color="red" onClick={() => setToDelete(u)}>
<IconTrash size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
@@ -125,10 +203,14 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
)} )}
<AddUserModal opened={addOpen} onClose={addCtl.close} onAdded={load} /> <AddUserModal opened={addOpen} onClose={addCtl.close} onAdded={load} />
<CreateInviteModal opened={createOpen} onClose={createCtl.close} onCreated={load} joinBaseURL={joinBaseURL} />
<DeleteUserModal user={toDelete} onClose={() => setToDelete(null)} onDeleted={load} />
</Stack> </Stack>
); );
} }
// AddUserModal registers a user from a sign_pub the admin already holds (the
// pre-invite flow, kept for advanced use). The wallet-model path is CreateInvite.
function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose: () => void; onAdded: () => void }) { function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose: () => void; onAdded: () => void }) {
const [handle, setHandle] = useState(""); const [handle, setHandle] = useState("");
const [signPub, setSignPub] = useState(""); const [signPub, setSignPub] = useState("");
@@ -153,8 +235,12 @@ function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose:
const ready = handle.trim().length > 0 && signPub.trim().length === 64; const ready = handle.trim().length > 0 && signPub.trim().length === 64;
return ( return (
<Modal opened={opened} onClose={onClose} title="Añadir user al bus" centered> <Modal opened={opened} onClose={onClose} title="Añadir user (clave ya conocida)" centered>
<Stack gap="sm"> <Stack gap="sm">
<Text size="xs" c="dimmed">
Para cuando ya tienes la clave pública del usuario. Para el alta normal sin manejar
claves, usa «Crear usuario» (enlace de invitación).
</Text>
<TextInput label="Handle" placeholder="ana" value={handle} onChange={(e) => setHandle(e.currentTarget.value)} data-autofocus /> <TextInput label="Handle" placeholder="ana" value={handle} onChange={(e) => setHandle(e.currentTarget.value)} data-autofocus />
<TextInput <TextInput
label="sign_pub (hex, 64)" label="sign_pub (hex, 64)"
@@ -173,3 +259,145 @@ function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose:
</Modal> </Modal>
); );
} }
// CreateInviteModal is the wallet-model account creation: the admin fixes a handle
// and role and gets back a single-use join link. The admin never sees a private
// key — the user generates its own keypair when it opens the link.
function CreateInviteModal({
opened,
onClose,
onCreated,
joinBaseURL,
}: {
opened: boolean;
onClose: () => void;
onCreated: () => void;
joinBaseURL: string;
}) {
const [handle, setHandle] = useState("");
const [role, setRole] = useState<string>("member");
const [days, setDays] = useState<number | string>(7);
const [busy, setBusy] = useState(false);
const [created, setCreated] = useState<InviteView | null>(null);
const reset = () => { setHandle(""); setRole("member"); setDays(7); setCreated(null); };
const close = () => { reset(); onClose(); };
const submit = async () => {
setBusy(true);
try {
const ttl = typeof days === "number" ? days * 86400 : 0;
const inv = await api.createInvite({ handle: handle.trim(), role, ttl_secs: ttl });
setCreated(inv);
onCreated();
} catch (e) {
notifyErr(e);
} finally {
setBusy(false);
}
};
const ready = handle.trim().length > 0;
const joinURL = created ? resolveJoinURL(created) : "";
return (
<Modal opened={opened} onClose={close} title="Crear usuario (enlace de invitación)" centered>
{!created ? (
<Stack gap="sm">
<Text size="xs" c="dimmed">
Genera un enlace de un solo uso. El usuario lo abre en su dispositivo, crea ahí su
clave (la privada nunca sale de su equipo) y se registra. no manejas ninguna clave.
</Text>
<TextInput label="Handle" placeholder="ana" value={handle} onChange={(e) => setHandle(e.currentTarget.value)} data-autofocus />
<Select label="Rol" data={[{ value: "member", label: "member" }, { value: "admin", label: "admin" }]} value={role} onChange={(v) => setRole(v || "member")} allowDeselect={false} />
<NumberInput label="Caducidad (días)" min={1} max={90} value={days} onChange={setDays} />
<Group justify="flex-end">
<Button variant="default" onClick={close}>Cancelar</Button>
<Button onClick={submit} loading={busy} disabled={!ready} leftSection={<IconTicket size={16} />}>Crear enlace</Button>
</Group>
</Stack>
) : (
<Stack gap="sm">
<Alert color="teal" icon={<IconCheck size={16} />} title={`Invitación para "${created.handle}" (${created.role})`}>
Comparte este enlace con el usuario. Es de un solo uso y caduca el {fmtTime(created.expires_at)}.
</Alert>
{!joinBaseURL && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />} title="Base URL del cliente sin configurar">
El enlace usa el origen de este panel como respaldo. Configura el cliente real con
<Text span fw={600}> --join-base-url </Text> o la variable
<Text span fw={600}> UNIBUS_JOIN_BASE_URL </Text> en el gateway.
</Alert>
)}
<TextInput readOnly value={joinURL} styles={{ input: { fontFamily: "monospace", fontSize: 12 } }} />
<Group justify="space-between">
<CopyButton value={joinURL}>
{({ copied, copy }) => (
<Button color={copied ? "teal" : "brand"} leftSection={copied ? <IconCheck size={16} /> : <IconCopy size={16} />} onClick={copy}>
{copied ? "Copiado" : "Copiar enlace"}
</Button>
)}
</CopyButton>
<Button variant="default" onClick={close}>Cerrar</Button>
</Group>
</Stack>
)}
</Modal>
);
}
// DeleteUserModal is the STRONG confirmation for a permanent hard-delete. Unlike
// revoke (a one-click window.confirm), purging requires typing the handle, so it
// cannot be triggered by a stray click. The wording makes the irreversibility and
// the difference from revoke explicit.
function DeleteUserModal({ user, onClose, onDeleted }: { user: UserView | null; onClose: () => void; onDeleted: () => void }) {
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { setConfirm(""); }, [user]);
if (!user) return null;
const submit = async () => {
setBusy(true);
try {
await api.deleteUser(user.sign_pub);
notifications.show({ color: "red", title: "Usuario eliminado", message: `${user.handle} purgado del allowlist` });
onClose();
onDeleted();
} catch (e) {
notifyErr(e);
} finally {
setBusy(false);
}
};
const ready = confirm.trim() === user.handle;
return (
<Modal opened={!!user} onClose={onClose} title="Borrado permanente" centered>
<Stack gap="sm">
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Esto NO es revocar">
<Text size="sm">
Vas a <Text span fw={700}>BORRAR PERMANENTEMENTE</Text> a «{user.handle}» del allowlist
del bus. Se elimina por completo (sin rastro auditable, a diferencia de revocar). El
usuario deja de poder autenticarse en ambos planos; sus membresías de rooms quedan
inertes. Esta acción es irreversible.
</Text>
</Alert>
<TextInput
label={`Escribe «${user.handle}» para confirmar`}
placeholder={user.handle}
value={confirm}
onChange={(e) => setConfirm(e.currentTarget.value)}
data-autofocus
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancelar</Button>
<Button color="red" leftSection={<IconTrash size={16} />} onClick={submit} loading={busy} disabled={!ready}>
Eliminar permanentemente
</Button>
</Group>
</Stack>
</Modal>
);
}
+17
View File
@@ -49,6 +49,23 @@ export interface MeInfo {
sign_pub: string; sign_pub: string;
users_backend: string; // "sqlite" | "kv" | "none" users_backend: string; // "sqlite" | "kv" | "none"
mock: boolean; mock: boolean;
join_base_url: string; // base URL of the end-user client (for invite links); "" when unset
}
export interface InviteView {
token: string;
handle: string;
role: string;
expires_at: string;
used: boolean;
created_at: string;
join_url: string; // pre-built join link; "" when the gateway has no client base URL
}
export interface CreateInviteReq {
handle: string;
role: string;
ttl_secs: number;
} }
export interface CreateRoomReq { export interface CreateRoomReq {