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