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
lang: go
domain: infra
version: 0.1.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."
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 (alta por invitación + baja por hard-delete) y el estado del cluster."
tags: [service, messaging, admin, nats, e2e]
uses_functions:
- sign_ed25519_go_cybersecurity
@@ -58,9 +58,10 @@ navegador (SPA Mantine)
unibus_admin (gateway Go, identidad ADMIN del operador)
├── 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 /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)
```
@@ -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 |
| **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
@@ -85,13 +86,14 @@ no expone. Nunca reimplementa firma ni cripto.
# Mock (iterar la SPA sin bus):
./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 \
--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
# 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 \
--ctrl-url https://127.0.0.1:8470 --nats-url tls://127.0.0.1:4250 \
--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
`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
- **Users en el cluster (KV)**: el plano de control no expone endpoint HTTP de users
— viven sólo en el store. Con el cluster en `--store kv`, el gateway no abre el KV
todavía, así que la pestaña Users queda degradada (estado informativo). Se habilita
con la vía de alta KV que añade la rama `quick/0011-deploy-gaps` del repo unibus, o
con `--db` en single-node.
- **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
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
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
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.
## 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 (
"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
// 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.
@@ -93,13 +84,43 @@ type AddUserReq struct {
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,
// 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 {
Endpoint string `json:"endpoint"`
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"`
// 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:
@@ -121,10 +142,23 @@ type Repo interface {
// (forward secrecy). This is the rekey-on-kick primitive the bus exposes.
KickMember(ctx context.Context, roomID, endpoint string) error
// Users (the bus allowlist). Available only with direct store access;
// otherwise these return ErrUsersUnavailable.
// Users (the bus allowlist). The live gateway manages these against the bus
// 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
ListUsers(ctx context.Context) ([]UserView, error)
AddUser(ctx context.Context, req AddUserReq) 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
nodes []NodeTarget
store membership.Store // optional; nil => Users tab degraded
storeBackend string // "sqlite" | "kv" | "none"
// store is an OPTIONAL direct membership store for single-node user
// 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.
@@ -55,6 +63,8 @@ type BusConfig struct {
Nodes []NodeTarget // nodes to probe for /healthz
Store membership.Store
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
@@ -90,9 +100,11 @@ func NewBusRepo(cfg BusConfig) (*busRepo, error) {
}
ctrlURLs := append([]string{cfg.CtrlURL}, cfg.CtrlURLs...)
backend := cfg.StoreBackend
if cfg.Store == nil {
backend = "none"
// With no direct store, user management rides the signed control-plane API
// (works in cluster). A direct store is an explicit single-node fallback.
backend := "control-plane"
if cfg.Store != nil {
backend = cfg.StoreBackend
}
return &busRepo{
id: cfg.Identity,
@@ -103,9 +115,20 @@ func NewBusRepo(cfg BusConfig) (*busRepo, error) {
nodes: cfg.Nodes,
store: cfg.Store,
storeBackend: backend,
joinBaseURL: strings.TrimRight(cfg.JoinBaseURL, "/"),
}, 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.
func (r *busRepo) Close() error {
if r.cli != nil {
@@ -120,6 +143,7 @@ func (r *busRepo) Me(context.Context) MeInfo {
SignPub: hex.EncodeToString(r.id.SignPub),
UsersBackend: r.storeBackend,
Mock: false,
JoinBaseURL: r.joinBaseURL,
}
}
@@ -326,12 +350,32 @@ func (r *busRepo) signedGET(path string) ([]byte, error) {
}
// ---- 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) {
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()
if err != nil {
@@ -353,14 +397,115 @@ func (r *busRepo) ListUsers(context.Context) ([]UserView, error) {
func (r *busRepo) AddUser(_ context.Context, req AddUserReq) error {
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)
}
func (r *busRepo) RevokeUser(_ context.Context, signPub string) error {
if r.store == nil {
return ErrUsersUnavailable
return r.cli.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 (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"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
// 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
// any control plane.
type mockRepo struct {
mu sync.Mutex
rooms []RoomView
users []UserView
mem map[string][]MemberView
mu sync.Mutex
rooms []RoomView
users []UserView
mem map[string][]MemberView
invites []InviteView
}
// NewMockRepo returns a Repo backed by in-memory sample data (--mock).
@@ -50,6 +57,7 @@ func (m *mockRepo) Me(context.Context) MeInfo {
SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa",
UsersBackend: "sqlite",
Mock: true,
JoinBaseURL: mockJoinBaseURL,
}
}
@@ -155,3 +163,58 @@ func (m *mockRepo) RevokeUser(_ context.Context, signPub string) error {
}
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 (
"context"
"encoding/json"
"errors"
"io/fs"
"log"
"net/http"
@@ -55,6 +54,12 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/users", s.handleListUsers)
s.mux.HandleFunc("POST /api/users", s.handleAddUser)
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.
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) {
users, err := s.repo.ListUsers(r.Context())
if err != nil {
if errors.Is(err, ErrUsersUnavailable) {
writeErr(w, http.StatusServiceUnavailable, err.Error())
return
}
writeErr(w, http.StatusBadGateway, err.Error())
return
}
@@ -160,11 +161,7 @@ func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.repo.AddUser(r.Context(), req); err != nil {
code := http.StatusBadGateway
if errors.Is(err, ErrUsersUnavailable) {
code = http.StatusServiceUnavailable
}
writeErr(w, code, err.Error())
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
@@ -182,16 +179,51 @@ func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.repo.RevokeUser(r.Context(), req.SignPub); err != nil {
code := http.StatusBadGateway
if errors.Is(err, ErrUsersUnavailable) {
code = http.StatusServiceUnavailable
}
writeErr(w, code, err.Error())
writeErr(w, http.StatusBadGateway, err.Error())
return
}
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 ----------------------------------------------------------
// 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)")
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")
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)")
)
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.SetPrefix("[unibus_admin] ")
@@ -59,7 +69,7 @@ func main() {
log.Fatalf("%v", err)
}
var store membership.Store
backend := "none"
backend := "control-plane"
if *dbPath != "" {
store, err = membership.Open(*dbPath)
if err != nil {
@@ -67,9 +77,9 @@ func main() {
}
defer store.Close()
backend = "sqlite"
log.Printf("users backend: sqlite %s", *dbPath)
log.Printf("users backend: sqlite %s (single-node direct store)", *dbPath)
} 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)
@@ -83,6 +93,7 @@ func main() {
Nodes: nodes,
Store: store,
StoreBackend: backend,
JoinBaseURL: joinBase,
})
if err != nil {
log.Fatalf("%v", err)
@@ -98,6 +109,11 @@ func main() {
tls = "ON (CA " + *caPath + ")"
}
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)
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 name="viewport" content="width=device-width, initial-scale=1.0" />
<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">
</head>
<body>
+1 -1
View File
@@ -91,7 +91,7 @@ export function AdminShell({ me }: { me: MeInfo }) {
<AppShell.Main>
{tab === "cluster" && <ClusterPage />}
{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>
);
+13
View File
@@ -4,8 +4,10 @@
// and real, and never signs or speaks NATS itself.
import type {
AddUserReq,
CreateInviteReq,
CreateRoomReq,
InviteReq,
InviteView,
MeInfo,
MemberView,
NodeHealth,
@@ -76,4 +78,15 @@ export const api = {
method: "POST",
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,
Button,
Card,
CopyButton,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
@@ -18,27 +20,49 @@ import {
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
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 type { UserView } from "../types";
import type { InviteView, UserView } from "../types";
import { fmtTime, trunc } from "../util";
function notifyErr(e: unknown) {
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 [invites, setInvites] = useState<InviteView[]>([]);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(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(() => {
setLoading(true);
api
.listUsers()
.then((u) => { setUsers(u); setErr(null); })
Promise.all([api.listUsers(), api.listInvites().catch(() => [] as InviteView[])])
.then(([u, inv]) => {
setUsers(u);
setInvites(inv);
setErr(null);
})
.catch((e: ApiError) => setErr(e.message))
.finally(() => setLoading(false));
}, []);
@@ -46,7 +70,7 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
useEffect(() => { load(); }, [load]);
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 {
await api.revokeUser(u.sign_pub);
notifications.show({ color: "teal", title: "Revocado", message: u.handle });
@@ -62,9 +86,11 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
<Group gap="sm">
<Title order={3}>Users</Title>
{users && <Badge color="brand" variant="light">{users.length}</Badge>}
<Badge variant="outline" color={writable ? "teal" : "gray"} style={{ textTransform: "none" }}>
store: {usersBackend}
</Badge>
<Tooltip label="Vía de gestión de la allowlist del bus">
<Badge variant="outline" color="teal" style={{ textTransform: "none" }}>
backend: {usersBackend}
</Badge>
</Tooltip>
</Group>
<Group gap="xs">
<Tooltip label="Refrescar">
@@ -72,22 +98,59 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<Button leftSection={<IconPlus size={16} />} onClick={addCtl.open} disabled={!writable}>
Añadir user
<Button variant="default" leftSection={<IconPlus size={16} />} onClick={addCtl.open}>
Añadir user (clave conocida)
</Button>
<Button leftSection={<IconTicket size={16} />} onClick={createCtl.open}>
Crear usuario
</Button>
</Group>
</Group>
{!writable && (
<Alert icon={<IconInfoCircle size={18} />} color="yellow" variant="light" title="Gestión de users no disponible">
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 && <Text c="red">{err}</Text>}
{!users && !err && <Loader color="brand" />}
{err && writable && <Text c="red">{err}</Text>}
{!users && !err && writable && <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 && (
<Card withBorder bg="dark.7" p={0} radius="md">
@@ -117,13 +180,20 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
</Table.Td>
<Table.Td><Text size="xs" c="dimmed">{fmtTime(u.created_at)}</Text></Table.Td>
<Table.Td>
{writable && u.status === "active" && (
<Tooltip label="Revocar acceso">
<ActionIcon variant="subtle" color="red" onClick={() => revoke(u)}>
<IconUserOff size={16} />
<Group gap={4} justify="flex-end" wrap="nowrap">
{u.status === "active" && (
<Tooltip label="Revocar acceso (deja rastro auditable)">
<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>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
@@ -133,10 +203,14 @@ export function UsersPage({ usersBackend }: { usersBackend: string }) {
)}
<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>
);
}
// 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 }) {
const [handle, setHandle] = 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;
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">
<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="sign_pub (hex, 64)"
@@ -181,3 +259,145 @@ function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose:
</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;
users_backend: string; // "sqlite" | "kv" | "none"
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 {