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:
+25
@@ -0,0 +1,25 @@
|
||||
# Wails build artifacts
|
||||
build/bin/
|
||||
build/darwin/
|
||||
build/windows/
|
||||
build/linux/
|
||||
|
||||
# Frontend
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/wailsjs/go/
|
||||
frontend/wailsjs/runtime/
|
||||
|
||||
# Local state
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
local_files/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,20 @@
|
||||
# matrix_admin_panel
|
||||
|
||||
Panel admin Matrix propio. Sustituye `synapse-admin` (eliminado en issue 0162).
|
||||
|
||||
Stack: Wails (Go) + React + Mantine v7. Login MAS OIDC PKCE (loopback :8766) + Synapse Admin API.
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
wails dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
wails build # linux/amd64
|
||||
wails build -platform windows/amd64 # windows
|
||||
```
|
||||
|
||||
Binary en `build/bin/matrix_admin_panel(.exe)`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: matrix_admin_panel
|
||||
lang: go
|
||||
domain: infra
|
||||
version: 0.1.0
|
||||
description: "Panel admin Matrix propio (Wails + React + Mantine). Sustituye synapse-admin. MAS OIDC login + Synapse Admin API."
|
||||
tags: [matrix, admin, synapse, mas, wails, react, mantine, infra, matrix-mas, client]
|
||||
uses_functions:
|
||||
- mas_oidc_loopback_go_infra
|
||||
- keyring_token_store_go_infra
|
||||
- synapse_admin_client_go_infra
|
||||
uses_types: []
|
||||
framework: "wails"
|
||||
entry_point: "main.go"
|
||||
dir_path: "projects/element_agents/apps/matrix_admin_panel"
|
||||
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/matrix_admin_panel.git"
|
||||
icon:
|
||||
phosphor: "shield-check"
|
||||
accent: "#dc2626"
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Panel admin Matrix propio que sustituye el contenedor synapse-admin eliminado (issue 0162). Wails (Go) + React+Mantine. Login MAS OIDC PKCE (loopback puerto 8766) + Synapse Admin API.
|
||||
|
||||
## Ejecutar
|
||||
|
||||
```bash
|
||||
cd projects/element_agents/apps/matrix_admin_panel
|
||||
wails dev # hot-reload
|
||||
wails build # binario Linux
|
||||
wails build -platform windows/amd64 # binario Windows
|
||||
```
|
||||
|
||||
## Flow
|
||||
|
||||
1. Login MAS OIDC (PKCE public client, mismo issuer que matrix_client_pc, distinto client_id).
|
||||
2. Tras login, modal `AdminTokenModal` pide el `access_token` Synapse de un user con `admin: true` (MAS no expone scope admin todavia).
|
||||
3. Validacion: GET `/_synapse/admin/v2/users/{self}` con el token. 200 = OK, se persiste en keyring con prefijo `admin_token:`.
|
||||
4. UI con AppShell.Navbar tabs: Users / Rooms / Sessions.
|
||||
5. Acciones row: Deactivate user (purge opcional), Reset password, Delete room (purge + block opcionales).
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
main.go entry: wails.Run + bind AdminService
|
||||
admin_service.go bindings (Login/SetAdminToken/ListUsers/...)
|
||||
helpers.go whoami helper
|
||||
internal/infra/ vendored helpers del registry
|
||||
mas_oidc_loopback.go
|
||||
keyring_token_store.go
|
||||
synapse_admin_client.go
|
||||
frontend/ React+Vite+TS+Mantine v7
|
||||
src/
|
||||
main.tsx MantineProvider violet dark
|
||||
App.tsx router (Login | Home)
|
||||
LoginScreen.tsx boton "Sign in with MAS"
|
||||
AdminTokenModal.tsx pide admin_token Synapse
|
||||
HomeScreen.tsx AppShell + sidebar tabs
|
||||
UsersTab.tsx tabla users + acciones
|
||||
RoomsTab.tsx tabla rooms + acciones
|
||||
SessionsTab.tsx placeholder TBD
|
||||
```
|
||||
|
||||
## MAS client (registrado en production)
|
||||
|
||||
- `client_id`: `XSFD2SWA394DXRVJFTREAMY6J6`
|
||||
- `client_auth_method`: `none` (PKCE public)
|
||||
- redirect URIs: `http://127.0.0.1:8766/callback`, `http://localhost:8766/callback`, `https://admin-mas.organic-machine.com/callback`, `http://localhost:8090/callback`
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.1.0 (2026-05-25) — baseline scaffold (issue 0163): Wails skeleton + login MAS OIDC + admin token modal + Users/Rooms/Sessions tabs (Sessions placeholder).
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>matrix_admin_panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./src/main.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "matrix_admin_panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^7.13.0",
|
||||
"@mantine/hooks": "^7.13.0",
|
||||
"@mantine/notifications": "^7.13.0",
|
||||
"@tabler/icons-react": "^3.19.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
935cdc8db32c31326419659d899b94e2
|
||||
Generated
+1445
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertCircle, IconShieldCheck } from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { SetAdminToken } from "../wailsjs/go/main/AdminService";
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function AdminTokenModal({ opened, onClose, onSaved }: Props) {
|
||||
const [token, setToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSave() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await SetAdminToken(token);
|
||||
notifications.show({
|
||||
title: "Admin token saved",
|
||||
message: "Admin API validated successfully.",
|
||||
color: "green",
|
||||
});
|
||||
setToken("");
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={18} color="var(--mantine-color-red-5)" />
|
||||
<Text fw={600}>Synapse admin token</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="lg"
|
||||
withCloseButton={false}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside={false}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
MAS aun no expone scope admin para la Synapse Admin API. Pega aqui el{" "}
|
||||
<Code>access_token</Code> de un usuario con <Code>admin: true</Code>{" "}
|
||||
(obtenible via Synapse legacy login o el <Code>.env</Code> del VPS).
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label="access_token"
|
||||
placeholder="syt_..."
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.currentTarget.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" color="gray" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="violet"
|
||||
onClick={handleSave}
|
||||
loading={busy}
|
||||
disabled={!token.trim()}
|
||||
>
|
||||
Validate and save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, LoadingOverlay } from "@mantine/core";
|
||||
import LoginScreen from "./LoginScreen";
|
||||
import HomeScreen from "./HomeScreen";
|
||||
import AdminTokenModal from "./AdminTokenModal";
|
||||
import { GetSession } from "../wailsjs/go/main/AdminService";
|
||||
|
||||
const LAST_USER_KEY = "matrix_admin_panel.last_user_id";
|
||||
|
||||
interface Session {
|
||||
user_id: string;
|
||||
homeserver_url: string;
|
||||
has_oidc_token: boolean;
|
||||
has_admin_token: boolean;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [userID, setUserID] = useState<string | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tokenModalOpen, setTokenModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const last = localStorage.getItem(LAST_USER_KEY);
|
||||
if (!last) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
GetSession(last)
|
||||
.then((s) => {
|
||||
const sess = s as Session | null;
|
||||
if (sess && sess.has_oidc_token) {
|
||||
setUserID(sess.user_id);
|
||||
setSession(sess);
|
||||
if (!sess.has_admin_token) {
|
||||
setTokenModalOpen(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function refreshSession(uid: string) {
|
||||
const s = (await GetSession(uid)) as Session | null;
|
||||
if (s) setSession(s);
|
||||
}
|
||||
|
||||
const handleLogin = async (uid: string) => {
|
||||
localStorage.setItem(LAST_USER_KEY, uid);
|
||||
setUserID(uid);
|
||||
await refreshSession(uid);
|
||||
// After OIDC login, ALWAYS prompt for admin token unless already saved.
|
||||
const s = (await GetSession(uid)) as Session | null;
|
||||
if (s && !s.has_admin_token) setTokenModalOpen(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem(LAST_USER_KEY);
|
||||
setUserID(null);
|
||||
setSession(null);
|
||||
};
|
||||
|
||||
const handleTokenSaved = async () => {
|
||||
setTokenModalOpen(false);
|
||||
if (userID) await refreshSession(userID);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box pos="relative" mih="100vh">
|
||||
<LoadingOverlay visible={loading} />
|
||||
{userID ? (
|
||||
<>
|
||||
<HomeScreen
|
||||
userID={userID}
|
||||
session={session}
|
||||
onLogout={handleLogout}
|
||||
onRequestAdminToken={() => setTokenModalOpen(true)}
|
||||
/>
|
||||
<AdminTokenModal
|
||||
opened={tokenModalOpen}
|
||||
onClose={() => setTokenModalOpen(false)}
|
||||
onSaved={handleTokenSaved}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<LoginScreen onLogin={handleLogin} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AppShell,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
NavLink,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconLogout,
|
||||
IconUsers,
|
||||
IconBuildingCommunity,
|
||||
IconDeviceMobile,
|
||||
IconShieldCheck,
|
||||
IconShieldOff,
|
||||
} from "@tabler/icons-react";
|
||||
import { Logout } from "../wailsjs/go/main/AdminService";
|
||||
import UsersTab from "./UsersTab";
|
||||
import RoomsTab from "./RoomsTab";
|
||||
import SessionsTab from "./SessionsTab";
|
||||
|
||||
interface Session {
|
||||
user_id: string;
|
||||
homeserver_url: string;
|
||||
has_oidc_token: boolean;
|
||||
has_admin_token: boolean;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
type Tab = "users" | "rooms" | "sessions";
|
||||
|
||||
interface Props {
|
||||
userID: string;
|
||||
session: Session | null;
|
||||
onLogout: () => void;
|
||||
onRequestAdminToken: () => void;
|
||||
}
|
||||
|
||||
export default function HomeScreen({
|
||||
userID,
|
||||
session,
|
||||
onLogout,
|
||||
onRequestAdminToken,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState<Tab>("users");
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await Logout(userID);
|
||||
} finally {
|
||||
onLogout();
|
||||
}
|
||||
}
|
||||
|
||||
const hasAdmin = !!session?.has_admin_token;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 56 }}
|
||||
navbar={{ width: 220, breakpoint: "sm" }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={22} color="var(--mantine-color-red-5)" />
|
||||
<Text fw={600}>matrix_admin_panel</Text>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
v0.1.0
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Tooltip label={userID}>
|
||||
<Text size="sm" c="dimmed" style={{ maxWidth: 260 }} truncate>
|
||||
{userID}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{hasAdmin ? (
|
||||
<Badge color="green" variant="light" leftSection={<IconShieldCheck size={12} />}>
|
||||
Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconShieldOff size={14} />}
|
||||
onClick={onRequestAdminToken}
|
||||
>
|
||||
Set admin token
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="xs">
|
||||
<NavLink
|
||||
label="Users"
|
||||
leftSection={<IconUsers size={18} />}
|
||||
active={tab === "users"}
|
||||
onClick={() => setTab("users")}
|
||||
/>
|
||||
<NavLink
|
||||
label="Rooms"
|
||||
leftSection={<IconBuildingCommunity size={18} />}
|
||||
active={tab === "rooms"}
|
||||
onClick={() => setTab("rooms")}
|
||||
/>
|
||||
<NavLink
|
||||
label="Sessions"
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
active={tab === "sessions"}
|
||||
onClick={() => setTab("sessions")}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Box>
|
||||
{tab === "users" && <UsersTab hasAdminToken={hasAdmin} />}
|
||||
{tab === "rooms" && <RoomsTab hasAdminToken={hasAdmin} />}
|
||||
{tab === "sessions" && <SessionsTab />}
|
||||
</Box>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Code,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react";
|
||||
import { Login } from "../wailsjs/go/main/AdminService";
|
||||
|
||||
export default function LoginScreen({ onLogin }: { onLogin: (uid: string) => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleClick() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const uid = await Login();
|
||||
onLogin(uid);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Center mih="100vh" p="lg">
|
||||
<Card shadow="md" padding="xl" radius="lg" withBorder maw={480} w="100%">
|
||||
<Stack gap="lg" align="center">
|
||||
<IconShieldCheck size={48} color="var(--mantine-color-red-5)" />
|
||||
<Stack gap={4} align="center">
|
||||
<Title order={2}>matrix_admin_panel</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Synapse admin (Wails + React + Mantine)
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text size="sm" ta="center" c="dimmed" maw={360}>
|
||||
Inicia sesion en <Code>matrix-af2f3d.organic-machine.com</Code> via
|
||||
Matrix Authentication Service. Tras login, se pedira un{" "}
|
||||
<Code>admin_token</Code> Synapse adicional.
|
||||
</Text>
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
color="red"
|
||||
variant="light"
|
||||
w="100%"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
size="md"
|
||||
color="violet"
|
||||
loading={busy}
|
||||
onClick={handleClick}
|
||||
fullWidth
|
||||
>
|
||||
Sign in with MAS
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
v0.1.0 (issue 0163)
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Code,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Menu,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDots,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { ListRooms, DeleteRoom } from "../wailsjs/go/main/AdminService";
|
||||
|
||||
interface AdminRoom {
|
||||
room_id: string;
|
||||
name: string;
|
||||
canonical_alias: string;
|
||||
joined_members: number;
|
||||
joined_local: number;
|
||||
version: string;
|
||||
encrypted: boolean;
|
||||
federatable: boolean;
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
interface ListResult {
|
||||
rooms: AdminRoom[];
|
||||
total_count: number;
|
||||
next_token: number;
|
||||
}
|
||||
|
||||
export default function RoomsTab({ hasAdminToken }: { hasAdminToken: boolean }) {
|
||||
const [data, setData] = useState<ListResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [from, setFrom] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
// Delete modal
|
||||
const [deleteRoom, setDeleteRoom] = useState<AdminRoom | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [purge, setPurge] = useState(true);
|
||||
const [block, setBlock] = useState(false);
|
||||
|
||||
async function load() {
|
||||
if (!hasAdminToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = (await ListRooms(from, limit, search)) as unknown as ListResult;
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAdminToken) load();
|
||||
|
||||
}, [hasAdminToken, from]);
|
||||
|
||||
function applySearch() {
|
||||
setFrom(0);
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteRoom) return;
|
||||
try {
|
||||
const id = await DeleteRoom(deleteRoom.room_id, reason, purge, block);
|
||||
notifications.show({
|
||||
title: "Room delete scheduled",
|
||||
message: `delete_id: ${id}`,
|
||||
color: "green",
|
||||
});
|
||||
setDeleteRoom(null);
|
||||
setReason("");
|
||||
setPurge(true);
|
||||
setBlock(false);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
notifications.show({
|
||||
title: "Delete failed",
|
||||
message: String(e?.message ?? e),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAdminToken) {
|
||||
return (
|
||||
<Alert color="orange" icon={<IconAlertCircle size={16} />}>
|
||||
Admin token not set. Click "Set admin token" on the top bar to enable
|
||||
room management.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Title order={3}>Rooms</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setFrom(0);
|
||||
load();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="md" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label="Search name/alias"
|
||||
placeholder="general"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
w={280}
|
||||
/>
|
||||
<Button variant="default" onClick={applySearch}>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Room ID</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Members</Table.Th>
|
||||
<Table.Th>Encrypted</Table.Th>
|
||||
<Table.Th>Public</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data?.rooms?.map((r) => (
|
||||
<Table.Tr key={r.room_id}>
|
||||
<Table.Td>
|
||||
<Code>{r.room_id}</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.name || r.canonical_alias || "-"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{r.joined_members}{" "}
|
||||
<Text component="span" size="xs" c="dimmed">
|
||||
({r.joined_local} local)
|
||||
</Text>
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.encrypted ? (
|
||||
<Badge color="green" size="sm">
|
||||
E2EE
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
no
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.public ? (
|
||||
<Badge color="blue" size="sm">
|
||||
public
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
private
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu shadow="md" position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => setDeleteRoom(r)}
|
||||
>
|
||||
Delete room
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{!data?.rooms?.length && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No rooms.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Total: {data?.total_count ?? 0} · Showing from {from}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={from === 0}
|
||||
onClick={() => setFrom(Math.max(0, from - limit))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={!data || data.next_token < 0}
|
||||
onClick={() => data && data.next_token >= 0 && setFrom(data.next_token)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={!!deleteRoom}
|
||||
onClose={() => {
|
||||
setDeleteRoom(null);
|
||||
setReason("");
|
||||
}}
|
||||
title="Delete room"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
About to delete <Code>{deleteRoom?.room_id}</Code>. This is
|
||||
asynchronous.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason (shown to members)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Purge all messages and state — IRREVERSIBLE"
|
||||
checked={purge}
|
||||
onChange={(e) => setPurge(e.currentTarget.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Block — prevent new users from joining"
|
||||
checked={block}
|
||||
onChange={(e) => setBlock(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setDeleteRoom(null);
|
||||
setReason("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Alert, Box, Button, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconInfoCircle, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
export default function SessionsTab() {
|
||||
return (
|
||||
<Box>
|
||||
<Stack gap="md">
|
||||
<Title order={3}>Sessions</Title>
|
||||
<Alert color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
<Text size="sm">
|
||||
TBD: MAS admin API integracion. Hoy la API admin de MAS sigue cerrada
|
||||
(issue 0163 v0.1.0). Cuando este disponible, esta vista listara
|
||||
tokens activos, dispositivos OIDC y permitira revocar sesiones.
|
||||
</Text>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
disabled
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Code,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Menu,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDots,
|
||||
IconKey,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconUserOff,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
ListUsers,
|
||||
DeactivateUser,
|
||||
ResetUserPassword,
|
||||
} from "../wailsjs/go/main/AdminService";
|
||||
|
||||
interface AdminUser {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
admin: boolean;
|
||||
deactivated: boolean;
|
||||
is_guest: boolean;
|
||||
creation_ts: number;
|
||||
last_seen_ts: number;
|
||||
}
|
||||
|
||||
interface ListResult {
|
||||
users: AdminUser[];
|
||||
total_count: number;
|
||||
next_token: number;
|
||||
}
|
||||
|
||||
type TriFilter = "any" | "yes" | "no";
|
||||
|
||||
function triToFilter(v: TriFilter): { set: boolean; val: boolean } {
|
||||
if (v === "yes") return { set: true, val: true };
|
||||
if (v === "no") return { set: true, val: false };
|
||||
return { set: false, val: false };
|
||||
}
|
||||
|
||||
function fmtTs(ts: number): string {
|
||||
if (!ts) return "-";
|
||||
const d = new Date(ts);
|
||||
return d.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export default function UsersTab({ hasAdminToken }: { hasAdminToken: boolean }) {
|
||||
const [data, setData] = useState<ListResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [deactivated, setDeactivated] = useState<TriFilter>("any");
|
||||
const [admins, setAdmins] = useState<TriFilter>("any");
|
||||
const [from, setFrom] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
// Deactivate modal
|
||||
const [deactivateUser, setDeactivateUser] = useState<AdminUser | null>(null);
|
||||
const [erase, setErase] = useState(false);
|
||||
|
||||
// Reset password modal
|
||||
const [resetUser, setResetUser] = useState<AdminUser | null>(null);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [logoutDevices, setLogoutDevices] = useState(true);
|
||||
|
||||
async function load() {
|
||||
if (!hasAdminToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = triToFilter(deactivated);
|
||||
const a = triToFilter(admins);
|
||||
const res = (await ListUsers({
|
||||
from,
|
||||
limit,
|
||||
search_term: search,
|
||||
deactivated_set: d.set,
|
||||
deactivated: d.val,
|
||||
admins_set: a.set,
|
||||
admins: a.val,
|
||||
})) as unknown as ListResult;
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAdminToken) load();
|
||||
|
||||
}, [hasAdminToken, from, deactivated, admins]);
|
||||
|
||||
function applySearch() {
|
||||
setFrom(0);
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleDeactivate() {
|
||||
if (!deactivateUser) return;
|
||||
try {
|
||||
await DeactivateUser(deactivateUser.user_id, erase);
|
||||
notifications.show({
|
||||
title: "User deactivated",
|
||||
message: deactivateUser.user_id,
|
||||
color: "green",
|
||||
});
|
||||
setDeactivateUser(null);
|
||||
setErase(false);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
notifications.show({
|
||||
title: "Deactivate failed",
|
||||
message: String(e?.message ?? e),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!resetUser) return;
|
||||
try {
|
||||
await ResetUserPassword(resetUser.user_id, newPassword, logoutDevices);
|
||||
notifications.show({
|
||||
title: "Password reset",
|
||||
message: resetUser.user_id,
|
||||
color: "green",
|
||||
});
|
||||
setResetUser(null);
|
||||
setNewPassword("");
|
||||
setLogoutDevices(true);
|
||||
} catch (e: any) {
|
||||
notifications.show({
|
||||
title: "Reset failed",
|
||||
message: String(e?.message ?? e),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAdminToken) {
|
||||
return (
|
||||
<Alert color="orange" icon={<IconAlertCircle size={16} />}>
|
||||
Admin token not set. Click "Set admin token" on the top bar to enable user
|
||||
management.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Title order={3}>Users</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setFrom(0);
|
||||
load();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="md" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label="Search user_id"
|
||||
placeholder="@user:server"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
w={280}
|
||||
/>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
Deactivated
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={deactivated}
|
||||
onChange={(v) => {
|
||||
setDeactivated(v as TriFilter);
|
||||
setFrom(0);
|
||||
}}
|
||||
data={[
|
||||
{ label: "Any", value: "any" },
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No", value: "no" },
|
||||
]}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
Admins
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={admins}
|
||||
onChange={(v) => {
|
||||
setAdmins(v as TriFilter);
|
||||
setFrom(0);
|
||||
}}
|
||||
data={[
|
||||
{ label: "Any", value: "any" },
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No", value: "no" },
|
||||
]}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
<Button variant="default" onClick={applySearch}>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table.ScrollContainer minWidth={800}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User ID</Table.Th>
|
||||
<Table.Th>Display name</Table.Th>
|
||||
<Table.Th>Admin</Table.Th>
|
||||
<Table.Th>Deactivated</Table.Th>
|
||||
<Table.Th>Last seen</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data?.users?.map((u) => (
|
||||
<Table.Tr key={u.user_id}>
|
||||
<Table.Td>
|
||||
<Code>{u.user_id}</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>{u.display_name || "-"}</Table.Td>
|
||||
<Table.Td>
|
||||
{u.admin ? (
|
||||
<Badge color="green" size="sm">
|
||||
admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{u.deactivated ? (
|
||||
<Badge color="red" size="sm">
|
||||
yes
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
no
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{fmtTs(u.last_seen_ts)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu shadow="md" position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconUserOff size={14} />}
|
||||
disabled={u.deactivated}
|
||||
onClick={() => setDeactivateUser(u)}
|
||||
>
|
||||
Deactivate
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconKey size={14} />}
|
||||
onClick={() => setResetUser(u)}
|
||||
>
|
||||
Reset password
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{!data?.users?.length && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No users.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Total: {data?.total_count ?? 0} · Showing from {from}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={from === 0}
|
||||
onClick={() => setFrom(Math.max(0, from - limit))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={!data || data.next_token < 0}
|
||||
onClick={() => data && data.next_token >= 0 && setFrom(data.next_token)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Deactivate modal */}
|
||||
<Modal
|
||||
opened={!!deactivateUser}
|
||||
onClose={() => {
|
||||
setDeactivateUser(null);
|
||||
setErase(false);
|
||||
}}
|
||||
title="Deactivate user"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
About to deactivate <Code>{deactivateUser?.user_id}</Code>. This is{" "}
|
||||
<strong>destructive</strong> and cannot be reversed via this panel.
|
||||
</Text>
|
||||
<Checkbox
|
||||
label="Erase all user data (purge messages, profile, etc.) — IRREVERSIBLE"
|
||||
checked={erase}
|
||||
onChange={(e) => setErase(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setDeactivateUser(null);
|
||||
setErase(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDeactivate}>
|
||||
Deactivate
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Reset password modal */}
|
||||
<Modal
|
||||
opened={!!resetUser}
|
||||
onClose={() => {
|
||||
setResetUser(null);
|
||||
setNewPassword("");
|
||||
}}
|
||||
title="Reset password"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
New password for <Code>{resetUser?.user_id}</Code>:
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Checkbox
|
||||
label="Logout all devices"
|
||||
checked={logoutDevices}
|
||||
onChange={(e) => setLogoutDevices(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setResetUser(null);
|
||||
setNewPassword("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="violet"
|
||||
onClick={handleReset}
|
||||
disabled={!newPassword.trim()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider, createTheme } from "@mantine/core";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import App from "./App";
|
||||
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: "violet",
|
||||
defaultRadius: "md",
|
||||
fontFamily: "Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider defaultColorScheme="dark" theme={theme}>
|
||||
<Notifications position="top-right" />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
module fn-registry/projects/element_agents/apps/matrix_admin_panel
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// whoami issues GET /_matrix/client/v3/account/whoami against the homeserver with the
|
||||
// provided access_token. Used to resolve the canonical user_id post-login.
|
||||
func whoami(ctx context.Context, homeserver, accessToken string) (string, string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
u, err := url.JoinPath(homeserver, "/_matrix/client/v3/account/whoami")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("joinpath: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
cl := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", fmt.Errorf("whoami: %s: %s", resp.Status, body)
|
||||
}
|
||||
var out struct {
|
||||
UserID string `json:"user_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", "", fmt.Errorf("whoami parse: %w", err)
|
||||
}
|
||||
return out.UserID, out.DeviceID, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
as := NewAdminService()
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "matrix_admin_panel",
|
||||
Width: 1400,
|
||||
Height: 880,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 26, G: 27, B: 30, A: 1},
|
||||
OnStartup: func(ctx context.Context) {
|
||||
as.SetContext(ctx)
|
||||
},
|
||||
Bind: []interface{}{
|
||||
as,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalln("Wails error:", err)
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "matrix_admin_panel",
|
||||
"outputfilename": "matrix_admin_panel",
|
||||
"frontend:install": "pnpm install",
|
||||
"frontend:build": "pnpm build",
|
||||
"frontend:dev:watcher": "pnpm dev",
|
||||
"frontend:dev:serverUrl": "auto",
|
||||
"author": {
|
||||
"name": "Egutierrez",
|
||||
"email": "egutierrez@dead.dd"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user