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:
Egutierrez
2026-05-25 01:05:43 +02:00
commit 0e3c5f5e84
30 changed files with 4283 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
package infra
import (
"encoding/json"
"errors"
"fmt"
"time"
keyring "github.com/zalando/go-keyring"
)
// ErrNotFound is returned by Load when no token exists for the given account.
var ErrNotFound = errors.New("token not found in keyring")
// Token holds OAuth/OIDC credentials that need to survive app restarts.
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never expires
UserID string `json:"user_id"`
DeviceID string `json:"device_id,omitempty"`
HomeserverURL string `json:"homeserver_url"`
Issuer string `json:"issuer,omitempty"` // MAS/OIDC issuer URL
ClientID string `json:"client_id,omitempty"` // MAS client_id used
}
// KeyringTokenStore persists tokens in the OS keyring (Secret Service on Linux,
// Keychain on macOS, Credential Manager on Windows).
type KeyringTokenStore struct {
// Service is the keyring namespace. Keep it stable across app versions.
// Example: "fn_registry.matrix_client_pc"
Service string
}
// NewKeyringTokenStore returns a store scoped to the given service name.
func NewKeyringTokenStore(service string) *KeyringTokenStore {
return &KeyringTokenStore{Service: service}
}
// Save serialises t to JSON and writes it to the keyring under (service, account).
// Overwrites silently if an entry already exists.
// account is typically the user ID, e.g. "@user:homeserver.example.com".
func (s *KeyringTokenStore) Save(account string, t Token) error {
b, err := json.Marshal(t)
if err != nil {
return fmt.Errorf("keyring save: marshal: %w", err)
}
if err := keyring.Set(s.Service, account, string(b)); err != nil {
return fmt.Errorf("keyring save: %w", err)
}
return nil
}
// Load retrieves and deserialises the token stored under (service, account).
// Returns ErrNotFound if no entry exists. Callers should check with errors.Is.
func (s *KeyringTokenStore) Load(account string) (*Token, error) {
raw, err := keyring.Get(s.Service, account)
if err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("keyring load: %w", err)
}
var t Token
if err := json.Unmarshal([]byte(raw), &t); err != nil {
return nil, fmt.Errorf("keyring load: unmarshal: %w", err)
}
return &t, nil
}
// Delete removes the token for account from the keyring.
// Idempotent: if no entry exists, returns nil.
func (s *KeyringTokenStore) Delete(account string) error {
err := keyring.Delete(s.Service, account)
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
return fmt.Errorf("keyring delete: %w", err)
}
return nil
}
+382
View File
@@ -0,0 +1,382 @@
package infra
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os/exec"
"runtime"
"strings"
"time"
)
// MasOidcLoopbackConfig configura el flujo OAuth2 PKCE con loopback HTTP
// contra Matrix Authentication Service (MAS).
type MasOidcLoopbackConfig struct {
// Issuer es la URL base del MAS. Debe terminar en "/".
// La funcion hace GET a {Issuer}.well-known/openid-configuration para descubrir endpoints.
Issuer string
// ClientID es el ULID del client registrado en MAS.
// El client debe tener client_auth_method: none (public client PKCE).
ClientID string
// Scopes a solicitar. Si vacio usa ["openid", "urn:matrix:org.matrix.msc2967.client:api:*"].
Scopes []string
// LoopbackPort es el puerto local donde escucha el callback.
// Debe coincidir con el redirect_uri registrado en MAS (http://127.0.0.1:{port}/callback).
// Si 0, elige un puerto libre dinamicamente.
LoopbackPort int
// OpenBrowser abre el browser del SO automaticamente si es true.
// Si false, imprime la URL a stdout y espera que el caller la abra.
OpenBrowser bool
// TimeoutSeconds es el tiempo maximo esperando el callback. Default 300.
TimeoutSeconds int
}
// MasOidcLoopbackResult contiene los tokens devueltos por MAS tras el intercambio.
type MasOidcLoopbackResult struct {
// AccessToken es el Bearer token para usar contra Synapse.
AccessToken string `json:"access_token"`
// RefreshToken permite renovar el access token sin re-autenticar.
RefreshToken string `json:"refresh_token"`
// ExpiresIn es el tiempo de vida del access token en segundos.
ExpiresIn int `json:"expires_in"`
// TokenType es el tipo de token, normalmente "Bearer".
TokenType string `json:"token_type"`
// Scope es la lista de scopes concedidos (space-separated).
Scope string `json:"scope"`
// IDToken es el JWT de identidad OIDC (puede estar vacio si no se pidio openid).
IDToken string `json:"id_token,omitempty"`
}
// oidcDiscovery es la respuesta de .well-known/openid-configuration.
type oidcDiscovery struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
}
// MasOidcLoopback ejecuta el flujo OAuth2 Authorization Code + PKCE contra MAS
// usando un servidor HTTP loopback para recibir el callback.
//
// Flujo:
// 1. Discovery de endpoints via .well-known/openid-configuration.
// 2. Generacion de code_verifier/challenge PKCE y state anti-CSRF.
// 3. Arranque de servidor loopback en 127.0.0.1:{LoopbackPort}.
// 4. Apertura del browser (o impresion de URL si OpenBrowser=false).
// 5. Espera del callback con el authorization code.
// 6. Intercambio del code por tokens via POST al token_endpoint.
// 7. Devolucion de MasOidcLoopbackResult.
func MasOidcLoopback(cfg MasOidcLoopbackConfig) (*MasOidcLoopbackResult, error) {
// 1. Validar inputs
if cfg.Issuer == "" {
return nil, fmt.Errorf("mas_oidc_loopback: Issuer no puede estar vacio")
}
if !strings.HasSuffix(cfg.Issuer, "/") {
return nil, fmt.Errorf("mas_oidc_loopback: Issuer debe terminar en '/' (got %q)", cfg.Issuer)
}
if cfg.ClientID == "" {
return nil, fmt.Errorf("mas_oidc_loopback: ClientID no puede estar vacio")
}
if cfg.LoopbackPort < 0 {
return nil, fmt.Errorf("mas_oidc_loopback: LoopbackPort debe ser >= 0")
}
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if cfg.TimeoutSeconds <= 0 {
timeout = 300 * time.Second
}
scopes := cfg.Scopes
if len(scopes) == 0 {
scopes = []string{"openid", "urn:matrix:org.matrix.msc2967.client:api:*"}
}
// 2. Discovery OIDC
discovery, err := masOidcDiscover(cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: discovery failed: %w", err)
}
// 3. PKCE: code_verifier + code_challenge
verifier, challenge, err := masOidcPKCE()
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: pkce generation failed: %w", err)
}
// 4. State anti-CSRF
state, err := masOidcRandomBase64URL(32)
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: state generation failed: %w", err)
}
// 5. Arrancar loopback server
listener, port, err := masOidcStartListener(cfg.LoopbackPort)
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: no se pudo abrir puerto loopback: %w", err)
}
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
// Canal para recibir el code o error desde el handler HTTP
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// Validar state anti-CSRF
if q.Get("state") != state {
errCh <- fmt.Errorf("mas_oidc_loopback: state mismatch (posible CSRF) — esperado %q, recibido %q", state, q.Get("state"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("<html><body><h2>Error: state mismatch. Por favor cierra esta ventana.</h2></body></html>"))
return
}
// Verificar error del proveedor
if errParam := q.Get("error"); errParam != "" {
desc := q.Get("error_description")
errCh <- fmt.Errorf("mas_oidc_loopback: proveedor devolvio error %q: %s", errParam, desc)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(fmt.Sprintf("<html><body><h2>Error de autorizacion: %s</h2></body></html>", desc)))
return
}
code := q.Get("code")
if code == "" {
errCh <- fmt.Errorf("mas_oidc_loopback: callback sin 'code'")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("<html><body><h2>Error: no se recibio authorization code.</h2></body></html>"))
return
}
// Responder al browser con mensaje de exito
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<!DOCTYPE html>
<html lang="es">
<head><meta charset="utf-8"><title>Login completo</title></head>
<body style="font-family:sans-serif;text-align:center;padding:3em;">
<h2>Login completo</h2>
<p>Puedes cerrar esta ventana y volver a la aplicacion.</p>
</body>
</html>`))
codeCh <- code
})
srv := &http.Server{Handler: mux}
// Arrancar el servidor en goroutine
srvErrCh := make(chan error, 1)
go func() {
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
srvErrCh <- err
}
}()
// 6. Construir URL de autorización
authURL := masOidcBuildAuthURL(
discovery.AuthorizationEndpoint,
cfg.ClientID,
redirectURI,
strings.Join(scopes, " "),
state,
challenge,
)
// 7. Abrir browser o imprimir URL
if cfg.OpenBrowser {
if err := masOidcOpenBrowser(authURL); err != nil {
// No es fatal: continuamos y el usuario puede abrir manualmente
fmt.Printf("mas_oidc_loopback: no se pudo abrir el browser automaticamente.\nAbre esta URL manualmente:\n%s\n", authURL)
}
} else {
fmt.Printf("Abre esta URL en tu browser para autenticarte:\n%s\n", authURL)
}
// 8. Esperar callback con timeout
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var code string
select {
case code = <-codeCh:
// ok
case callbackErr := <-errCh:
_ = srv.Shutdown(context.Background())
return nil, callbackErr
case <-ctx.Done():
_ = srv.Shutdown(context.Background())
return nil, fmt.Errorf("mas_oidc_loopback: timeout esperando callback despues de %v", timeout)
case srvErr := <-srvErrCh:
return nil, fmt.Errorf("mas_oidc_loopback: servidor loopback fallo: %w", srvErr)
}
// 9. Shutdown graceful del servidor loopback
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer shutdownCancel()
_ = srv.Shutdown(shutdownCtx)
// 10. Intercambiar code por tokens
result, err := masOidcExchangeCode(
discovery.TokenEndpoint,
cfg.ClientID,
code,
redirectURI,
verifier,
)
if err != nil {
return nil, fmt.Errorf("mas_oidc_loopback: token exchange failed: %w", err)
}
return result, nil
}
// masOidcHTTPClient es el cliente HTTP usado por masOidcDiscover y masOidcExchangeCode.
// Tiene timeout de 15s. Puede ser reemplazado en tests.
var masOidcHTTPClient = &http.Client{Timeout: 15 * time.Second}
// masOidcDiscover obtiene los endpoints OIDC desde .well-known/openid-configuration.
func masOidcDiscover(issuer string) (*oidcDiscovery, error) {
discoveryURL := issuer + ".well-known/openid-configuration"
resp, err := masOidcHTTPClient.Get(discoveryURL) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("GET %s: %w", discoveryURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("discovery HTTP %d: %s", resp.StatusCode, string(body))
}
var d oidcDiscovery
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
return nil, fmt.Errorf("parsing discovery JSON: %w", err)
}
if d.AuthorizationEndpoint == "" {
return nil, fmt.Errorf("discovery: authorization_endpoint vacio")
}
if d.TokenEndpoint == "" {
return nil, fmt.Errorf("discovery: token_endpoint vacio")
}
return &d, nil
}
// masOidcPKCE genera un code_verifier aleatorio y su code_challenge SHA256/base64url.
func masOidcPKCE() (verifier, challenge string, err error) {
verifier, err = masOidcRandomBase64URL(32) // 32 bytes -> 43 chars base64url
if err != nil {
return "", "", err
}
h := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(h[:])
return verifier, challenge, nil
}
// masOidcRandomBase64URL genera n bytes aleatorios codificados en base64url sin padding.
func masOidcRandomBase64URL(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// masOidcStartListener abre un listener TCP en 127.0.0.1:{port}.
// Si port=0, elige un puerto libre y devuelve el puerto asignado.
func masOidcStartListener(port int) (net.Listener, int, error) {
addr := fmt.Sprintf("127.0.0.1:%d", port)
l, err := net.Listen("tcp", addr)
if err != nil {
return nil, 0, err
}
assignedPort := l.Addr().(*net.TCPAddr).Port
return l, assignedPort, nil
}
// masOidcBuildAuthURL construye la URL de autorización OAuth2 con PKCE.
func masOidcBuildAuthURL(authEndpoint, clientID, redirectURI, scope, state, challenge string) string {
u, _ := url.Parse(authEndpoint)
q := u.Query()
q.Set("response_type", "code")
q.Set("client_id", clientID)
q.Set("redirect_uri", redirectURI)
q.Set("scope", scope)
q.Set("state", state)
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
u.RawQuery = q.Encode()
return u.String()
}
// masOidcOpenBrowser abre la URL en el browser predeterminado del SO.
func masOidcOpenBrowser(rawURL string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("xdg-open", rawURL)
case "darwin":
cmd = exec.Command("open", rawURL)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
default:
return fmt.Errorf("plataforma no soportada para abrir browser: %s", runtime.GOOS)
}
return cmd.Start()
}
// masOidcExchangeCode intercambia el authorization code por tokens via POST al token_endpoint.
func masOidcExchangeCode(tokenEndpoint, clientID, code, redirectURI, verifier string) (*MasOidcLoopbackResult, error) {
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", code)
formData.Set("redirect_uri", redirectURI)
formData.Set("client_id", clientID)
formData.Set("code_verifier", verifier)
resp, err := masOidcHTTPClient.PostForm(tokenEndpoint, formData) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("POST %s: %w", tokenEndpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("leyendo respuesta del token endpoint: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token endpoint HTTP %d: %s", resp.StatusCode, string(body))
}
var result MasOidcLoopbackResult
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parsing token response JSON: %w", err)
}
if result.AccessToken == "" {
return nil, fmt.Errorf("token response sin access_token: %s", string(body))
}
return &result, nil
}
+323
View File
@@ -0,0 +1,323 @@
package infra
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// SynapseAdminClient wraps the Synapse Admin API (/_synapse/admin/...) for user and room management.
type SynapseAdminClient struct {
HomeserverURL string // e.g. https://matrix-af2f3d.organic-machine.com
AdminToken string // access_token of a user with admin:true in Synapse
HTTPClient *http.Client // optional; default 30s timeout
}
// NewSynapseAdminClient creates a client with sensible defaults.
func NewSynapseAdminClient(homeserver, adminToken string) *SynapseAdminClient {
return &SynapseAdminClient{
HomeserverURL: homeserver,
AdminToken: adminToken,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
}
}
// AdminUser represents a Synapse user as returned by the admin API.
type AdminUser struct {
UserID string `json:"name"`
DisplayName string `json:"displayname"`
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"`
}
// ListUsersFilter controls pagination and filtering for ListUsers.
type ListUsersFilter struct {
From int // pagination offset
Limit int // default 100
SearchTerm string // filter by name / user_id
Deactivated *bool // nil = both, true/false to filter
Admins *bool // nil = both, true/false to filter
}
// ListUsersResult holds a page of users plus pagination metadata.
type ListUsersResult struct {
Users []AdminUser
TotalCount int
NextToken *int // nil if last page
}
// AdminRoom represents a Synapse room as returned by the admin API.
type AdminRoom 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_members"`
Version string `json:"version"`
Encrypted bool `json:"encryption_enabled"`
Federatable bool `json:"federatable"`
Public bool `json:"public"`
}
// AdminDevice represents a device belonging to a Synapse user.
type AdminDevice struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name"`
LastSeenIP string `json:"last_seen_ip"`
LastSeenTs int64 `json:"last_seen_ts"`
}
// synapseError is the error envelope returned by Synapse for 4xx/5xx responses.
type synapseError struct {
ErrCode string `json:"errcode"`
ErrMsg string `json:"error"`
}
// client returns the HTTPClient, falling back to a 30-second default.
func (c *SynapseAdminClient) client() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return &http.Client{Timeout: 30 * time.Second}
}
// do executes an authenticated request and returns the raw response body.
// Returns an error for HTTP >= 400, including the Synapse errcode when present.
func (c *SynapseAdminClient) do(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("synapse_admin: marshal request body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.HomeserverURL+path, bodyReader)
if err != nil {
return nil, fmt.Errorf("synapse_admin: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.AdminToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.client().Do(req)
if err != nil {
return nil, fmt.Errorf("synapse_admin: http %s %s: %w", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("synapse_admin: read response: %w", err)
}
if resp.StatusCode >= 500 {
var se synapseError
if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" {
return nil, fmt.Errorf("synapse_admin: synapse internal %d %s: %s", resp.StatusCode, se.ErrCode, se.ErrMsg)
}
return nil, fmt.Errorf("synapse_admin: synapse internal: %d", resp.StatusCode)
}
if resp.StatusCode >= 400 {
var se synapseError
if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" {
return nil, fmt.Errorf("synapse_admin: %s %s → %d %s: %s", method, path, resp.StatusCode, se.ErrCode, se.ErrMsg)
}
return nil, fmt.Errorf("synapse_admin: %s %s → HTTP %d", method, path, resp.StatusCode)
}
return data, nil
}
// --- Users ---
// ListUsers returns a page of users matching the given filter.
// Use ListUsersResult.NextToken to paginate: set ListUsersFilter.From = *NextToken on the next call.
func (c *SynapseAdminClient) ListUsers(ctx context.Context, f ListUsersFilter) (*ListUsersResult, error) {
limit := f.Limit
if limit <= 0 {
limit = 100
}
q := url.Values{}
q.Set("from", strconv.Itoa(f.From))
q.Set("limit", strconv.Itoa(limit))
if f.SearchTerm != "" {
q.Set("user_id", f.SearchTerm)
}
if f.Deactivated != nil {
q.Set("deactivated", strconv.FormatBool(*f.Deactivated))
}
if f.Admins != nil {
q.Set("admins", strconv.FormatBool(*f.Admins))
}
path := "/_synapse/admin/v2/users?" + q.Encode()
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw struct {
Users []AdminUser `json:"users"`
Total int `json:"total"`
NextToken *int `json:"next_token"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("synapse_admin: ListUsers decode: %w", err)
}
return &ListUsersResult{
Users: raw.Users,
TotalCount: raw.Total,
NextToken: raw.NextToken,
}, nil
}
// GetUser returns the admin view of a single user by their full Matrix ID (e.g. @user:server).
func (c *SynapseAdminClient) GetUser(ctx context.Context, userID string) (*AdminUser, error) {
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID)
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var u AdminUser
if err := json.Unmarshal(data, &u); err != nil {
return nil, fmt.Errorf("synapse_admin: GetUser decode: %w", err)
}
return &u, nil
}
// DeactivateUser deactivates a user account.
// If erase=true, Synapse purges all user data — IRREVERSIBLE.
func (c *SynapseAdminClient) DeactivateUser(ctx context.Context, userID string, erase bool) error {
path := "/_synapse/admin/v1/deactivate/" + url.PathEscape(userID)
_, err := c.do(ctx, http.MethodPost, path, map[string]bool{"erase": erase})
return err
}
// ResetPassword sets a new password for the given user.
// If logoutDevices=true, all existing sessions are invalidated.
func (c *SynapseAdminClient) ResetPassword(ctx context.Context, userID, newPassword string, logoutDevices bool) error {
path := "/_synapse/admin/v1/reset_password/" + url.PathEscape(userID)
body := map[string]interface{}{
"new_password": newPassword,
"logout_devices": logoutDevices,
}
_, err := c.do(ctx, http.MethodPost, path, body)
return err
}
// --- Rooms ---
// ListRooms returns a page of rooms.
// from and limit control pagination; searchTerm filters by room name/alias.
func (c *SynapseAdminClient) ListRooms(ctx context.Context, from, limit int, searchTerm string) (rooms []AdminRoom, total int, nextToken *int, err error) {
if limit <= 0 {
limit = 100
}
q := url.Values{}
q.Set("from", strconv.Itoa(from))
q.Set("limit", strconv.Itoa(limit))
q.Set("order_by", "name")
if searchTerm != "" {
q.Set("search_term", searchTerm)
}
path := "/_synapse/admin/v1/rooms?" + q.Encode()
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, 0, nil, err
}
var raw struct {
Rooms []AdminRoom `json:"rooms"`
TotalRooms int `json:"total_rooms"`
NextBatch *int `json:"next_batch"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, 0, nil, fmt.Errorf("synapse_admin: ListRooms decode: %w", err)
}
return raw.Rooms, raw.TotalRooms, raw.NextBatch, nil
}
// GetRoom returns the admin view of a single room by its room ID (e.g. !room:server).
func (c *SynapseAdminClient) GetRoom(ctx context.Context, roomID string) (*AdminRoom, error) {
path := "/_synapse/admin/v1/rooms/" + url.PathEscape(roomID)
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var r AdminRoom
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("synapse_admin: GetRoom decode: %w", err)
}
return &r, nil
}
// DeleteRoom schedules an async room deletion. Returns the delete_id for status polling.
// purge=true destroys all messages and state (IRREVERSIBLE).
// block=true prevents new users from joining after deletion.
func (c *SynapseAdminClient) DeleteRoom(ctx context.Context, roomID, reason string, purge, block bool) (deleteID string, err error) {
path := "/_synapse/admin/v2/rooms/" + url.PathEscape(roomID)
body := map[string]interface{}{
"new_room_user_id": nil,
"purge": purge,
"block": block,
"message": reason,
}
data, err := c.do(ctx, http.MethodDelete, path, body)
if err != nil {
return "", err
}
var raw struct {
DeleteID string `json:"delete_id"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return "", fmt.Errorf("synapse_admin: DeleteRoom decode: %w", err)
}
return raw.DeleteID, nil
}
// --- Devices ---
// ListUserDevices returns all devices registered for the given user.
func (c *SynapseAdminClient) ListUserDevices(ctx context.Context, userID string) ([]AdminDevice, error) {
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices"
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw struct {
Devices []AdminDevice `json:"devices"`
Total int `json:"total"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("synapse_admin: ListUserDevices decode: %w", err)
}
return raw.Devices, nil
}
// DeleteUserDevice removes a specific device from a user's account.
func (c *SynapseAdminClient) DeleteUserDevice(ctx context.Context, userID, deviceID string) error {
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices/" + url.PathEscape(deviceID)
_, err := c.do(ctx, http.MethodDelete, path, nil)
return err
}