feat: scaffold matrix_admin_panel v0.1.0 (issue 0163)
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.
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user