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 }