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 }