0e3c5f5e84
Wails + React + Mantine v7 admin panel for Matrix/Synapse. Replaces the removed synapse-admin container. MAS OIDC PKCE login (loopback :8766) + Synapse Admin API (users/rooms/sessions). - MAS client: XSFD2SWA394DXRVJFTREAMY6J6 (public PKCE, no auth method). - Backend: AdminService (Go) with Login/SetAdminToken/ListUsers/ DeactivateUser/ResetUserPassword/ListRooms/DeleteRoom/GetUserDevices. - Vendored helpers in internal/infra/ from registry: mas_oidc_loopback_go_infra, keyring_token_store_go_infra, synapse_admin_client_go_infra. - Frontend: AppShell + sidebar tabs (Users/Rooms/Sessions). Sessions placeholder pending MAS admin API. - Build verified: Linux + Windows.
419 lines
11 KiB
Go
419 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"fn-registry/projects/element_agents/apps/matrix_admin_panel/internal/infra"
|
|
)
|
|
|
|
// Constants — hardcoded for issue 0163 MVP. Operator-configurable later via settings UI.
|
|
const (
|
|
homeserverURL = "https://matrix-af2f3d.organic-machine.com"
|
|
masIssuer = "https://auth-af2f3d.organic-machine.com/"
|
|
masClientID = "XSFD2SWA394DXRVJFTREAMY6J6"
|
|
loopbackPort = 8766
|
|
keyringServiceName = "fn_registry.matrix_admin_panel"
|
|
oidcTimeoutSeconds = 300
|
|
adminTokenPrefix = "admin_token:"
|
|
)
|
|
|
|
var defaultScopes = []string{
|
|
"openid",
|
|
"urn:matrix:org.matrix.msc2967.client:api:*",
|
|
}
|
|
|
|
// AdminService is bound to the Wails frontend.
|
|
type AdminService struct {
|
|
ctx context.Context
|
|
mu sync.Mutex
|
|
store *infra.KeyringTokenStore
|
|
adminToken string
|
|
loggedUser string
|
|
}
|
|
|
|
func NewAdminService() *AdminService {
|
|
return &AdminService{
|
|
store: infra.NewKeyringTokenStore(keyringServiceName),
|
|
}
|
|
}
|
|
|
|
func (s *AdminService) SetContext(ctx context.Context) {
|
|
s.ctx = ctx
|
|
}
|
|
|
|
// SessionView is the safe-to-send JSON for the frontend (no tokens).
|
|
type SessionView struct {
|
|
UserID string `json:"user_id"`
|
|
HomeserverURL string `json:"homeserver_url"`
|
|
HasOIDCToken bool `json:"has_oidc_token"`
|
|
HasAdminToken bool `json:"has_admin_token"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
// Login launches the OAuth2 PKCE flow against MAS. Blocks until completion or timeout.
|
|
// Returns the OIDC subject (user identifier as known by MAS) for session lookup.
|
|
func (s *AdminService) Login() (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
cfg := infra.MasOidcLoopbackConfig{
|
|
Issuer: masIssuer,
|
|
ClientID: masClientID,
|
|
Scopes: defaultScopes,
|
|
LoopbackPort: loopbackPort,
|
|
OpenBrowser: true,
|
|
TimeoutSeconds: oidcTimeoutSeconds,
|
|
}
|
|
res, err := infra.MasOidcLoopback(cfg)
|
|
if err != nil {
|
|
return "", fmt.Errorf("oidc: %w", err)
|
|
}
|
|
|
|
// Resolve user_id via /whoami so we have a stable handle.
|
|
userID, _, err := whoami(s.ctx, homeserverURL, res.AccessToken)
|
|
if err != nil {
|
|
return "", fmt.Errorf("whoami: %w", err)
|
|
}
|
|
|
|
tok := infra.Token{
|
|
AccessToken: res.AccessToken,
|
|
RefreshToken: res.RefreshToken,
|
|
UserID: userID,
|
|
HomeserverURL: homeserverURL,
|
|
Issuer: masIssuer,
|
|
ClientID: masClientID,
|
|
}
|
|
if res.ExpiresIn > 0 {
|
|
tok.ExpiresAt = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
|
|
}
|
|
if err := s.store.Save(userID, tok); err != nil {
|
|
return "", fmt.Errorf("keyring save: %w", err)
|
|
}
|
|
s.loggedUser = userID
|
|
|
|
// Try to load a previously saved admin token for this user.
|
|
if adminTok, err := s.store.Load(adminTokenPrefix + userID); err == nil {
|
|
s.adminToken = adminTok.AccessToken
|
|
}
|
|
|
|
return userID, nil
|
|
}
|
|
|
|
// GetSession returns the persisted session for the given user_id.
|
|
func (s *AdminService) GetSession(userID string) (*SessionView, error) {
|
|
if userID == "" {
|
|
return nil, errors.New("user_id required")
|
|
}
|
|
tok, err := s.store.Load(userID)
|
|
if err != nil {
|
|
if errors.Is(err, infra.ErrNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("keyring load: %w", err)
|
|
}
|
|
|
|
// Re-attach admin token in memory on restart.
|
|
s.mu.Lock()
|
|
if s.adminToken == "" {
|
|
if adminTok, err := s.store.Load(adminTokenPrefix + userID); err == nil {
|
|
s.adminToken = adminTok.AccessToken
|
|
}
|
|
}
|
|
s.loggedUser = userID
|
|
hasAdmin := s.adminToken != ""
|
|
s.mu.Unlock()
|
|
|
|
view := &SessionView{
|
|
UserID: tok.UserID,
|
|
HomeserverURL: tok.HomeserverURL,
|
|
HasOIDCToken: tok.AccessToken != "",
|
|
HasAdminToken: hasAdmin,
|
|
}
|
|
if !tok.ExpiresAt.IsZero() {
|
|
view.ExpiresAt = tok.ExpiresAt.Format(time.RFC3339)
|
|
}
|
|
return view, nil
|
|
}
|
|
|
|
// Logout deletes the persisted OIDC token + admin token for the given user_id.
|
|
func (s *AdminService) Logout(userID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if userID == "" {
|
|
return errors.New("user_id required")
|
|
}
|
|
_ = s.store.Delete(adminTokenPrefix + userID)
|
|
s.adminToken = ""
|
|
s.loggedUser = ""
|
|
return s.store.Delete(userID)
|
|
}
|
|
|
|
// SetAdminToken validates the provided Synapse admin access_token against the admin API
|
|
// and persists it on success. The token is NOT the MAS OIDC token — it must belong to a
|
|
// user with `admin: true` in Synapse.
|
|
func (s *AdminService) SetAdminToken(token string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return errors.New("admin token is empty")
|
|
}
|
|
if s.loggedUser == "" {
|
|
return errors.New("must login first")
|
|
}
|
|
|
|
// Probe the admin API: GET /_synapse/admin/v1/users/{self} requires admin:true.
|
|
// We use the OIDC-logged user as probe target — the lookup always works for admins.
|
|
client := infra.NewSynapseAdminClient(homeserverURL, token)
|
|
if _, err := client.GetUser(s.context(), s.loggedUser); err != nil {
|
|
return fmt.Errorf("admin token invalid: %w", err)
|
|
}
|
|
|
|
s.adminToken = token
|
|
|
|
// Persist (reuse Token struct, only AccessToken matters).
|
|
if err := s.store.Save(adminTokenPrefix+s.loggedUser, infra.Token{
|
|
AccessToken: token,
|
|
UserID: s.loggedUser,
|
|
HomeserverURL: homeserverURL,
|
|
}); err != nil {
|
|
return fmt.Errorf("keyring save admin token: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// adminClient returns a configured SynapseAdminClient or an error if admin token not set.
|
|
func (s *AdminService) adminClient() (*infra.SynapseAdminClient, error) {
|
|
s.mu.Lock()
|
|
tok := s.adminToken
|
|
s.mu.Unlock()
|
|
if tok == "" {
|
|
return nil, errors.New("admin token not set; call SetAdminToken first")
|
|
}
|
|
return infra.NewSynapseAdminClient(homeserverURL, tok), nil
|
|
}
|
|
|
|
func (s *AdminService) context() context.Context {
|
|
if s.ctx != nil {
|
|
return s.ctx
|
|
}
|
|
return context.Background()
|
|
}
|
|
|
|
// --- Args/Results for Wails bindings (JSON-safe, no interfaces) ---
|
|
|
|
type AdminUserArg struct {
|
|
UserID string `json:"user_id"`
|
|
DisplayName string `json:"display_name"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
Admin bool `json:"admin"`
|
|
Deactivated bool `json:"deactivated"`
|
|
IsGuest bool `json:"is_guest"`
|
|
CreationTs int64 `json:"creation_ts"`
|
|
LastSeenTs int64 `json:"last_seen_ts"`
|
|
}
|
|
|
|
type ListUsersFilterArg struct {
|
|
From int `json:"from"`
|
|
Limit int `json:"limit"`
|
|
SearchTerm string `json:"search_term"`
|
|
DeactivatedSet bool `json:"deactivated_set"` // true => apply deactivated filter
|
|
Deactivated bool `json:"deactivated"`
|
|
AdminsSet bool `json:"admins_set"` // true => apply admins filter
|
|
Admins bool `json:"admins"`
|
|
}
|
|
|
|
type ListUsersResultArg struct {
|
|
Users []AdminUserArg `json:"users"`
|
|
TotalCount int `json:"total_count"`
|
|
NextToken int `json:"next_token"` // -1 if no more pages
|
|
}
|
|
|
|
type AdminRoomArg struct {
|
|
RoomID string `json:"room_id"`
|
|
Name string `json:"name"`
|
|
CanonicalAlias string `json:"canonical_alias"`
|
|
JoinedMembers int `json:"joined_members"`
|
|
JoinedLocal int `json:"joined_local"`
|
|
Version string `json:"version"`
|
|
Encrypted bool `json:"encrypted"`
|
|
Federatable bool `json:"federatable"`
|
|
Public bool `json:"public"`
|
|
}
|
|
|
|
type ListRoomsResultArg struct {
|
|
Rooms []AdminRoomArg `json:"rooms"`
|
|
TotalCount int `json:"total_count"`
|
|
NextToken int `json:"next_token"` // -1 if no more pages
|
|
}
|
|
|
|
type AdminDeviceArg struct {
|
|
DeviceID string `json:"device_id"`
|
|
DisplayName string `json:"display_name"`
|
|
LastSeenIP string `json:"last_seen_ip"`
|
|
LastSeenTs int64 `json:"last_seen_ts"`
|
|
}
|
|
|
|
func toUserArg(u infra.AdminUser) AdminUserArg {
|
|
return AdminUserArg{
|
|
UserID: u.UserID,
|
|
DisplayName: u.DisplayName,
|
|
AvatarURL: u.AvatarURL,
|
|
Admin: u.Admin,
|
|
Deactivated: u.Deactivated,
|
|
IsGuest: u.IsGuest,
|
|
CreationTs: u.CreationTs,
|
|
LastSeenTs: u.LastSeenTs,
|
|
}
|
|
}
|
|
|
|
func toRoomArg(r infra.AdminRoom) AdminRoomArg {
|
|
return AdminRoomArg{
|
|
RoomID: r.RoomID,
|
|
Name: r.Name,
|
|
CanonicalAlias: r.CanonicalAlias,
|
|
JoinedMembers: r.JoinedMembers,
|
|
JoinedLocal: r.JoinedLocal,
|
|
Version: r.Version,
|
|
Encrypted: r.Encrypted,
|
|
Federatable: r.Federatable,
|
|
Public: r.Public,
|
|
}
|
|
}
|
|
|
|
// --- Users ---
|
|
|
|
func (s *AdminService) ListUsers(f ListUsersFilterArg) (*ListUsersResultArg, error) {
|
|
c, err := s.adminClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
filter := infra.ListUsersFilter{
|
|
From: f.From,
|
|
Limit: f.Limit,
|
|
SearchTerm: f.SearchTerm,
|
|
}
|
|
if f.DeactivatedSet {
|
|
v := f.Deactivated
|
|
filter.Deactivated = &v
|
|
}
|
|
if f.AdminsSet {
|
|
v := f.Admins
|
|
filter.Admins = &v
|
|
}
|
|
|
|
res, err := c.ListUsers(s.context(), filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &ListUsersResultArg{
|
|
TotalCount: res.TotalCount,
|
|
NextToken: -1,
|
|
Users: make([]AdminUserArg, 0, len(res.Users)),
|
|
}
|
|
if res.NextToken != nil {
|
|
out.NextToken = *res.NextToken
|
|
}
|
|
for _, u := range res.Users {
|
|
out.Users = append(out.Users, toUserArg(u))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *AdminService) DeactivateUser(userID string, erase bool) error {
|
|
c, err := s.adminClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.DeactivateUser(s.context(), userID, erase)
|
|
}
|
|
|
|
func (s *AdminService) ResetUserPassword(userID, newPassword string, logoutDevices bool) error {
|
|
if strings.TrimSpace(newPassword) == "" {
|
|
return errors.New("new_password is empty")
|
|
}
|
|
c, err := s.adminClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.ResetPassword(s.context(), userID, newPassword, logoutDevices)
|
|
}
|
|
|
|
func (s *AdminService) GetUserDevices(userID string) ([]AdminDeviceArg, error) {
|
|
c, err := s.adminClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
devices, err := c.ListUserDevices(s.context(), userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]AdminDeviceArg, 0, len(devices))
|
|
for _, d := range devices {
|
|
out = append(out, AdminDeviceArg{
|
|
DeviceID: d.DeviceID,
|
|
DisplayName: d.DisplayName,
|
|
LastSeenIP: d.LastSeenIP,
|
|
LastSeenTs: d.LastSeenTs,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// --- Rooms ---
|
|
|
|
func (s *AdminService) ListRooms(from, limit int, search string) (*ListRoomsResultArg, error) {
|
|
c, err := s.adminClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rooms, total, next, err := c.ListRooms(s.context(), from, limit, search)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := &ListRoomsResultArg{
|
|
TotalCount: total,
|
|
NextToken: -1,
|
|
Rooms: make([]AdminRoomArg, 0, len(rooms)),
|
|
}
|
|
if next != nil {
|
|
out.NextToken = *next
|
|
}
|
|
for _, r := range rooms {
|
|
out.Rooms = append(out.Rooms, toRoomArg(r))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *AdminService) DeleteRoom(roomID, reason string, purge, block bool) (string, error) {
|
|
c, err := s.adminClient()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return c.DeleteRoom(s.context(), roomID, reason, purge, block)
|
|
}
|
|
|
|
// HTTPStatusCheck is a small helper exposed mostly for diagnostics: hits Synapse health.
|
|
func (s *AdminService) Ping() (int, error) {
|
|
req, err := http.NewRequestWithContext(s.context(), http.MethodGet, homeserverURL+"/_matrix/client/versions", nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
cl := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := cl.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
return resp.StatusCode, nil
|
|
}
|