feat: scaffold matrix_admin_panel v0.1.0 (issue 0163)

Wails + React + Mantine v7 admin panel for Matrix/Synapse. Replaces the
removed synapse-admin container. MAS OIDC PKCE login (loopback :8766) +
Synapse Admin API (users/rooms/sessions).

- MAS client: XSFD2SWA394DXRVJFTREAMY6J6 (public PKCE, no auth method).
- Backend: AdminService (Go) with Login/SetAdminToken/ListUsers/
  DeactivateUser/ResetUserPassword/ListRooms/DeleteRoom/GetUserDevices.
- Vendored helpers in internal/infra/ from registry:
  mas_oidc_loopback_go_infra, keyring_token_store_go_infra,
  synapse_admin_client_go_infra.
- Frontend: AppShell + sidebar tabs (Users/Rooms/Sessions). Sessions
  placeholder pending MAS admin API.
- Build verified: Linux + Windows.
This commit is contained in:
Egutierrez
2026-05-25 01:05:43 +02:00
commit 0e3c5f5e84
30 changed files with 4283 additions and 0 deletions
+25
View File
@@ -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
+20
View File
@@ -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)`.
+418
View File
@@ -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
}
+73
View File
@@ -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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

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