f65271dc92
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>
221 lines
6.5 KiB
Go
221 lines
6.5 KiB
Go
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
|
|
invites []InviteView
|
|
}
|
|
|
|
// NewMockRepo returns a Repo backed by in-memory sample data (--mock).
|
|
func NewMockRepo() Repo { return newMockRepo() }
|
|
|
|
func newMockRepo() *mockRepo {
|
|
return &mockRepo{
|
|
rooms: []RoomView{
|
|
{RoomID: "01HV...GENERAL", Subject: "team.general", Epoch: 1, Encrypt: true, Persist: true, SignMsgs: true, Role: "owner"},
|
|
{RoomID: "01HV...BOARD", Subject: "board.private", Epoch: 3, Encrypt: true, Persist: true, SignMsgs: true, Role: "owner"},
|
|
{RoomID: "01HV...BOTS", Subject: "bots.echo", Epoch: 1, Encrypt: false, Persist: false, SignMsgs: false, Role: "member"},
|
|
{RoomID: "01HV...INFRA", Subject: "infra.alerts", Epoch: 2, Encrypt: true, Persist: true, SignMsgs: true, Role: "owner"},
|
|
},
|
|
users: []UserView{
|
|
{SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa", Handle: "operator", Role: "admin", Status: "active", CreatedAt: "2026-06-01T10:00:00Z"},
|
|
{SignPub: "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00", Handle: "ana", Role: "member", Status: "active", CreatedAt: "2026-06-02T11:30:00Z"},
|
|
{SignPub: "ffeeddccbbaa99887766554433221100f0e1d2c3b4a5968778695a4b3c2d1e0f", Handle: "lucas", Role: "member", Status: "active", CreatedAt: "2026-06-03T09:15:00Z"},
|
|
{SignPub: "0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff", Handle: "leo-revoked", Role: "member", Status: "revoked", CreatedAt: "2026-05-20T08:00:00Z", RevokedAt: "2026-06-04T14:00:00Z"},
|
|
},
|
|
mem: map[string][]MemberView{
|
|
"01HV...GENERAL": {
|
|
{Endpoint: "ep-operator", Role: "owner", SignPub: "48bc0dc8...", KexPub: "9f3a..."},
|
|
{Endpoint: "ep-ana", Role: "member", SignPub: "a1b2c3d4...", KexPub: "7c2b..."},
|
|
{Endpoint: "ep-lucas", Role: "member", SignPub: "ffeeddcc...", KexPub: "5e1d..."},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (m *mockRepo) Me(context.Context) MeInfo {
|
|
return MeInfo{
|
|
Endpoint: "ep-operator",
|
|
SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa",
|
|
UsersBackend: "sqlite",
|
|
Mock: true,
|
|
JoinBaseURL: mockJoinBaseURL,
|
|
}
|
|
}
|
|
|
|
func (m *mockRepo) Cluster(context.Context) []NodeHealth {
|
|
p := Posture{Enforce: true, ACL: true, TLS: true, Cluster: true, Store: "kv"}
|
|
return []NodeHealth{
|
|
{Name: "magnus", URL: "https://127.0.0.1:8470", Up: true, Posture: p, LatencyMs: 4},
|
|
{Name: "homer", URL: "https://10.0.0.2:8470", Up: true, Posture: p, LatencyMs: 11},
|
|
{Name: "datardos", URL: "https://10.0.0.3:8470", Up: false, Posture: Posture{}, LatencyMs: 0, Error: "dial tcp: i/o timeout"},
|
|
}
|
|
}
|
|
|
|
func (m *mockRepo) ListRooms(context.Context) ([]RoomView, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]RoomView, len(m.rooms))
|
|
copy(out, m.rooms)
|
|
return out, nil
|
|
}
|
|
|
|
func (m *mockRepo) CreateRoom(_ context.Context, req CreateRoomReq) (RoomView, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
rv := RoomView{
|
|
RoomID: fmt.Sprintf("01HV...NEW%d", len(m.rooms)),
|
|
Subject: req.Subject,
|
|
Epoch: 1,
|
|
Encrypt: req.Encrypt,
|
|
Persist: req.Persist,
|
|
SignMsgs: req.SignMsgs,
|
|
Role: "owner",
|
|
}
|
|
m.rooms = append(m.rooms, rv)
|
|
return rv, nil
|
|
}
|
|
|
|
func (m *mockRepo) ListMembers(_ context.Context, roomID string) ([]MemberView, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if ms, ok := m.mem[roomID]; ok {
|
|
return ms, nil
|
|
}
|
|
return []MemberView{{Endpoint: "ep-operator", Role: "owner", SignPub: "48bc0dc8...", KexPub: "9f3a..."}}, nil
|
|
}
|
|
|
|
func (m *mockRepo) Invite(context.Context, string, InviteReq) error { return nil }
|
|
|
|
func (m *mockRepo) KickMember(_ context.Context, roomID, endpoint string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if ms, ok := m.mem[roomID]; ok {
|
|
kept := ms[:0]
|
|
for _, mv := range ms {
|
|
if mv.Endpoint != endpoint {
|
|
kept = append(kept, mv)
|
|
}
|
|
}
|
|
m.mem[roomID] = kept
|
|
}
|
|
for i := range m.rooms {
|
|
if m.rooms[i].RoomID == roomID {
|
|
m.rooms[i].Epoch++
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRepo) UsersWritable() bool { return true }
|
|
|
|
func (m *mockRepo) ListUsers(context.Context) ([]UserView, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]UserView, len(m.users))
|
|
copy(out, m.users)
|
|
return out, nil
|
|
}
|
|
|
|
func (m *mockRepo) AddUser(_ context.Context, req AddUserReq) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
role := req.Role
|
|
if role == "" {
|
|
role = "member"
|
|
}
|
|
m.users = append(m.users, UserView{
|
|
SignPub: req.SignPub,
|
|
Handle: req.Handle,
|
|
Role: role,
|
|
Status: "active",
|
|
CreatedAt: "2026-06-07T12:00:00Z",
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRepo) RevokeUser(_ context.Context, signPub string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for i := range m.users {
|
|
if m.users[i].SignPub == signPub {
|
|
m.users[i].Status = "revoked"
|
|
m.users[i].RevokedAt = "2026-06-07T12:30:00Z"
|
|
}
|
|
}
|
|
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
|
|
}
|