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