Compare commits

...

6 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
egutierrez 1b19f8e60f docs: app.md reflects control-plane Users wiring and the deploy gap
Update the architecture diagram, capability table, run examples and known
gaps to describe user management via the signed control-plane API instead
of the old store-only/--db path. Record the remaining gap honestly: the
membershipd binaries currently deployed on the cluster predate the /users
route and return 404, so the Users tab becomes functional in production
once the bus is updated to v0.10.0; the gateway already connects and signs
correctly against those nodes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:10:41 +02:00
egutierrez c7631074cb feat: make the Users tab operational, drop the degraded empty state
With user management now wired through the control-plane API, the Users
tab is always functional against a live gateway. Remove the "Gestión de
users no disponible" alert and the writable gating (button disabled,
revoke hidden) that were driven by the old users_backend === "none"
case. The backend badge now reads the wiring in use ("control-plane" or
"sqlite"). Add user (handle + 64-hex sign-pub + role) and revoke (with
explicit confirmation) consume the gateway REST unchanged. Includes the
rebuilt SPA bundle embedded by the binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:10:35 +02:00
egutierrez c412941e4c feat: route Users management through the signed control-plane API
The gateway previously managed the bus allowlist only via a direct
membership store opened with --db, falling back to a "none" backend that
left the Users tab degraded in cluster (the control plane exposed no user
HTTP endpoint). The unibus control plane now exposes an admin-only user
API (GET/POST /users, POST /users/{signpub}/revoke), and pkg/client wraps
it with ListUsers/AddUser/RevokeUser that sign each request.

busRepo now drives those client methods whenever no direct store is
configured (the cluster default), so user management works in cluster
without KV/SQLite access — the bus verifies the operator's admin identity
with requireAdmin and writes to the same store the room handlers use. A
direct store (--db) is kept as an explicit single-node fallback. The
reported users_backend becomes "control-plane" (or "sqlite" with --db),
and ErrUsersUnavailable / the "none" path are removed since a connected
gateway can always reach the API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:10:27 +02:00
13 changed files with 873 additions and 250 deletions
+67 -12
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
@@ -58,9 +58,10 @@ navegador (SPA Mantine)
unibus_admin (gateway Go, identidad ADMIN del operador) unibus_admin (gateway Go, identidad ADMIN del operador)
├── pkg/client (unibus) → CreateRoom / Invite / Kick / ListMyRooms (firma + cripto E2E) ├── pkg/client (unibus) → CreateRoom / Invite / Kick / ListMyRooms (firma + cripto E2E)
├── pkg/client (unibus) → ListUsers / AddUser / RevokeUser (API admin firmada; funciona en cluster)
├── GET firmado → /rooms/{id}/members (CanonicalRequest + SignEd25519, reusa la construcción del bus) ├── GET firmado → /rooms/{id}/members (CanonicalRequest + SignEd25519, reusa la construcción del bus)
├── GET /healthz (CA-pinned)→ estado + posture de los 3 nodos del cluster ├── GET /healthz (CA-pinned)→ estado + posture de los 3 nodos del cluster
└── membership.Store (opc.) → users (allowlist) cuando hay acceso directo al store └── membership.Store (opc.) → users (allowlist) como fallback single-node con --db
cluster unibus (magnus + homer + datardos, enforce + ACL + TLS + KV) cluster unibus (magnus + homer + datardos, enforce + ACL + TLS + KV)
``` ```
@@ -77,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 | `membership.Store` directo — sólo con `--db` (single-node) o acceso KV admin | | **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
@@ -85,13 +86,14 @@ no expone. Nunca reimplementa firma ni cripto.
# Mock (iterar la SPA sin bus): # Mock (iterar la SPA sin bus):
./unibus_admin --mock --port 8480 ./unibus_admin --mock --port 8480
# Real contra un membershipd local (dev, sqlite, sin TLS): # Real contra un membershipd local (dev, sin TLS). Users vía la API del plano de
# control; añade --db sólo si quieres gestionar users contra un SQLite local:
./unibus_admin --port 8480 \ ./unibus_admin --port 8480 \
--ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250 \ --ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250 \
--db ./local_files/unibus.db \
--identity-pass unibus/operator-identity --identity-pass unibus/operator-identity
# Producción (cluster magnus, enforce + TLS + nkey): # Producción (cluster magnus, enforce + TLS + nkey). Sin --db: la pestaña Users
# gestiona la allowlist por la API admin firmada del plano de control:
./unibus_admin --port 8480 --bind 127.0.0.1 \ ./unibus_admin --port 8480 --bind 127.0.0.1 \
--ctrl-url https://127.0.0.1:8470 --nats-url tls://127.0.0.1:4250 \ --ctrl-url https://127.0.0.1:8470 --nats-url tls://127.0.0.1:4250 \
--ca /opt/unibus/tls/ca.crt \ --ca /opt/unibus/tls/ca.crt \
@@ -125,15 +127,68 @@ 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
- **Users en el cluster (KV)**: el plano de control no expone endpoint HTTP de users - **Cuentas (invites + hard-delete) contra el cluster desplegado**: el gateway y la
— viven sólo en el store. Con el cluster en `--store kv`, el gateway no abre el KV SPA ya consumen `POST/GET /api/invites` y `DELETE /api/users/{pub}`, verificados
todavía, así que la pestaña Users queda degradada (estado informativo). Se habilita end-to-end contra un `membershipd` v0.12.0 local (crear invite → registrar por
con la vía de alta KV que añade la rama `quick/0011-deploy-gaps` del repo unibus, o `/register` sin firma → aparece en `/users` → hard-delete → desaparece) y vía
con `--db` en single-node. `--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
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.
La cadena completa (list/add/revoke + idempotencia 409) está verificada end-to-end
contra un `membershipd` master local. El gap restante es de **despliegue**: los
binarios `membershipd` que corren hoy en el cluster (magnus/homer/datardos) son
anteriores al merge de esta ruta y devuelven `404` en `/users`, así que la pestaña
Users sólo será funcional en producción cuando el bus se actualice a v0.10.0. El
gateway ya conecta y firma correctamente contra esos nodos (verificado: `/api/me`
responde con el endpoint real del operador y pasa el `enforce` de magnus). Para
gestión single-node sin esperar al cluster, `--db` sigue disponible.
- **meta-leader / tamaño de quórum** del cluster: `/healthz` no los expone; requieren - **meta-leader / tamaño de quórum** del cluster: `/healthz` no los expone; requieren
el endpoint de monitoreo de NATS (varz/jsz). La pestaña Cluster muestra up/posture. el endpoint de monitoreo de NATS (varz/jsz). La pestaña Cluster muestra up/posture.
- **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.
+47 -13
View File
@@ -7,17 +7,8 @@ package admin
import ( import (
"context" "context"
"errors"
) )
// ErrUsersUnavailable is returned by the users operations when the gateway was
// started without a membership store (no --db / no KV access). The bus control
// plane exposes no user-management HTTP endpoint — users live only in the store
// — so the Users tab is read-and-write only when the gateway can reach that
// store directly. Without it the tab degrades to an explanatory empty state
// rather than failing opaquely.
var ErrUsersUnavailable = errors.New("admin: user management requires direct store access (start with --db or a KV-backed store)")
// Posture is the security posture a membershipd node publishes on /healthz. It // Posture is the security posture a membershipd node publishes on /healthz. It
// mirrors membership.Posture but is duplicated here so the wire shape the SPA // mirrors membership.Posture but is duplicated here so the wire shape the SPA
// consumes is owned by the gateway, not coupled to the bus package's struct tags. // consumes is owned by the gateway, not coupled to the bus package's struct tags.
@@ -93,13 +84,43 @@ 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 gate the Users tab. // so the SPA can render the operator endpoint and label the Users tab's backend.
type MeInfo struct { type MeInfo struct {
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"` SignPub string `json:"sign_pub"`
UsersBackend string `json:"users_backend"` // "sqlite" | "kv" | "none" 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,10 +142,23 @@ type Repo interface {
// (forward secrecy). This is the rekey-on-kick primitive the bus exposes. // (forward secrecy). This is the rekey-on-kick primitive the bus exposes.
KickMember(ctx context.Context, roomID, endpoint string) error KickMember(ctx context.Context, roomID, endpoint string) error
// Users (the bus allowlist). Available only with direct store access; // Users (the bus allowlist). The live gateway manages these against the bus
// otherwise these return ErrUsersUnavailable. // control plane's admin-only user endpoints, signing each request as the
// operator's admin identity — so user management works in cluster without
// direct store/KV access. A single-node deployment may instead point the
// gateway at the SQLite store directly (--db) as an explicit fallback.
UsersWritable() bool UsersWritable() bool
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)
} }
+154 -9
View File
@@ -40,8 +40,16 @@ type busRepo struct {
cli *client.Client cli *client.Client
nodes []NodeTarget nodes []NodeTarget
store membership.Store // optional; nil => Users tab degraded // store is an OPTIONAL direct membership store for single-node user
storeBackend string // "sqlite" | "kv" | "none" // management. When nil (the cluster default), user operations go through the
// signed control-plane API on r.cli instead — see ListUsers/AddUser/RevokeUser.
store membership.Store
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.
@@ -55,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
@@ -90,9 +100,11 @@ func NewBusRepo(cfg BusConfig) (*busRepo, error) {
} }
ctrlURLs := append([]string{cfg.CtrlURL}, cfg.CtrlURLs...) ctrlURLs := append([]string{cfg.CtrlURL}, cfg.CtrlURLs...)
backend := cfg.StoreBackend // With no direct store, user management rides the signed control-plane API
if cfg.Store == nil { // (works in cluster). A direct store is an explicit single-node fallback.
backend = "none" backend := "control-plane"
if cfg.Store != nil {
backend = cfg.StoreBackend
} }
return &busRepo{ return &busRepo{
id: cfg.Identity, id: cfg.Identity,
@@ -103,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 {
@@ -120,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,
} }
} }
@@ -326,12 +350,32 @@ func (r *busRepo) signedGET(path string) ([]byte, error) {
} }
// ---- users ---------------------------------------------------------------- // ---- users ----------------------------------------------------------------
//
// User management has two backends. The cluster default has no direct store
// (r.store == nil): every operation goes through the unibus client's admin-only
// HTTP endpoints (GET/POST /users, POST /users/{signpub}/revoke), each request
// signed as the operator's admin identity and verified by the bus's requireAdmin
// against the same store the room handlers use — so it works in cluster without
// KV/SQLite access. A single-node deployment may instead pass --db to manage the
// SQLite store directly; that path is kept as an explicit fallback.
func (r *busRepo) UsersWritable() bool { return r.store != nil } // UsersWritable reports whether the Users tab can mutate the allowlist. The live
// gateway always can: either it holds a direct store, or it signs as an admin
// against the control plane. (A non-admin signer is rejected at request time by
// the bus with 403; that is an authorization outcome, not a missing capability.)
func (r *busRepo) UsersWritable() bool { return true }
func (r *busRepo) ListUsers(context.Context) ([]UserView, error) { func (r *busRepo) ListUsers(context.Context) ([]UserView, error) {
if r.store == nil { if r.store == nil {
return nil, ErrUsersUnavailable users, err := r.cli.ListUsers()
if err != nil {
return nil, err
}
out := make([]UserView, 0, len(users))
for _, u := range users {
out = append(out, UserView(u))
}
return out, nil
} }
users, err := r.store.ListUsers() users, err := r.store.ListUsers()
if err != nil { if err != nil {
@@ -353,14 +397,115 @@ func (r *busRepo) ListUsers(context.Context) ([]UserView, error) {
func (r *busRepo) AddUser(_ context.Context, req AddUserReq) error { func (r *busRepo) AddUser(_ context.Context, req AddUserReq) error {
if r.store == nil { if r.store == nil {
return ErrUsersUnavailable return r.cli.AddUser(req.SignPub, req.Handle, req.Role)
} }
return r.store.AddUser(req.SignPub, req.Handle, req.Role) return r.store.AddUser(req.SignPub, req.Handle, req.Role)
} }
func (r *busRepo) RevokeUser(_ context.Context, signPub string) error { func (r *busRepo) RevokeUser(_ context.Context, signPub string) error {
if r.store == nil { if r.store == nil {
return ErrUsersUnavailable return r.cli.RevokeUser(signPub)
} }
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
}
+47 -15
View File
@@ -3,7 +3,6 @@ package admin
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
@@ -55,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)
@@ -140,10 +145,6 @@ func (s *Server) handleKick(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.repo.ListUsers(r.Context()) users, err := s.repo.ListUsers(r.Context())
if err != nil { if err != nil {
if errors.Is(err, ErrUsersUnavailable) {
writeErr(w, http.StatusServiceUnavailable, err.Error())
return
}
writeErr(w, http.StatusBadGateway, err.Error()) writeErr(w, http.StatusBadGateway, err.Error())
return return
} }
@@ -160,11 +161,7 @@ func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.repo.AddUser(r.Context(), req); err != nil { if err := s.repo.AddUser(r.Context(), req); err != nil {
code := http.StatusBadGateway writeErr(w, http.StatusBadGateway, err.Error())
if errors.Is(err, ErrUsersUnavailable) {
code = http.StatusServiceUnavailable
}
writeErr(w, code, err.Error())
return return
} }
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"}) writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
@@ -182,16 +179,51 @@ func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.repo.RevokeUser(r.Context(), req.SignPub); err != nil { if err := s.repo.RevokeUser(r.Context(), req.SignPub); err != nil {
code := http.StatusBadGateway writeErr(w, http.StatusBadGateway, err.Error())
if errors.Is(err, ErrUsersUnavailable) {
code = http.StatusServiceUnavailable
}
writeErr(w, code, err.Error())
return return
} }
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
+20 -4
View File
@@ -36,11 +36,21 @@ func main() {
nodesCSV = flag.String("nodes", "", "cluster nodes to probe for /healthz as name=url,name=url (default: derive one from --ctrl-url)") nodesCSV = flag.String("nodes", "", "cluster nodes to probe for /healthz as name=url,name=url (default: derive one from --ctrl-url)")
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", "", "membership SQLite path for the Users tab (single-node/dev). Empty = Users tab read-only-unavailable unless --mock") 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] ")
@@ -59,7 +69,7 @@ func main() {
log.Fatalf("%v", err) log.Fatalf("%v", err)
} }
var store membership.Store var store membership.Store
backend := "none" backend := "control-plane"
if *dbPath != "" { if *dbPath != "" {
store, err = membership.Open(*dbPath) store, err = membership.Open(*dbPath)
if err != nil { if err != nil {
@@ -67,9 +77,9 @@ func main() {
} }
defer store.Close() defer store.Close()
backend = "sqlite" backend = "sqlite"
log.Printf("users backend: sqlite %s", *dbPath) log.Printf("users backend: sqlite %s (single-node direct store)", *dbPath)
} else { } else {
log.Printf("users backend: none (Users tab degraded; pass --db for single-node user management)") log.Printf("users backend: control-plane (signed admin HTTP to the bus; works in cluster)")
} }
nodes := parseNodes(*nodesCSV, *ctrlURL) nodes := parseNodes(*nodesCSV, *ctrlURL)
@@ -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-D7Qf15Sh.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),
}),
}; };
+248 -28
View File
@@ -5,9 +5,11 @@ import {
Badge, Badge,
Button, Button,
Card, Card,
CopyButton,
Group, Group,
Loader, Loader,
Modal, Modal,
NumberInput,
Select, Select,
Stack, Stack,
Table, Table,
@@ -18,27 +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, IconInfoCircle } 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 writable = usersBackend !== "none"; 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));
}, []); }, []);
@@ -46,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 });
@@ -62,9 +86,11 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
<Group gap="sm"> <Group gap="sm">
<Title order={3}>Users</Title> <Title order={3}>Users</Title>
{users && <Badge color="brand" variant="light">{users.length}</Badge>} {users && <Badge color="brand" variant="light">{users.length}</Badge>}
<Badge variant="outline" color={writable ? "teal" : "gray"} style={{ textTransform: "none" }}> <Tooltip label="Vía de gestión de la allowlist del bus">
store: {usersBackend} <Badge variant="outline" color="teal" style={{ textTransform: "none" }}>
</Badge> backend: {usersBackend}
</Badge>
</Tooltip>
</Group> </Group>
<Group gap="xs"> <Group gap="xs">
<Tooltip label="Refrescar"> <Tooltip label="Refrescar">
@@ -72,22 +98,59 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
<IconRefresh size={18} /> <IconRefresh size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Button leftSection={<IconPlus size={16} />} onClick={addCtl.open} disabled={!writable}> <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>
{!writable && ( {err && <Text c="red">{err}</Text>}
<Alert icon={<IconInfoCircle size={18} />} color="yellow" variant="light" title="Gestión de users no disponible"> {!users && !err && <Loader color="brand" />}
El plano de control no expone endpoint de users; viven solo en el store. Arranca el gateway con <code>--db</code>
(single-node) o con acceso KV admin del cluster para listar/dar de alta/revocar. Coordinar con la vía KV que
añade <code>quick/0011-deploy-gaps</code>.
</Alert>
)}
{err && writable && <Text c="red">{err}</Text>} {invites.length > 0 && (
{!users && !err && writable && <Loader color="brand" />} <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">
@@ -117,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>
{writable && 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>
))} ))}
@@ -133,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("");
@@ -161,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)"
@@ -181,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 {