feat: add control-plane identity and management console
Build web service image / build (push) Successful in 2m7s

This commit is contained in:
2026-09-27 20:27:31 +08:00
parent 083aeef18a
commit 8760fa49f0
40 changed files with 4827 additions and 2431 deletions
+48
View File
@@ -0,0 +1,48 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
Control-plane operators use the browser workspace to inspect connected desktop Clients, verified WeChat accounts, conversations, contacts, and task outcomes. Administrators manage persistent workspace users and assign Desktop Agent/Node access; members can access only their assigned Nodes.
## Product Purpose
WxAgent coordinates desktop Agents and exposes their authorized, normalized read-only data through a control plane. The workspace helps operators select a Client context and review its messages and operational state. The workspace supports persistent multi-user administration and assigns remote Desktop Agent/Node access to users.
## Positioning
The existing system connects a remote control plane to desktop Agents and verified accounts; user-facing data is read through authorized account-scoped APIs. In this project, “Agent” assignment means access to remote Desktop Agent/Node records and their verified WeChat accounts; it does not mean AI Flow assignment.
## Operating Context
The browser application is React/Vite, served as static assets embedded by the Go control plane. Operators authenticate through the existing `/v1/auth/login` endpoint. Existing Client, task, event, audit, contact, conversation, and message reads use current control-plane APIs.
## Capabilities and Constraints
- Preserve the existing real login and Client/message/contact/task/diagnostic API workflows.
- Add a persistent user directory, role-aware authentication, user-management APIs, and user-to-Node assignments.
- Existing environment-configured Web accounts are bootstrapped as administrators when the identity database is first initialized; later account management is database-backed.
- Administrators can manage users and access all Nodes; ordinary members can access only explicitly assigned Nodes and their authorized account data.
- Keep Client/account data isolated; show only normalized authorized data. Do not expose raw WeChat databases, keys, or unredacted UI snapshots.
- Keep Node assignment distinct from unsupported AI Flow assignment; do not expose other users' Nodes or account data through any API.
- The current message send control remains unavailable until its separate real-device acceptance is complete.
## Brand Commitments
The product name is WxAgent. For this requested WebUI replacement, use the Ant Design Pro official design conventions and Ant Design X conversation components as the binding interface system.
## Evidence on Hand
Existing API handlers and current WebUI implement login, Client selection, message/conversation and contact reads, task/event/audit views, and read-coverage-aware state merging. Before this change, login credentials came from environment configuration and all authenticated users shared global access; there was no persistent user-management table or per-user Node authorization. Existing environment-configured Web accounts will bootstrap as administrators. User records and Node assignments will be persisted and managed through real APIs.
## Product Principles
- Preserve real Client/account isolation and the existing read-only trust boundary.
- Clearly distinguish backend-backed information from synthetic demo controls and data.
- Prefer explicit states and recoverable read errors over silently hiding stale or partial data.
- Treat desktop Agent availability and business-Agent assignment as distinct concepts.
+14
View File
@@ -53,6 +53,20 @@ type AccountStoreManager struct {
closed bool
}
func (m *AccountStoreManager) SourceNodeID(ctx context.Context, accountID string) (string, error) {
if !validIdentifier(accountID, 200) {
return "", sql.ErrNoRows
}
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return "", fmt.Errorf("account store is closed")
}
var nodeID string
err := m.catalog.QueryRowContext(ctx, `SELECT source_node_id FROM accounts WHERE account_id = ? AND verified = 1`, accountID).Scan(&nodeID)
return nodeID, err
}
type AccountRegistration struct {
AccountID string
StableIdentity string
+5 -1
View File
@@ -1227,10 +1227,14 @@ func (s *Server) triggerAIFlow(w http.ResponseWriter, flowID, username, correlat
}
func (s *Server) aiRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
username, err := s.authenticateWeb(r)
principal, err := s.authenticateWebPrincipal(r)
if err != nil {
return err
}
if principal.Role != RoleAdmin {
return requestError{status: http.StatusForbidden, code: "AdminRequired", message: "Administrator access is required."}
}
username := principal.Username
parts := pathParts(r.URL.Path)
if len(parts) == 3 && parts[0] == "v1" && parts[1] == "ai" {
switch parts[2] {
@@ -59,8 +59,8 @@ func main() {
config.WebUsers = map[string]string{webUser: webPassword}
}
}
if len(config.NodeTokens) == 0 || len(config.WebUsers) == 0 {
log.Fatal("configure node and web credentials with environment variables or *_FILE secret files")
if len(config.NodeTokens) == 0 {
log.Fatal("configure node credentials with environment variables or *_FILE secret files")
}
server, err := controlplane.NewServer(config)
+5 -1
View File
@@ -300,7 +300,7 @@ func validChatType(chatType ChatType) bool {
}
func (s *Server) dataRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
username, err := s.authenticateWeb(r)
principal, err := s.authenticateWebPrincipal(r)
if err != nil {
return err
}
@@ -312,6 +312,10 @@ func (s *Server) dataRoute(w http.ResponseWriter, r *http.Request, correlationID
if len(parts) != 5 {
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
if err := s.authorizeAccount(r.Context(), principal, accountID); err != nil {
return err
}
username := principal.Username
switch parts[4] {
case "conversations":
if r.Method != http.MethodGet {
+1
View File
@@ -10,6 +10,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
golang.org/x/sys v0.31.0 // indirect
modernc.org/libc v1.61.13 // indirect
+2
View File
@@ -10,6 +10,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
+129 -45
View File
@@ -86,9 +86,9 @@ type Server struct {
config ServerConfig
store *Store
accountStores *AccountStoreManager
userStore *UserStore
tlsConfig *tls.Config
nodeTokenHashes map[string][32]byte
userPasswords map[string][32]byte
sessionMu sync.Mutex
sessions map[string]session
aiProvider AIProvider
@@ -101,8 +101,8 @@ type Server struct {
}
type session struct {
Username string
Expires time.Time
UserID string
Expires time.Time
}
type requestError struct {
@@ -204,14 +204,26 @@ func NewServer(config ServerConfig) (*Server, error) {
_ = store.Close()
return nil, err
}
userStore, err := NewUserStore(accountStores.catalog)
if err != nil {
_ = accountStores.Close()
_ = store.Close()
return nil, err
}
if err := userStore.BootstrapAdmins(context.Background(), config.WebUsers); err != nil {
_ = accountStores.Close()
_ = store.Close()
return nil, err
}
config.WebUsers = nil
aiCtx, aiCancel := context.WithCancel(context.Background())
s := &Server{
config: config,
store: store,
accountStores: accountStores,
userStore: userStore,
tlsConfig: tlsConfig,
nodeTokenHashes: map[string][32]byte{},
userPasswords: map[string][32]byte{},
sessions: map[string]session{},
aiProvider: config.AIProvider,
aiCtx: aiCtx,
@@ -223,11 +235,6 @@ func NewServer(config ServerConfig) (*Server, error) {
s.nodeTokenHashes[nodeID] = sha256.Sum256([]byte(token))
}
}
for username, password := range config.WebUsers {
if username != "" && password != "" {
s.userPasswords[username] = sha256.Sum256([]byte(password))
}
}
s.aiWG.Add(3)
go s.aiWorker()
go s.aiScheduler()
@@ -310,6 +317,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.URL.Path == "/" && r.Method == http.MethodGet:
s.serveFrontend(w, "dist/index.html")
return
case r.Method == http.MethodGet && isFrontendRoute(r.URL.Path):
s.serveFrontend(w, "dist/index.html")
return
case strings.HasPrefix(r.URL.Path, "/assets/") && r.Method == http.MethodGet:
s.serveFrontend(w, path.Join("dist", strings.TrimPrefix(r.URL.Path, "/")))
return
@@ -320,6 +330,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
err = s.ready(w, correlationID)
case r.URL.Path == "/v1/auth/login" && r.Method == http.MethodPost:
err = s.login(w, r, correlationID)
case r.URL.Path == "/v1/auth/me" || r.URL.Path == "/v1/auth/logout":
err = s.webAuthRoute(w, r, correlationID)
case r.URL.Path == "/v1/users" || strings.HasPrefix(r.URL.Path, "/v1/users/"):
err = s.usersRoute(w, r, correlationID)
case r.URL.Path == "/v1/nodes" && r.Method == http.MethodGet:
err = s.listNodes(w, r)
case r.URL.Path == "/v1/nodes/register" && r.Method == http.MethodPost:
@@ -348,6 +362,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
func isFrontendRoute(route string) bool {
switch route {
case "/overview", "/messages", "/contacts", "/tasks", "/agents", "/users", "/broadcast", "/diagnostics":
return true
default:
return false
}
}
func (s *Server) ready(w http.ResponseWriter, correlationID string) error {
if err := s.store.Read(func(PersistedState) error { return nil }); err != nil {
return requestError{status: http.StatusServiceUnavailable, code: "NotReady", message: "The control plane store is not ready."}
@@ -364,17 +387,19 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request, correlationID str
if err := decodeJSON(r, &request, 8*1024); err != nil {
return err
}
password, exists := s.userPasswords[request.Username]
provided := sha256.Sum256([]byte(request.Password))
if !exists || subtle.ConstantTimeCompare(password[:], provided[:]) != 1 {
user, ok, err := s.userStore.Authenticate(r.Context(), request.Username, request.Password)
if err != nil {
return requestError{status: http.StatusInternalServerError, code: "AuthenticationUnavailable", message: "Authentication is temporarily unavailable."}
}
if !ok {
return requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Authentication failed."}
}
token := randomID()
s.sessionMu.Lock()
s.sessions[token] = session{Username: request.Username, Expires: time.Now().UTC().Add(s.config.SessionTTL)}
s.sessions[webSessionKey(token)] = session{UserID: user.ID, Expires: time.Now().UTC().Add(s.config.SessionTTL)}
s.sessionMu.Unlock()
_ = s.appendAudit(request.Username, "login", "session", correlationID, "success")
writeJSON(w, http.StatusOK, map[string]any{"access_token": token, "token_type": "Bearer", "expires_in": int(s.config.SessionTTL.Seconds()), "correlation_id": correlationID})
_ = s.appendAudit(user.Username, "login", "session", correlationID, "success")
writeJSON(w, http.StatusOK, map[string]any{"access_token": token, "token_type": "Bearer", "expires_in": int(s.config.SessionTTL.Seconds()), "user": user, "correlation_id": correlationID})
return nil
}
@@ -464,13 +489,17 @@ func (s *Server) nodeRoute(w http.ResponseWriter, r *http.Request, correlationID
}
func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) error {
if _, err := s.authenticateWeb(r); err != nil {
principal, err := s.authenticateWebPrincipal(r)
if err != nil {
return err
}
var nodes []Node
if err := s.store.Mutate(func(state *PersistedState) error {
now := time.Now().UTC()
for nodeID, value := range state.Nodes {
if !principal.CanAccessNode(nodeID) {
continue
}
node := value
nodeChanged := false
if node.LastHeartbeatAt == nil || now.Sub(*node.LastHeartbeatAt) > s.config.HeartbeatTimeout {
@@ -886,7 +915,7 @@ func (s *Server) recordTaskResult(w http.ResponseWriter, r *http.Request, nodeID
}
func (s *Server) readRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
username, err := s.authenticateWeb(r)
principal, err := s.authenticateWebPrincipal(r)
if err != nil {
return err
}
@@ -906,10 +935,13 @@ func (s *Server) readRoute(w http.ResponseWriter, r *http.Request, correlationID
if err != nil {
return err
}
if !principal.CanAccessNode(request.NodeID) {
return inaccessibleResource()
}
return s.createTaskSubmission(w, TaskSubmission{
NodeID: request.NodeID, AccountID: request.AccountID, Kind: kind,
IdempotencyKey: request.IdempotencyKey, Payload: payload, NotAfter: request.NotAfter,
}, username, correlationID)
}, principal.Username, correlationID)
}
type readSessionsPayload struct {
@@ -1016,7 +1048,7 @@ func validPagination(limit, offset int) bool { return limit >= 1 && limit <= 200
func hasTaskContent(content jsonRaw) bool { return len(content) != 0 && string(content) != "null" }
func (s *Server) taskRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
username, err := s.authenticateWeb(r)
principal, err := s.authenticateWebPrincipal(r)
if err != nil {
return err
}
@@ -1024,31 +1056,34 @@ func (s *Server) taskRoute(w http.ResponseWriter, r *http.Request, correlationID
if len(parts) == 2 && parts[0] == "v1" && parts[1] == "tasks" {
switch r.Method {
case http.MethodGet:
return s.listTasks(w, r)
return s.listTasks(w, r, principal)
case http.MethodPost:
return s.createTask(w, r, username, correlationID)
return s.createTask(w, r, principal, correlationID)
default:
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."}
}
}
if len(parts) == 3 && r.Method == http.MethodGet {
return s.getTask(w, parts[2])
return s.getTask(w, parts[2], principal)
}
if len(parts) == 4 && parts[3] == "cancel" && r.Method == http.MethodPost {
return s.cancelTask(w, parts[2], username, correlationID)
return s.cancelTask(w, parts[2], principal, correlationID)
}
if len(parts) == 4 && parts[3] == "resume" && r.Method == http.MethodPost {
return s.resumeTask(w, parts[2], username, correlationID)
return s.resumeTask(w, parts[2], principal, correlationID)
}
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
func (s *Server) createTask(w http.ResponseWriter, r *http.Request, username, correlationID string) error {
func (s *Server) createTask(w http.ResponseWriter, r *http.Request, principal WebPrincipal, correlationID string) error {
var request TaskSubmission
if err := decodeJSON(r, &request, 128*1024); err != nil {
return err
}
return s.createTaskSubmission(w, request, username, correlationID)
if !principal.CanAccessNode(request.NodeID) {
return inaccessibleResource()
}
return s.createTaskSubmission(w, request, principal.Username, correlationID)
}
func (s *Server) createTaskSubmission(w http.ResponseWriter, request TaskSubmission, username, correlationID string) error {
@@ -1103,14 +1138,14 @@ func (s *Server) createTaskSubmission(w http.ResponseWriter, request TaskSubmiss
return nil
}
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) error {
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request, principal WebPrincipal) error {
nodeID := r.URL.Query().Get("node_id")
accountID := r.URL.Query().Get("account_id")
limit := queryLimit(r.URL.Query().Get("limit"))
var tasks []Task
if err := s.store.Read(func(state PersistedState) error {
for _, task := range state.Tasks {
if nodeID != "" && task.NodeID != nodeID || accountID != "" && task.AccountID != accountID {
if !principal.CanAccessNode(task.NodeID) || nodeID != "" && task.NodeID != nodeID || accountID != "" && task.AccountID != accountID {
continue
}
tasks = append(tasks, task)
@@ -1127,14 +1162,14 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (s *Server) getTask(w http.ResponseWriter, taskID string) error {
func (s *Server) getTask(w http.ResponseWriter, taskID string, principal WebPrincipal) error {
if !validIdentifier(taskID, 200) {
return requestError{status: http.StatusBadRequest, code: "InvalidTask", message: "Task ID is invalid."}
}
var task Task
if err := s.store.Read(func(state PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok {
if !ok || !principal.CanAccessNode(value.NodeID) {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
task = value
@@ -1146,13 +1181,16 @@ func (s *Server) getTask(w http.ResponseWriter, taskID string) error {
return nil
}
func (s *Server) cancelTask(w http.ResponseWriter, taskID, username, correlationID string) error {
func (s *Server) cancelTask(w http.ResponseWriter, taskID string, principal WebPrincipal, correlationID string) error {
var task Task
err := s.store.Mutate(func(state *PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
if !principal.CanAccessNode(value.NodeID) {
return inaccessibleResource()
}
task = value
if terminal(task.Status) {
return nil
@@ -1167,7 +1205,7 @@ func (s *Server) cancelTask(w http.ResponseWriter, taskID, username, correlation
task.LeaseExpiresAt = nil
}
task.UpdatedAt = now
state.Audit = appendAudit(state.Audit, "user:"+username, "task.cancel-request", taskID, correlationID, "success", now)
state.Audit = appendAudit(state.Audit, "user:"+principal.Username, "task.cancel-request", taskID, correlationID, "success", now)
state.Tasks[taskID] = task
}
return nil
@@ -1179,13 +1217,16 @@ func (s *Server) cancelTask(w http.ResponseWriter, taskID, username, correlation
return nil
}
func (s *Server) resumeTask(w http.ResponseWriter, taskID, username, correlationID string) error {
func (s *Server) resumeTask(w http.ResponseWriter, taskID string, principal WebPrincipal, correlationID string) error {
var task Task
err := s.store.Mutate(func(state *PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
if !principal.CanAccessNode(value.NodeID) {
return inaccessibleResource()
}
task = value
if terminal(task.Status) {
return requestError{status: http.StatusConflict, code: "TerminalState", message: "The task cannot be resumed after it reached a terminal state."}
@@ -1212,7 +1253,7 @@ func (s *Server) resumeTask(w http.ResponseWriter, taskID, username, correlation
task.LastCorrelationID = correlationID
}
state.Tasks[taskID] = task
state.Audit = appendAudit(state.Audit, "user:"+username, "task.resume", taskID, correlationID, "success", now)
state.Audit = appendAudit(state.Audit, "user:"+principal.Username, "task.resume", taskID, correlationID, "success", now)
return nil
})
if err != nil {
@@ -1226,10 +1267,11 @@ func (s *Server) eventRoute(w http.ResponseWriter, r *http.Request, _ string) er
if r.Method != http.MethodGet {
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."}
}
if _, err := s.authenticateWeb(r); err != nil {
principal, err := s.authenticateWebPrincipal(r)
if err != nil {
return err
}
return s.listEvents(w, r)
return s.listEvents(w, r, principal)
}
func (s *Server) ingestEvent(w http.ResponseWriter, r *http.Request, nodeID, correlationID string) error {
@@ -1286,7 +1328,7 @@ func (s *Server) ingestEvent(w http.ResponseWriter, r *http.Request, nodeID, cor
return nil
}
func (s *Server) listEvents(w http.ResponseWriter, r *http.Request) error {
func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, principal WebPrincipal) error {
query := r.URL.Query()
nodeID, accountID, chatID := query.Get("node_id"), query.Get("account_id"), query.Get("chat_id")
limit := queryLimit(query.Get("limit"))
@@ -1294,7 +1336,7 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request) error {
if err := s.store.Read(func(state PersistedState) error {
for index := len(state.Events) - 1; index >= 0 && len(events) < limit; index-- {
event := state.Events[index]
if nodeID != "" && event.NodeID != nodeID || accountID != "" && event.AccountID != accountID || chatID != "" && event.ChatID != chatID {
if !principal.CanAccessNode(event.NodeID) || nodeID != "" && event.NodeID != nodeID || accountID != "" && event.AccountID != accountID || chatID != "" && event.ChatID != chatID {
continue
}
events = append(events, event)
@@ -1308,9 +1350,13 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request) error {
}
func (s *Server) auditRoute(w http.ResponseWriter, r *http.Request, _ string) error {
if _, err := s.authenticateWeb(r); err != nil {
principal, err := s.authenticateWebPrincipal(r)
if err != nil {
return err
}
if principal.Role != RoleAdmin {
return requestError{status: http.StatusForbidden, code: "AdminRequired", message: "Administrator access is required."}
}
limit := queryLimit(r.URL.Query().Get("limit"))
var audit []AuditEntry
if err := s.store.Read(func(state PersistedState) error {
@@ -1343,22 +1389,40 @@ func (s *Server) authenticateNode(r *http.Request) (string, error) {
}
func (s *Server) authenticateWeb(r *http.Request) (string, error) {
principal, err := s.authenticateWebPrincipal(r)
return principal.Username, err
}
func (s *Server) authenticateWebPrincipal(r *http.Request) (WebPrincipal, error) {
token := bearerToken(r)
if token == "" {
return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Web authentication is required."}
return WebPrincipal{}, requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Web authentication is required."}
}
key := webSessionKey(token)
now := time.Now().UTC()
s.sessionMu.Lock()
value, ok := s.sessions[token]
value, ok := s.sessions[key]
if ok && !now.Before(value.Expires) {
delete(s.sessions, token)
delete(s.sessions, key)
ok = false
}
s.sessionMu.Unlock()
if !ok {
return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Web session is missing or expired."}
return WebPrincipal{}, requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Web session is missing or expired."}
}
return value.Username, nil
user, err := s.userStore.Get(r.Context(), value.UserID)
if errors.Is(err, ErrUserNotFound) || err == nil && !user.Active {
return WebPrincipal{}, requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Web session is missing or expired."}
}
if err != nil {
return WebPrincipal{}, requestError{status: http.StatusInternalServerError, code: "AuthenticationUnavailable", message: "Authentication is temporarily unavailable."}
}
return WebPrincipal{WebUser: user}, nil
}
func webSessionKey(token string) string {
digest := sha256.Sum256([]byte(token))
return string(digest[:])
}
func (s *Server) appendAudit(principal, action, resource, correlationID, outcome string) error {
@@ -1378,12 +1442,32 @@ func (s *Server) serveFrontend(w http.ResponseWriter, name string) {
writeError(w, requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}, randomID())
return
}
nonce := ""
if path.Base(name) == "index.html" {
random := make([]byte, 16)
if _, err := rand.Read(random); err != nil {
writeError(w, requestError{status: http.StatusInternalServerError, code: "InternalError", message: "The web application could not be initialized."}, randomID())
return
}
nonce = base64.RawStdEncoding.EncodeToString(random)
html := string(content)
if !strings.Contains(html, "__WX_CSP_NONCE__") {
writeError(w, requestError{status: http.StatusInternalServerError, code: "InternalError", message: "The web application could not be initialized."}, randomID())
return
}
content = []byte(strings.Replace(html, "__WX_CSP_NONCE__", nonce, 1))
w.Header().Set("Cache-Control", "no-store")
}
contentType := mime.TypeByExtension(path.Ext(name))
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType+"; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
styleSource := "'self'"
if nonce != "" {
styleSource += " 'nonce-" + nonce + "'"
}
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src "+styleSource+"; style-src-attr 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}
+32 -3
View File
@@ -13,7 +13,7 @@ import (
)
func TestReactFrontendIsEmbedded(t *testing.T) {
server, err := NewServer(ServerConfig{DataFile: filepath.Join(t.TempDir(), "state.json")})
server, err := NewServer(ServerConfig{DataFile: filepath.Join(t.TempDir(), "state.json"), WebUsers: map[string]string{"admin": "test-password"}})
if err != nil {
t.Fatal(err)
}
@@ -32,8 +32,23 @@ func TestReactFrontendIsEmbedded(t *testing.T) {
if response.StatusCode != http.StatusOK || !strings.Contains(string(body), "<div id=\"root\"></div>") || !strings.Contains(string(body), "/assets/") {
t.Fatalf("React index was not served: status=%d body=%s", response.StatusCode, body)
}
if strings.Contains(response.Header.Get("Content-Security-Policy"), "unsafe-inline") {
t.Fatal("React frontend still permits inline scripts")
csp := response.Header.Get("Content-Security-Policy")
if strings.Contains(csp, "script-src 'self' 'unsafe-inline'") || !strings.Contains(csp, "style-src-attr 'unsafe-inline'") {
t.Fatalf("frontend CSP should keep scripts strict while permitting component style attributes: %q", csp)
}
nonceMarker := `<meta name="csp-nonce" content="`
nonceStart := strings.Index(string(body), nonceMarker)
if nonceStart < 0 {
t.Fatal("React index is missing its CSP nonce")
}
nonceStart += len(nonceMarker)
nonceEnd := strings.Index(string(body)[nonceStart:], `"`)
if nonceEnd < 1 {
t.Fatal("React CSP nonce is malformed")
}
nonce := string(body)[nonceStart : nonceStart+nonceEnd]
if strings.Contains(string(body), "__WX_CSP_NONCE__") || !strings.Contains(csp, "style-src 'self' 'nonce-"+nonce+"'") || response.Header.Get("Cache-Control") != "no-store" {
t.Fatalf("frontend CSP nonce is not applied consistently: policy=%q cache=%q", csp, response.Header.Get("Cache-Control"))
}
marker := `src="/assets/`
start := strings.Index(string(body), marker)
@@ -54,6 +69,20 @@ func TestReactFrontendIsEmbedded(t *testing.T) {
if asset.StatusCode != http.StatusOK {
t.Fatalf("React asset status = %d", asset.StatusCode)
}
for _, route := range []string{"/overview", "/users", "/messages"} {
page, err := http.Get(httpServer.URL + route)
if err != nil {
t.Fatal(err)
}
pageBody, err := io.ReadAll(page.Body)
page.Body.Close()
if err != nil {
t.Fatal(err)
}
if page.StatusCode != http.StatusOK || !strings.Contains(string(pageBody), "<div id=\"root\"></div>") {
t.Fatalf("React route %s was not served: status=%d", route, page.StatusCode)
}
}
}
func TestNodeWebTaskAndEventFlow(t *testing.T) {
+249
View File
@@ -0,0 +1,249 @@
package controlplane
import (
"context"
"database/sql"
"errors"
"net/http"
"strings"
)
func (s *Server) webAuthRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
switch {
case r.URL.Path == "/v1/auth/me" && r.Method == http.MethodGet:
principal, err := s.authenticateWebPrincipal(r)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"user": principal.WebUser, "correlation_id": correlationID})
return nil
case r.URL.Path == "/v1/auth/logout" && r.Method == http.MethodPost:
if token := bearerToken(r); token != "" {
s.sessionMu.Lock()
delete(s.sessions, webSessionKey(token))
s.sessionMu.Unlock()
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "correlation_id": correlationID})
return nil
default:
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "The method is not allowed for this resource."}
}
}
func (s *Server) usersRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
principal, err := s.authenticateWebPrincipal(r)
if err != nil {
return err
}
if principal.Role != RoleAdmin {
return requestError{status: http.StatusForbidden, code: "AdminRequired", message: "Administrator access is required."}
}
parts := strings.Split(strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/users"), "/"), "/")
if len(parts) == 1 && parts[0] == "" {
switch r.Method {
case http.MethodGet:
users, err := s.userStore.List(r.Context())
if err != nil {
return userStoreRequestError(err)
}
writeJSON(w, http.StatusOK, map[string]any{"users": users, "correlation_id": correlationID})
return nil
case http.MethodPost:
return s.createWebUser(w, r, principal, correlationID)
default:
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "The method is not allowed for this resource."}
}
}
if len(parts) < 1 || !validIdentifier(parts[0], 128) {
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
userID := parts[0]
if len(parts) == 2 && parts[1] == "password" {
if r.Method != http.MethodPost {
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "The method is not allowed for this resource."}
}
return s.resetWebUserPassword(w, r, principal, userID, correlationID)
}
if len(parts) != 1 {
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
switch r.Method {
case http.MethodGet:
user, err := s.userStore.Get(r.Context(), userID)
if errors.Is(err, ErrUserNotFound) {
return requestError{status: http.StatusNotFound, code: "UserNotFound", message: "User was not found."}
}
if err != nil {
return userStoreRequestError(err)
}
writeJSON(w, http.StatusOK, map[string]any{"user": user, "correlation_id": correlationID})
return nil
case http.MethodPatch, http.MethodPut:
return s.updateWebUser(w, r, principal, userID, correlationID)
default:
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "The method is not allowed for this resource."}
}
}
func (s *Server) createWebUser(w http.ResponseWriter, r *http.Request, actor WebPrincipal, correlationID string) error {
var request struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
Role string `json:"role"`
NodeIDs []string `json:"node_ids"`
}
if err := decodeJSON(r, &request, 16*1024); err != nil {
return err
}
if request.Role == "" {
request.Role = RoleMember
}
if request.Role == RoleMember {
if err := s.validateUserNodeIDs(request.NodeIDs); err != nil {
return err
}
}
user, err := s.userStore.Create(r.Context(), WebUser{
Username: request.Username, DisplayName: request.DisplayName, Role: request.Role, NodeIDs: request.NodeIDs,
}, request.Password)
if errors.Is(err, ErrUserAlreadyExists) {
return requestError{status: http.StatusConflict, code: "UserAlreadyExists", message: "A user with this username already exists."}
}
if errors.Is(err, ErrInvalidUser) {
return requestError{status: http.StatusBadRequest, code: "InvalidUser", message: "User details or password do not meet the requirements."}
}
if err != nil {
return userStoreRequestError(err)
}
_ = s.appendAudit(actor.Username, "user.create", "user:"+user.ID, correlationID, "success")
writeJSON(w, http.StatusCreated, map[string]any{"user": user, "correlation_id": correlationID})
return nil
}
func (s *Server) updateWebUser(w http.ResponseWriter, r *http.Request, actor WebPrincipal, userID, correlationID string) error {
var request struct {
DisplayName *string `json:"display_name"`
Role *string `json:"role"`
Active *bool `json:"active"`
NodeIDs *[]string `json:"node_ids"`
}
if err := decodeJSON(r, &request, 16*1024); err != nil {
return err
}
current, err := s.userStore.Get(r.Context(), userID)
if errors.Is(err, ErrUserNotFound) {
return requestError{status: http.StatusNotFound, code: "UserNotFound", message: "User was not found."}
}
if err != nil {
return userStoreRequestError(err)
}
update := UserUpdate{DisplayName: current.DisplayName, Role: current.Role, Active: current.Active, NodeIDs: current.NodeIDs}
if request.DisplayName != nil {
update.DisplayName = *request.DisplayName
}
if request.Role != nil {
update.Role = *request.Role
}
if request.Active != nil {
update.Active = *request.Active
}
if request.NodeIDs != nil {
update.NodeIDs = *request.NodeIDs
}
if update.Role == RoleMember {
if err := s.validateUserNodeIDs(update.NodeIDs); err != nil {
return err
}
}
if userID == actor.ID && (update.Role != RoleAdmin || !update.Active) {
return requestError{status: http.StatusBadRequest, code: "SelfDemotionNotAllowed", message: "Administrators cannot disable or demote their own account."}
}
updated, err := s.userStore.Update(r.Context(), userID, update)
if errors.Is(err, ErrUserNotFound) {
return requestError{status: http.StatusNotFound, code: "UserNotFound", message: "User was not found."}
}
if errors.Is(err, ErrLastAdministrator) {
return requestError{status: http.StatusConflict, code: "LastAdministrator", message: "At least one active administrator must remain."}
}
if errors.Is(err, ErrInvalidUser) {
return requestError{status: http.StatusBadRequest, code: "InvalidUser", message: "User details are invalid."}
}
if err != nil {
return userStoreRequestError(err)
}
if !updated.Active {
s.revokeUserSessions(userID)
}
_ = s.appendAudit(actor.Username, "user.update", "user:"+userID, correlationID, "success")
writeJSON(w, http.StatusOK, map[string]any{"user": updated, "correlation_id": correlationID})
return nil
}
func (s *Server) resetWebUserPassword(w http.ResponseWriter, r *http.Request, actor WebPrincipal, userID, correlationID string) error {
var request struct {
Password string `json:"password"`
}
if err := decodeJSON(r, &request, 8*1024); err != nil {
return err
}
if err := s.userStore.SetPassword(r.Context(), userID, request.Password); errors.Is(err, ErrUserNotFound) {
return requestError{status: http.StatusNotFound, code: "UserNotFound", message: "User was not found."}
} else if errors.Is(err, ErrInvalidUser) {
return requestError{status: http.StatusBadRequest, code: "InvalidPassword", message: "Password must be between 12 and 256 bytes."}
} else if err != nil {
return userStoreRequestError(err)
}
s.revokeUserSessions(userID)
_ = s.appendAudit(actor.Username, "user.password_reset", "user:"+userID, correlationID, "success")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "correlation_id": correlationID})
return nil
}
func (s *Server) validateUserNodeIDs(nodeIDs []string) error {
known := make(map[string]struct{}, len(nodeIDs))
err := s.store.Read(func(state PersistedState) error {
for nodeID := range state.Nodes {
known[nodeID] = struct{}{}
}
return nil
})
if err != nil {
return requestError{status: http.StatusInternalServerError, code: "StoreError", message: "Unable to validate Node assignments."}
}
for _, nodeID := range uniqueNodeIDs(nodeIDs) {
if _, ok := known[nodeID]; !ok {
return requestError{status: http.StatusBadRequest, code: "UnknownNode", message: "One or more assigned Nodes do not exist."}
}
}
return nil
}
func (s *Server) revokeUserSessions(userID string) {
s.sessionMu.Lock()
for tokenHash, session := range s.sessions {
if session.UserID == userID {
delete(s.sessions, tokenHash)
}
}
s.sessionMu.Unlock()
}
func userStoreRequestError(_ error) error {
return requestError{status: http.StatusInternalServerError, code: "UserStoreError", message: "Unable to complete the user-management request."}
}
func inaccessibleResource() error {
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
func (s *Server) authorizeAccount(ctx context.Context, principal WebPrincipal, accountID string) error {
nodeID, err := s.accountStores.SourceNodeID(ctx, accountID)
if errors.Is(err, sql.ErrNoRows) || err == nil && !principal.CanAccessNode(nodeID) {
return inaccessibleResource()
}
if err != nil {
return requestError{status: http.StatusInternalServerError, code: "AccountLookupFailed", message: "Unable to verify account access."}
}
return nil
}
+150
View File
@@ -0,0 +1,150 @@
package controlplane
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
)
func TestUserManagementPersistsAndScopesNodeAccess(t *testing.T) {
dataFile := filepath.Join(t.TempDir(), "control-plane.json")
nodeTokens := map[string]string{"node-a": "node-secret-a", "node-b": "node-secret-b"}
server, err := NewServer(ServerConfig{DataFile: dataFile, NodeTokens: nodeTokens, WebUsers: map[string]string{"root": "legacy-admin-password"}})
if err != nil {
t.Fatal(err)
}
httpServer := httptest.NewServer(server.Handler())
defer httpServer.Close()
defer server.Close()
client := httpServer.Client()
adminToken := testLogin(t, client, httpServer.URL, "root", "legacy-admin-password")
for _, item := range []struct{ nodeID, accountID, token string }{
{"node-a", "account-a", nodeTokens["node-a"]},
{"node-b", "account-b", nodeTokens["node-b"]},
} {
registration := NodeRegistration{NodeID: item.nodeID, AgentVersion: "test", ProtocolVersion: ProtocolVersion,
Capabilities: []string{"heartbeat", "poll-tasks", "send-text"}, ReportingConfigVersion: 1,
Accounts: []AccountSummary{{AccountID: item.accountID, Active: true, Verified: true}}}
if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer "+item.token, registration); response.Code != http.StatusOK {
t.Fatalf("register %s: status=%d body=%s", item.nodeID, response.Code, response.Body.String())
}
heartbeat := Heartbeat{NodeID: item.nodeID, AgentVersion: "test", ProtocolVersion: ProtocolVersion, NodeStatus: NodeOnline,
WechatRunning: true, WechatLoggedIn: true, ActiveAccountID: item.accountID, ReportingConfigVersion: 1}
if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/"+item.nodeID+"/heartbeat", "Bearer "+item.token, heartbeat); response.Code != http.StatusOK {
t.Fatalf("heartbeat %s: status=%d body=%s", item.nodeID, response.Code, response.Body.String())
}
}
createUser := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/users", "Bearer "+adminToken, map[string]any{
"username": "operator-a", "display_name": "Operator A", "password": "member-password-1", "role": RoleMember, "node_ids": []string{"node-a"},
})
if createUser.Code != http.StatusCreated {
t.Fatalf("create member: status=%d body=%s", createUser.Code, createUser.Body.String())
}
memberToken := testLogin(t, client, httpServer.URL, "operator-a", "member-password-1")
nodes := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes", "Bearer "+memberToken, nil)
var nodeList struct {
Nodes []Node `json:"nodes"`
}
decodeBody(t, nodes, &nodeList)
if nodes.Code != http.StatusOK || len(nodeList.Nodes) != 1 || nodeList.Nodes[0].NodeID != "node-a" {
t.Fatalf("member Node list was not scoped: status=%d nodes=%+v", nodes.Code, nodeList.Nodes)
}
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/users", "Bearer "+memberToken, nil); response.Code != http.StatusForbidden {
t.Fatalf("member user-list status=%d body=%s", response.Code, response.Body.String())
}
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/data/accounts/account-b/conversations", "Bearer "+memberToken, nil); response.Code != http.StatusNotFound {
t.Fatalf("unassigned account data status=%d body=%s", response.Code, response.Body.String())
}
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/audit", "Bearer "+memberToken, nil); response.Code != http.StatusForbidden {
t.Fatalf("member audit status=%d body=%s", response.Code, response.Body.String())
}
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/ai/flows", "Bearer "+memberToken, nil); response.Code != http.StatusForbidden {
t.Fatalf("member AI-flow status=%d body=%s", response.Code, response.Body.String())
}
deniedTask := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", "Bearer "+memberToken, TaskSubmission{
NodeID: "node-b", AccountID: "account-b", Kind: "send-text", IdempotencyKey: "denied-node-b",
Payload: json.RawMessage(`{"target_id":"chat-b","text":"not sent","confirmed":true}`),
})
if deniedTask.Code != http.StatusNotFound {
t.Fatalf("task for unassigned Node status=%d body=%s", deniedTask.Code, deniedTask.Body.String())
}
adminTask := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", "Bearer "+adminToken, TaskSubmission{
NodeID: "node-b", AccountID: "account-b", Kind: "send-text", IdempotencyKey: "admin-node-b",
Payload: json.RawMessage(`{"target_id":"chat-b","text":"test only","confirmed":true}`),
})
if adminTask.Code != http.StatusAccepted {
t.Fatalf("admin task create status=%d body=%s", adminTask.Code, adminTask.Body.String())
}
var adminTaskBody TaskSubmissionResponse
decodeBody(t, adminTask, &adminTaskBody)
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/tasks/"+adminTaskBody.TaskID, "Bearer "+memberToken, nil); response.Code != http.StatusNotFound {
t.Fatalf("unassigned task detail status=%d body=%s", response.Code, response.Body.String())
}
adminTaskA := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", "Bearer "+adminToken, TaskSubmission{
NodeID: "node-a", AccountID: "account-a", Kind: "send-text", IdempotencyKey: "admin-node-a",
Payload: json.RawMessage(`{"target_id":"chat-a","text":"test only","confirmed":true}`),
})
if adminTaskA.Code != http.StatusAccepted {
t.Fatalf("admin Node A task create status=%d body=%s", adminTaskA.Code, adminTaskA.Body.String())
}
memberTasks := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/tasks?limit=20", "Bearer "+memberToken, nil)
var taskList struct {
Tasks []Task `json:"tasks"`
}
decodeBody(t, memberTasks, &taskList)
if memberTasks.Code != http.StatusOK || len(taskList.Tasks) != 1 || taskList.Tasks[0].NodeID != "node-a" {
t.Fatalf("member task list was not scoped: status=%d tasks=%+v", memberTasks.Code, taskList.Tasks)
}
logout := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/auth/logout", "Bearer "+memberToken, nil)
if logout.Code != http.StatusOK {
t.Fatalf("logout status=%d", logout.Code)
}
if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/auth/me", "Bearer "+memberToken, nil); response.Code != http.StatusUnauthorized {
t.Fatalf("logged-out token status=%d", response.Code)
}
httpServer.Close()
if err := server.Close(); err != nil {
t.Fatal(err)
}
restarted, err := NewServer(ServerConfig{DataFile: dataFile, NodeTokens: nodeTokens})
if err != nil {
t.Fatalf("restart from persistent user store: %v", err)
}
restartedHTTP := httptest.NewServer(restarted.Handler())
defer restartedHTTP.Close()
defer restarted.Close()
restartedClient := restartedHTTP.Client()
persistedToken := testLogin(t, restartedClient, restartedHTTP.URL, "operator-a", "member-password-1")
persistedNodes := doJSON(t, restartedClient, http.MethodGet, restartedHTTP.URL+"/v1/nodes", "Bearer "+persistedToken, nil)
var persistedList struct {
Nodes []Node `json:"nodes"`
}
decodeBody(t, persistedNodes, &persistedList)
if persistedNodes.Code != http.StatusOK || len(persistedList.Nodes) != 1 || persistedList.Nodes[0].NodeID != "node-a" {
t.Fatalf("persisted assignment was lost: status=%d nodes=%+v", persistedNodes.Code, persistedList.Nodes)
}
}
func testLogin(t *testing.T, client *http.Client, baseURL, username, password string) string {
t.Helper()
response := doJSON(t, client, http.MethodPost, baseURL+"/v1/auth/login", "", map[string]string{"username": username, "password": password})
if response.Code != http.StatusOK {
t.Fatalf("login %s: status=%d body=%s", username, response.Code, response.Body.String())
}
var body struct {
AccessToken string `json:"access_token"`
}
decodeBody(t, response, &body)
if body.AccessToken == "" {
t.Fatalf("login %s returned no token", username)
}
return body.AccessToken
}
+486
View File
@@ -0,0 +1,486 @@
package controlplane
import (
"context"
"crypto/rand"
"crypto/subtle"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"sort"
"strings"
"time"
"unicode/utf8"
"golang.org/x/crypto/argon2"
)
const (
RoleAdmin = "admin"
RoleMember = "member"
passwordMemory = 64 * 1024
passwordTime = 3
passwordThreads = 2
passwordKeySize = 32
passwordSaltLen = 16
)
var (
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrLastAdministrator = errors.New("cannot disable or demote the last active administrator")
ErrInvalidUser = errors.New("invalid user")
ErrNoBootstrapAdmin = errors.New("no web users are configured to bootstrap the identity database")
)
type WebUser struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Active bool `json:"active"`
NodeIDs []string `json:"node_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WebPrincipal struct {
WebUser
}
func (p WebPrincipal) CanAccessNode(nodeID string) bool {
if p.Role == RoleAdmin {
return true
}
for _, assignedID := range p.NodeIDs {
if assignedID == nodeID {
return true
}
}
return false
}
type webCredential struct {
user WebUser
passwordHash string
}
type UserUpdate struct {
DisplayName string
Role string
Active bool
NodeIDs []string
}
type UserStore struct {
db *sql.DB
}
func NewUserStore(db *sql.DB) (*UserStore, error) {
if db == nil {
return nil, errors.New("user store database is required")
}
store := &UserStore{db: db}
if err := store.init(context.Background()); err != nil {
return nil, err
}
return store, nil
}
func (s *UserStore) init(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
active INTEGER NOT NULL CHECK (active IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_nodes (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, node_id)
);
CREATE INDEX IF NOT EXISTS user_nodes_node_id_idx ON user_nodes(node_id);
CREATE TABLE IF NOT EXISTS web_auth_schema (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL
);
INSERT OR IGNORE INTO web_auth_schema (id, version) VALUES (1, 1);
`)
if err != nil {
return fmt.Errorf("initialize user database: %w", err)
}
var version int
if err := s.db.QueryRowContext(ctx, `SELECT version FROM web_auth_schema WHERE id = 1`).Scan(&version); err != nil {
return fmt.Errorf("read user schema version: %w", err)
}
if version != 1 {
return fmt.Errorf("unsupported user schema version %d", version)
}
return nil
}
// BootstrapAdmins imports legacy environment credentials only while the new user table is empty.
func (s *UserStore) BootstrapAdmins(ctx context.Context, credentials map[string]string) error {
var count int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return fmt.Errorf("count users: %w", err)
}
if count > 0 {
return nil
}
if len(credentials) == 0 {
return ErrNoBootstrapAdmin
}
users := make([]string, 0, len(credentials))
for username := range credentials {
users = append(users, username)
}
sort.Strings(users)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin admin bootstrap: %w", err)
}
defer tx.Rollback()
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, username := range users {
password := credentials[username]
if !validUsername(username) || password == "" {
return ErrInvalidUser
}
hash, err := hashPassword(password)
if err != nil {
return fmt.Errorf("hash bootstrap password: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO users (id, username, display_name, password_hash, role, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)`, randomID(), strings.TrimSpace(username), strings.TrimSpace(username), hash, RoleAdmin, now, now); err != nil {
return fmt.Errorf("bootstrap administrator: %w", err)
}
}
return tx.Commit()
}
func (s *UserStore) Count(ctx context.Context) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
return count, err
}
func (s *UserStore) Authenticate(ctx context.Context, username, password string) (WebUser, bool, error) {
var credential webCredential
var active int
var created, updated string
err := s.db.QueryRowContext(ctx, `
SELECT id, username, display_name, password_hash, role, active, created_at, updated_at
FROM users WHERE username = ? COLLATE NOCASE`, strings.TrimSpace(username)).
Scan(&credential.user.ID, &credential.user.Username, &credential.user.DisplayName,
&credential.passwordHash, &credential.user.Role, &active, &created, &updated)
credential.user.Active = active == 1
if err == nil {
credential.user.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err == nil {
credential.user.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated)
}
}
if errors.Is(err, sql.ErrNoRows) {
return WebUser{}, false, nil
}
if err != nil {
return WebUser{}, false, fmt.Errorf("read user credentials: %w", err)
}
if !verifyPassword(credential.passwordHash, password) || !credential.user.Active {
return WebUser{}, false, nil
}
user, err := s.Get(ctx, credential.user.ID)
return user, err == nil, err
}
func (s *UserStore) List(ctx context.Context) ([]WebUser, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, u.username, u.display_name, u.role, u.active, u.created_at, u.updated_at, un.node_id
FROM users u LEFT JOIN user_nodes un ON un.user_id = u.id
ORDER BY u.username COLLATE NOCASE, un.node_id`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
users := make([]WebUser, 0)
byID := make(map[string]int)
for rows.Next() {
var user WebUser
var active int
var created, updated string
var nodeID sql.NullString
if err := rows.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Role, &active, &created, &updated, &nodeID); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
index, exists := byID[user.ID]
if !exists {
user.Active = active == 1
user.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err != nil {
return nil, fmt.Errorf("parse user creation time: %w", err)
}
user.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated)
if err != nil {
return nil, fmt.Errorf("parse user update time: %w", err)
}
user.NodeIDs = []string{}
users = append(users, user)
index = len(users) - 1
byID[user.ID] = index
}
if nodeID.Valid {
users[index].NodeIDs = append(users[index].NodeIDs, nodeID.String)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read users: %w", err)
}
return users, nil
}
func (s *UserStore) Get(ctx context.Context, id string) (WebUser, error) {
users, err := s.listBy(ctx, id)
if err != nil {
return WebUser{}, err
}
if len(users) == 0 {
return WebUser{}, ErrUserNotFound
}
return users[0], nil
}
func (s *UserStore) listBy(ctx context.Context, id string) ([]WebUser, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, u.username, u.display_name, u.role, u.active, u.created_at, u.updated_at, un.node_id
FROM users u LEFT JOIN user_nodes un ON un.user_id = u.id WHERE u.id = ? ORDER BY un.node_id`, id)
if err != nil {
return nil, fmt.Errorf("read user: %w", err)
}
defer rows.Close()
var user WebUser
found := false
for rows.Next() {
var active int
var created, updated string
var nodeID sql.NullString
if err := rows.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Role, &active, &created, &updated, &nodeID); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
if !found {
user.Active = active == 1
user.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err != nil {
return nil, fmt.Errorf("parse user creation time: %w", err)
}
user.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated)
if err != nil {
return nil, fmt.Errorf("parse user update time: %w", err)
}
user.NodeIDs = []string{}
found = true
}
if nodeID.Valid {
user.NodeIDs = append(user.NodeIDs, nodeID.String)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read user: %w", err)
}
if !found {
return nil, nil
}
return []WebUser{user}, nil
}
func (s *UserStore) Create(ctx context.Context, user WebUser, password string) (WebUser, error) {
user.Username = strings.TrimSpace(user.Username)
user.DisplayName = strings.TrimSpace(user.DisplayName)
if !validUsername(user.Username) || !validDisplayName(user.DisplayName) || !validRole(user.Role) || !validNewPassword(password) {
return WebUser{}, ErrInvalidUser
}
if user.Role == RoleAdmin {
user.NodeIDs = nil
} else {
user.NodeIDs = uniqueNodeIDs(user.NodeIDs)
}
hash, err := hashPassword(password)
if err != nil {
return WebUser{}, err
}
now := time.Now().UTC()
user.ID, user.Active, user.CreatedAt, user.UpdatedAt = randomID(), true, now, now
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return WebUser{}, fmt.Errorf("begin user creation: %w", err)
}
defer tx.Rollback()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE`, user.Username).Scan(&exists); err != nil {
return WebUser{}, fmt.Errorf("check username: %w", err)
}
if exists > 0 {
return WebUser{}, ErrUserAlreadyExists
}
stamp := now.Format(time.RFC3339Nano)
if _, err := tx.ExecContext(ctx, `INSERT INTO users (id, username, display_name, password_hash, role, active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?)`,
user.ID, user.Username, user.DisplayName, hash, user.Role, stamp, stamp); err != nil {
return WebUser{}, fmt.Errorf("create user: %w", err)
}
if err := insertUserNodes(ctx, tx, user.ID, user.NodeIDs, stamp); err != nil {
return WebUser{}, err
}
if err := tx.Commit(); err != nil {
return WebUser{}, fmt.Errorf("commit user creation: %w", err)
}
return user, nil
}
func (s *UserStore) Update(ctx context.Context, id string, update UserUpdate) (WebUser, error) {
update.DisplayName = strings.TrimSpace(update.DisplayName)
if !validDisplayName(update.DisplayName) || !validRole(update.Role) {
return WebUser{}, ErrInvalidUser
}
update.NodeIDs = uniqueNodeIDs(update.NodeIDs)
if update.Role == RoleAdmin {
update.NodeIDs = nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return WebUser{}, fmt.Errorf("begin user update: %w", err)
}
defer tx.Rollback()
var oldRole string
var oldActive int
if err := tx.QueryRowContext(ctx, `SELECT role, active FROM users WHERE id = ?`, id).Scan(&oldRole, &oldActive); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return WebUser{}, ErrUserNotFound
}
return WebUser{}, fmt.Errorf("read user before update: %w", err)
}
if oldRole == RoleAdmin && oldActive == 1 && (update.Role != RoleAdmin || !update.Active) {
var otherAdmins int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = ? AND active = 1 AND id <> ?`, RoleAdmin, id).Scan(&otherAdmins); err != nil {
return WebUser{}, fmt.Errorf("count active administrators: %w", err)
}
if otherAdmins == 0 {
return WebUser{}, ErrLastAdministrator
}
}
stamp := time.Now().UTC().Format(time.RFC3339Nano)
if _, err := tx.ExecContext(ctx, `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = ? WHERE id = ?`,
update.DisplayName, update.Role, boolInt(update.Active), stamp, id); err != nil {
return WebUser{}, fmt.Errorf("update user: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM user_nodes WHERE user_id = ?`, id); err != nil {
return WebUser{}, fmt.Errorf("replace user Node assignments: %w", err)
}
if err := insertUserNodes(ctx, tx, id, update.NodeIDs, stamp); err != nil {
return WebUser{}, err
}
if err := tx.Commit(); err != nil {
return WebUser{}, fmt.Errorf("commit user update: %w", err)
}
return s.Get(ctx, id)
}
func (s *UserStore) SetPassword(ctx context.Context, id, password string) error {
if !validNewPassword(password) {
return ErrInvalidUser
}
hash, err := hashPassword(password)
if err != nil {
return err
}
result, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, hash, time.Now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("update user password: %w", err)
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count == 0 {
return ErrUserNotFound
}
return nil
}
func insertUserNodes(ctx context.Context, tx *sql.Tx, userID string, nodeIDs []string, stamp string) error {
for _, nodeID := range nodeIDs {
if !validIdentifier(nodeID, 200) {
return ErrInvalidUser
}
if _, err := tx.ExecContext(ctx, `INSERT INTO user_nodes (user_id, node_id, created_at) VALUES (?, ?, ?)`, userID, nodeID, stamp); err != nil {
return fmt.Errorf("assign user Node: %w", err)
}
}
return nil
}
func hashPassword(password string) (string, error) {
salt := make([]byte, passwordSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt, passwordTime, passwordMemory, passwordThreads, passwordKeySize)
return "argon2id$v=19$m=65536,t=3,p=2$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(hash), nil
}
func verifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" || parts[2] != "m=65536,t=3,p=2" {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil || len(salt) != passwordSaltLen {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil || len(want) != passwordKeySize {
return false
}
got := argon2.IDKey([]byte(password), salt, passwordTime, passwordMemory, passwordThreads, passwordKeySize)
return subtle.ConstantTimeCompare(got, want) == 1
}
func validUsername(username string) bool {
if len(username) < 3 || len(username) > 64 || strings.TrimSpace(username) != username {
return false
}
for _, char := range username {
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || strings.ContainsRune("._@-", char)) {
return false
}
}
return true
}
func validDisplayName(name string) bool {
return name != "" && len(name) <= 120 && utf8.ValidString(name) && strings.TrimSpace(name) == name && !strings.ContainsAny(name, "\r\n\x00")
}
func validRole(role string) bool { return role == RoleAdmin || role == RoleMember }
func validNewPassword(password string) bool { return len(password) >= 12 && len(password) <= 256 }
func uniqueNodeIDs(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
+90
View File
@@ -0,0 +1,90 @@
package controlplane
import (
"context"
"errors"
"path/filepath"
"testing"
)
func testUserStore(t *testing.T) *UserStore {
t.Helper()
db, err := openSQLite(filepath.Join(t.TempDir(), "users.sqlite"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
store, err := NewUserStore(db)
if err != nil {
t.Fatal(err)
}
return store
}
func TestUserStoreBootstrapAndAuthentication(t *testing.T) {
store := testUserStore(t)
ctx := context.Background()
if err := store.BootstrapAdmins(ctx, map[string]string{"root": "bootstrap-password"}); err != nil {
t.Fatal(err)
}
user, ok, err := store.Authenticate(ctx, "ROOT", "bootstrap-password")
if err != nil || !ok {
t.Fatalf("expected case-insensitive bootstrap login, got ok=%v err=%v", ok, err)
}
if user.Role != RoleAdmin || !user.Active {
t.Fatalf("unexpected bootstrap user: %+v", user)
}
if _, ok, err := store.Authenticate(ctx, "root", "wrong-password"); err != nil || ok {
t.Fatalf("expected invalid password, got ok=%v err=%v", ok, err)
}
if err := store.BootstrapAdmins(ctx, map[string]string{"root": "changed-password"}); err != nil {
t.Fatal(err)
}
if _, ok, err := store.Authenticate(ctx, "root", "bootstrap-password"); err != nil || !ok {
t.Fatalf("bootstrap must not overwrite persisted credentials: ok=%v err=%v", ok, err)
}
}
func TestUserStoreAssignmentsAndLastAdministrator(t *testing.T) {
store := testUserStore(t)
ctx := context.Background()
if err := store.BootstrapAdmins(ctx, map[string]string{"root": "bootstrap-password"}); err != nil {
t.Fatal(err)
}
member, err := store.Create(ctx, WebUser{
Username: "operator-1", DisplayName: "Operator One", Role: RoleMember,
NodeIDs: []string{"node-a", "node-a"},
}, "member-password-1")
if err != nil {
t.Fatal(err)
}
if len(member.NodeIDs) != 1 || member.NodeIDs[0] != "node-a" {
t.Fatalf("expected de-duplicated Node assignment, got %v", member.NodeIDs)
}
if _, err := store.Create(ctx, WebUser{Username: "OPERATOR-1", DisplayName: "Duplicate", Role: RoleMember}, "member-password-2"); !errors.Is(err, ErrUserAlreadyExists) {
t.Fatalf("expected case-insensitive duplicate rejection, got %v", err)
}
if _, err := store.Update(ctx, member.ID, UserUpdate{
DisplayName: "Operator Updated", Role: RoleMember, Active: false, NodeIDs: []string{"node-b"},
}); err != nil {
t.Fatal(err)
}
updated, err := store.Get(ctx, member.ID)
if err != nil {
t.Fatal(err)
}
if updated.Active || len(updated.NodeIDs) != 1 || updated.NodeIDs[0] != "node-b" {
t.Fatalf("user update did not persist: %+v", updated)
}
admins, err := store.List(ctx)
if err != nil {
t.Fatal(err)
}
for _, admin := range admins {
if admin.Role == RoleAdmin {
if _, err := store.Update(ctx, admin.ID, UserUpdate{DisplayName: admin.DisplayName, Role: RoleMember, Active: true}); !errors.Is(err, ErrLastAdministrator) {
t.Fatalf("expected last administrator protection, got %v", err)
}
}
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -5
View File
@@ -3,11 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f7fb" />
<meta name="description" content="WxAgent 普通用户工作台" />
<title>WxAgent 工作台</title>
<script type="module" crossorigin src="/assets/index-BS_ZFWFu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-6ql7JGil.css">
<meta name="theme-color" content="#f5f7fb" />
<meta name="csp-nonce" content="__WX_CSP_NONCE__" />
<meta name="description" content="WxAgent control plane for Desktop Agent management" />
<title>WxAgent 控制面</title>
<script type="module" crossorigin src="/assets/index-B_02q8Ux.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DLJyOQT-.css">
</head>
<body>
<div id="root"></div>
+4 -3
View File
@@ -3,9 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f7fb" />
<meta name="description" content="WxAgent 普通用户工作台" />
<title>WxAgent 工作台</title>
<meta name="theme-color" content="#f5f7fb" />
<meta name="csp-nonce" content="__WX_CSP_NONCE__" />
<meta name="description" content="WxAgent control plane for Desktop Agent management" />
<title>WxAgent 控制面</title>
</head>
<body>
<div id="root"></div>
+1588
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -10,9 +10,16 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@ant-design/pro-components": "^2.8.10",
"@ant-design/x": "^1.6.1",
"antd": "^5.29.3",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"overrides": {
"path-to-regexp": "8.4.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.14"
+298
View File
@@ -0,0 +1,298 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { Avatar, Button, Dropdown, Form, Input, Select, Space, Spin, Tag, Typography } from "antd";
import { PageContainer, ProLayout } from "@ant-design/pro-components";
import {
AppstoreOutlined,
AuditOutlined,
ContactsOutlined,
DesktopOutlined,
LogoutOutlined,
MessageOutlined,
ReloadOutlined,
SendOutlined,
TeamOutlined,
UnorderedListOutlined,
UserOutlined,
} from "@ant-design/icons";
import { api, activeAccount, isClientAvailable, statusColor, statusLabel } from "./api.js";
const MessagesView = lazy(() => import("./views/MessagesView.jsx"));
const UsersView = lazy(() => import("./views/AdminViews.jsx").then((module) => ({ default: module.UsersView })));
const NodesView = lazy(() => import("./views/AdminViews.jsx").then((module) => ({ default: module.NodesView })));
const OverviewView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.OverviewView })));
const ContactsView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.ContactsView })));
const TasksView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.TasksView })));
const BroadcastView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.BroadcastView })));
const DiagnosticsView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.DiagnosticsView })));
const { Text } = Typography;
const TOKEN_KEY = "wxagent-token";
const CLIENT_KEY = "wxagent-client";
const PAGE_META = {
overview: { title: "工作台概览", subtitle: "统一查看 Desktop Agent 连接状态与任务执行情况" },
messages: { title: "消息工作台", subtitle: "按已授权 Client / Node 查看会话和消息历史" },
contacts: { title: "通讯录", subtitle: "读取已授权账号的联系人与群聊缓存" },
tasks: { title: "任务中心", subtitle: "跟踪 Client 领取、执行与结果状态" },
agents: { title: "Desktop Agent", subtitle: "远程桌面节点与账号连接状态" },
users: { title: "用户与权限", subtitle: "管理控制面用户,并分配可访问的 Desktop Agent / Node" },
broadcast: { title: "受控群发", subtitle: "Web 写操作仍需单独验收" },
diagnostics: { title: "诊断与审计", subtitle: "管理员可见的连接、事件与审计信息" },
};
const VALID_VIEWS = new Set(Object.keys(PAGE_META));
function viewFromLocation() {
const path = window.location.pathname.split("/").filter(Boolean).join("/");
return VALID_VIEWS.has(path) ? path : "overview";
}
function LoginPage({ onLogin }) {
const [form] = Form.useForm();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const submit = async (values) => {
setBusy(true);
setError("");
try {
const result = await api("/v1/auth/login", { method: "POST", body: values });
onLogin(result.access_token, result.user);
} catch (reason) {
setError(reason.message);
} finally {
setBusy(false);
}
};
return (
<main className="login-shell">
<section className="login-intro-panel">
<div className="brand-lockup"><span className="brand-mark">WX</span><span>WxAgent <Text type="secondary">CONTROL PLANE</Text></span></div>
<div className="login-intro-copy">
<Tag color="blue">Desktop Agent 管理平台</Tag>
<Typography.Title level={1}>把多个 Client,<br />放进同一个工作台。</Typography.Title>
<Typography.Paragraph type="secondary">按用户分配 Desktop Agent / Node 访问范围,在统一工作台中查看授权会话、通讯录与任务状态。</Typography.Paragraph>
</div>
<div className="login-capabilities">
<div><DesktopOutlined /><span><strong>节点隔离</strong><small>成员仅能查看获分配的 Node</small></span></div>
<div><TeamOutlined /><span><strong>持久化权限</strong><small>用户与分配关系由控制面保存</small></span></div>
<div><MessageOutlined /><span><strong>消息优先</strong><small>保留现有真实消息和 Client 接口</small></span></div>
</div>
<Text className="login-legal" type="secondary">仅展示已授权、规范化的只读数据;写操作按真机验收状态开放。</Text>
</section>
<section className="login-form-panel">
<div className="login-form-card">
<div className="login-form-heading"><Avatar size={46} icon={<UserOutlined />} /><div><Typography.Title level={3}>登录控制面</Typography.Title><Text type="secondary">使用管理员为你创建的 Web 账号</Text></div></div>
{error && <div className="login-error" role="alert">{error}</div>}
<Form form={form} layout="vertical" onFinish={submit} requiredMark={false} size="large">
<Form.Item name="username" label="用户名" rules={[{ required: true, message: "请输入用户名" }]}>
<Input autoFocus autoComplete="username" placeholder="输入用户名" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
<Input.Password autoComplete="current-password" placeholder="输入密码" />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={busy}>登录工作台</Button>
</Form>
<div className="login-security"><span className="security-indicator" /> 会话凭据仅保存在当前浏览器标签页</div>
</div>
<Text className="login-footer" type="secondary">WxAgent · 远程 Client 管理与只读数据工作台</Text>
</section>
</main>
);
}
function ConnectionAlert({ node, nodes, onSettings }) {
if (!nodes.length) {
return <div className="context-alert"><Tag color="error">无可用 Node</Tag><Text>请启动已配置的 Desktop Agent;浏览器不会模拟连接状态。</Text><Button type="link" onClick={onSettings}>查看诊断</Button></div>;
}
if (!isClientAvailable(node)) {
return <div className="context-alert"><Tag color="warning">当前 Node 不可用</Tag><Text>可从顶部切换到其它已分配的 Node;离线任务不会自动转派或重放。</Text><Button type="link" onClick={onSettings}>查看诊断</Button></div>;
}
if (!activeAccount(node)) {
return <div className="context-alert"><Tag color="warning">未确认活动账号</Tag><Text>读取只针对已验证的活动账号。</Text><Button type="link" onClick={onSettings}>查看诊断</Button></div>;
}
return null;
}
function ConsoleApp() {
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) || "");
const [profile, setProfile] = useState(null);
const [view, setView] = useState(viewFromLocation);
const [nodes, setNodes] = useState([]);
const [selectedNodeId, setSelectedNodeId] = useState(() => sessionStorage.getItem(CLIENT_KEY) || "");
const [tasks, setTasks] = useState([]);
const [events, setEvents] = useState([]);
const [audit, setAudit] = useState([]);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const isAdmin = profile?.role === "admin";
const selectedNode = useMemo(() => nodes.find((node) => node.node_id === selectedNodeId), [nodes, selectedNodeId]);
const navigate = useCallback((nextView) => {
if (!VALID_VIEWS.has(nextView)) return;
window.history.pushState({}, "", nextView === "overview" ? "/" : `/${nextView}`);
setView(nextView);
}, []);
useEffect(() => {
const onPopState = () => setView(viewFromLocation());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
const clearSession = useCallback(() => {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(CLIENT_KEY);
setToken("");
setProfile(null);
setNodes([]);
setTasks([]);
setEvents([]);
setAudit([]);
}, []);
const logout = useCallback(async () => {
if (token) {
try { await api("/v1/auth/logout", { token, method: "POST" }); } catch { /* The local session must still be cleared. */ }
}
clearSession();
}, [clearSession, token]);
const refresh = useCallback(async () => {
if (!token) return;
setRefreshing(true);
setError("");
try {
const me = await api("/v1/auth/me", { token });
setProfile(me.user);
const [nodeData, taskData, eventData, auditData] = await Promise.all([
api("/v1/nodes", { token }),
api("/v1/tasks?limit=200", { token }),
api("/v1/events?limit=200", { token }),
me.user.role === "admin" ? api("/v1/audit?limit=200", { token }) : Promise.resolve({ audit: [] }),
]);
setNodes(nodeData.nodes || []);
setTasks(taskData.tasks || []);
setEvents(eventData.events || []);
setAudit(auditData.audit || []);
} catch (reason) {
if (reason.status === 401) clearSession();
else setError(reason.message);
} finally {
setRefreshing(false);
}
}, [clearSession, token]);
useEffect(() => {
if (!token) return undefined;
void refresh();
const timer = window.setInterval(() => void refresh(), 15000);
return () => window.clearInterval(timer);
}, [refresh, token]);
useEffect(() => {
if (!nodes.length) {
setSelectedNodeId("");
sessionStorage.removeItem(CLIENT_KEY);
return;
}
if (!nodes.some((node) => node.node_id === selectedNodeId)) {
const next = nodes.find(isClientAvailable) || nodes[0];
setSelectedNodeId(next.node_id);
sessionStorage.setItem(CLIENT_KEY, next.node_id);
}
}, [nodes, selectedNodeId]);
useEffect(() => {
if (!profile) return;
const allowedViews = new Set(["overview", "messages", "contacts", "tasks", "agents", "broadcast", ...(isAdmin ? ["users", "diagnostics"] : [])]);
if (!allowedViews.has(view)) navigate("overview");
}, [isAdmin, navigate, profile, view]);
const handleLogin = (nextToken, nextProfile) => {
sessionStorage.setItem(TOKEN_KEY, nextToken);
setToken(nextToken);
setProfile(nextProfile || null);
};
const selectNode = (nodeID) => {
setSelectedNodeId(nodeID);
sessionStorage.setItem(CLIENT_KEY, nodeID);
};
if (!token) return <LoginPage onLogin={handleLogin} />;
if (!profile) return <div className="boot-screen"><span className="brand-mark">WX</span><Text type="secondary">正在验证会话…</Text></div>;
const navigation = [
{ key: "overview", path: "/overview", name: "概览", icon: <AppstoreOutlined /> },
{ key: "messages", path: "/messages", name: "消息工作台", icon: <MessageOutlined /> },
{ key: "contacts", path: "/contacts", name: "通讯录", icon: <ContactsOutlined /> },
{ key: "tasks", path: "/tasks", name: "任务中心", icon: <UnorderedListOutlined /> },
{ key: "agents", path: "/agents", name: "Desktop Agent", icon: <DesktopOutlined /> },
{ key: "broadcast", path: "/broadcast", name: "受控群发", icon: <SendOutlined /> },
...(isAdmin ? [
{ key: "users", path: "/users", name: "用户与权限", icon: <TeamOutlined /> },
{ key: "diagnostics", path: "/diagnostics", name: "诊断与审计", icon: <AuditOutlined /> },
] : []),
];
const page = PAGE_META[view] || PAGE_META.overview;
const contextPage = ["messages", "contacts", "tasks"].includes(view);
const userMenu = {
items: [{ key: "logout", icon: <LogoutOutlined />, label: "退出登录" }],
onClick: ({ key }) => key === "logout" && void logout(),
};
return (
<ProLayout
className="wx-pro-layout"
title="WxAgent"
logo={<span className="pro-brand-mark">WX</span>}
layout="mix"
navTheme="light"
fixedHeader
fixSiderbar
siderWidth={248}
location={{ pathname: `/${view}` }}
menuDataRender={() => navigation}
menuItemRender={(item, dom) => <a className="pro-menu-link" href={item.path} onClick={(event) => { event.preventDefault(); navigate(item.key); }}>{dom}</a>}
rightContentRender={() => (
<Space size={14} className="header-actions">
<Select
aria-label="当前 Desktop Agent"
value={selectedNodeId || undefined}
placeholder="选择 Desktop Agent"
disabled={!nodes.length}
onChange={selectNode}
options={nodes.map((node) => ({ value: node.node_id, label: `${node.node_id} · ${statusLabel(node.status)}` }))}
style={{ width: 250 }}
/>
{selectedNode && <Tag color={statusColor(selectedNode.status)}>{statusLabel(selectedNode.status)}</Tag>}
<Button type="text" icon={<ReloadOutlined />} loading={refreshing} onClick={() => void refresh()}>刷新</Button>
<Dropdown menu={userMenu} placement="bottomRight" trigger={["click"]}>
<Button type="text" className="profile-button"><Avatar size="small" icon={<UserOutlined />} />{profile.display_name || profile.username}<Tag color={isAdmin ? "blue" : "default"}>{isAdmin ? "管理员" : "成员"}</Tag></Button>
</Dropdown>
</Space>
)}
menuFooterRender={() => <div className="pro-sidebar-footer"><span className="security-indicator" /> 会话已认证 · Node 权限隔离</div>}
footerRender={() => null}
contentStyle={{ padding: 0, flex: 1, minHeight: 0, overflow: "hidden" }}
>
<PageContainer title={page.title} subTitle={page.subtitle} className="wx-page-container">
<Suspense fallback={<div className="view-loading"><Spin /><Text type="secondary">正在加载视图…</Text></div>}>
{error && <div className="global-error"><Text type="danger">{error}</Text><Button type="link" onClick={() => setError("")}>关闭</Button></div>}
{contextPage && <ConnectionAlert node={selectedNode} nodes={nodes} onSettings={() => navigate(isAdmin ? "diagnostics" : "agents")} />}
{view === "overview" && <OverviewView nodes={nodes} tasks={tasks} events={events} onNavigate={navigate} onRefresh={() => void refresh()} refreshing={refreshing} />}
{view === "messages" && <MessagesView token={token} client={selectedNode} onSettings={() => navigate(isAdmin ? "diagnostics" : "agents")} />}
{view === "contacts" && <ContactsView token={token} client={selectedNode} />}
{view === "tasks" && <TasksView tasks={tasks} selectedNodeId={selectedNodeId} />}
{view === "agents" && <NodesView token={token} nodes={nodes} isAdmin={isAdmin} onGoUsers={() => navigate("users")} />}
{view === "users" && isAdmin && <UsersView token={token} currentUser={profile} nodes={nodes} onChanged={() => void refresh()} />}
{view === "broadcast" && <BroadcastView client={selectedNode} />}
{view === "diagnostics" && isAdmin && <DiagnosticsView nodes={nodes} events={events} audit={audit} selectedNodeId={selectedNodeId} onSelectNode={selectNode} />}
</Suspense>
</PageContainer>
</ProLayout>
);
}
export default ConsoleApp;
+181
View File
@@ -0,0 +1,181 @@
import {
extractItems,
mergeStableItems,
normalizeMessages,
normalizeSessions,
resolveReadCoverage,
shouldReplaceReadState,
} from "./readState.js";
const STATUS_LABELS = {
Online: "在线",
Degraded: "需要注意",
Offline: "已离线",
Registered: "已注册",
SessionLocked: "桌面已锁定",
WechatNotRunning: "微信未运行",
WechatNotLoggedIn: "微信未登录",
Pending: "待处理",
WaitingForClient: "等待 Client",
Accepted: "已接收",
Running: "执行中",
Succeeded: "已完成",
Failed: "失败",
Cancelled: "已取消",
Expired: "已过期",
ResultUnconfirmed: "待核对",
};
const TERMINAL_TASKS = new Set(["Succeeded", "Failed", "Cancelled", "Expired", "ResultUnconfirmed"]);
export class ApiError extends Error {
constructor(message, status, code = "RequestFailed") {
super(message);
this.status = status;
this.code = code;
}
}
export async function api(path, { token, method = "GET", body } = {}) {
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) headers["Content-Type"] = "application/json";
const response = await fetch(path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new ApiError(payload.error?.message || `请求失败(${response.status})`, response.status, payload.error?.code);
}
return payload;
}
const wait = (duration) => new Promise((resolve) => window.setTimeout(resolve, duration));
const TASK_ERROR_MESSAGES = {
ReportingDisabled: "Client 未启用 Reporting 白名单。请在托盘远程连接页启用并配置白名单。",
ReportingConfigInvalid: "Client 的 Reporting 配置无效,请检查托盘远程连接页。",
AccountNotAuthorized: "当前账号未加入 Client 的 Reporting 白名单。",
ChatNotAuthorized: "当前会话不在 Client 的 Reporting 白名单中。",
ChatIdentityUnconfirmed: "当前会话身份尚未确认,暂不能读取。",
DataTypeNotAuthorized: "当前读取类型未被 Reporting 白名单授权。",
};
function taskFailureMessage(task) {
const code = task.result?.error_code || task.status;
return TASK_ERROR_MESSAGES[code] || task.result?.message || `读取任务${statusLabel(task.status)}。`;
}
async function waitForTask(token, taskId) {
const deadline = Date.now() + 30000;
let delay = 250;
let lastTask = { status: "Pending" };
while (Date.now() < deadline) {
const task = await api(`/v1/tasks/${encodeURIComponent(taskId)}`, { token });
lastTask = task;
if (task.status === "Succeeded") return task.result?.content || {};
if (task.status === "WaitingForClient") {
throw new ApiError("当前 Client 暂不可用,请切换 Client 后重试。", 409, "ClientNotReady");
}
if (TERMINAL_TASKS.has(task.status)) {
throw new ApiError(taskFailureMessage(task), 409, task.result?.error_code || task.status);
}
await wait(delay);
delay = Math.min(1000, delay + 150);
}
if (lastTask.status === "Running") {
throw new ApiError(`Client 仍在执行读取任务(${taskId.slice(0, 12)}…),请稍后在任务页查看结果。`, 408, "TaskRunning");
}
throw new ApiError(`任务仍在等待 Client 领取(${taskId.slice(0, 12)}…),请检查 Reporting 账号配置。`, 408, "TaskPending");
}
export async function readWithTask(path, body, token) {
const created = await api(path, {
token,
method: "POST",
body: { ...body, idempotency_key: `${path}-${Date.now()}-${Math.random().toString(36).slice(2)}` },
});
if (created.status === "WaitingForClient") {
throw new ApiError("当前 Client 暂不可用,请切换 Client 后重试。", 409, "ClientNotReady");
}
return waitForTask(token, created.task_id);
}
export function dataPath(accountId, resource, query = "") {
return `/v1/data/accounts/${encodeURIComponent(accountId)}/${resource}${query}`;
}
function canUseLegacyReadFallback(reason) {
return ["AccountDataNotFound", "AccountDataUnavailable", "DataStatusFailed"].includes(reason.code);
}
export async function readStoredOrFallback({ storedPath, legacyPath, legacyBody, token }) {
try {
return await api(storedPath, { token });
} catch (reason) {
if (!canUseLegacyReadFallback(reason)) throw reason;
return readWithTask(legacyPath, legacyBody, token);
}
}
export function formatTime(value) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("zh-CN", { hour12: false });
}
export function shortId(value, length = 16) {
if (!value) return "—";
return value.length > length ? `${value.slice(0, length)}…` : value;
}
export function statusLabel(status) {
return STATUS_LABELS[status] || status || "未知";
}
export function statusColor(status) {
if (["Online", "Succeeded", "Accepted"].includes(status)) return "success";
if (["Running", "Pending", "WaitingForClient", "Degraded"].includes(status)) return "warning";
if (["Failed", "Cancelled", "Expired", "ResultUnconfirmed", "Offline", "SessionLocked", "WechatNotRunning", "WechatNotLoggedIn"].includes(status)) return "error";
return "default";
}
export function isClientAvailable(node) {
return Boolean(node && ["Online", "Degraded", "Registered"].includes(node.status));
}
export function activeAccount(node) {
if (!node) return "";
if (node.active_account_id) return node.active_account_id;
return node.accounts?.find((account) => account.active && account.verified)?.account_id || "";
}
export function normalizeContacts(content) {
return extractItems(content, ["items", "contacts", "sessions"]).map((item, index) => ({
id: String(item.contact_id ?? item.contactId ?? item.chat_id ?? item.chatId ?? item.id ?? index),
title: String(item.name ?? item.display_name ?? item.displayName ?? item.nickname ?? item.contact_id ?? item.id ?? "未命名联系人"),
type: item.chat_type ?? item.chatType ?? (item.is_group ? "Group" : "Private"),
detail: String(item.remark ?? item.alias ?? item.account_id ?? item.accountId ?? ""),
}));
}
export function coverageNotice(label, coverage) {
if (!coverage) return "";
const freshness = coverage.lastSuccessAt ? `最后同步 ${formatTime(coverage.lastSuccessAt)}` : "尚未成功同步";
const backlog = coverage.backlogCount == null ? "积压未知" : `积压 ${coverage.backlogCount}`;
const suffix = `${freshness} · ${backlog}`;
if (coverage.state === "complete") return coverage.source === "platform-cache" ? `${label}来自平台副本,${suffix}。` : "";
if (coverage.state === "partial") return `${label}同步不完整,已保留已有数据;${suffix}。`;
return `${label}同步状态未知,未用本次结果清除已有数据;${suffix}${coverage.errorMessage ? ` · ${coverage.errorMessage}` : ""}。`;
}
export {
extractItems,
mergeStableItems,
normalizeMessages,
normalizeSessions,
resolveReadCoverage,
shouldReplaceReadState,
};
+16 -623
View File
@@ -1,627 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { App as AntApp, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import ConsoleApp from "./App.jsx";
import "antd/dist/reset.css";
import "./styles.css";
import {
mergeStableItems,
normalizeMessages,
normalizeSessions,
resolveReadCoverage,
shouldReplaceReadState,
} from "./readState.js";
const NAV_ITEMS = [
{ id: "messages", label: "消息", description: "查找会话、阅读并回复", icon: "message" },
{ id: "broadcast", label: "群发", description: "创建受控群发任务", icon: "send" },
{ id: "contacts", label: "通讯录", description: "查找联系人和群聊", icon: "contacts" },
{ id: "tasks", label: "任务", description: "查看执行结果", icon: "tasks" },
];
const cspNonce = document.querySelector('meta[name="csp-nonce"]')?.content;
const STATUS_LABELS = {
Online: "在线",
Degraded: "需要注意",
Offline: "已离线",
Registered: "已注册",
SessionLocked: "桌面已锁定",
WechatNotRunning: "微信未运行",
WechatNotLoggedIn: "微信未登录",
Pending: "待处理",
WaitingForClient: "等待 Client",
Accepted: "已接收",
Running: "执行中",
Succeeded: "已完成",
Failed: "失败",
Cancelled: "已取消",
Expired: "已过期",
ResultUnconfirmed: "待核对",
};
const TERMINAL_TASKS = new Set([
"Succeeded",
"Failed",
"Cancelled",
"Expired",
"ResultUnconfirmed",
]);
class ApiError extends Error {
constructor(message, status, code = "RequestFailed") {
super(message);
this.status = status;
this.code = code;
}
}
async function api(path, { token, method = "GET", body } = {}) {
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) headers["Content-Type"] = "application/json";
const response = await fetch(path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new ApiError(
payload.error?.message || `请求失败(${response.status})`,
response.status,
payload.error?.code,
);
}
return payload;
}
const wait = (duration) => new Promise((resolve) => window.setTimeout(resolve, duration));
const TASK_ERROR_MESSAGES = {
ReportingDisabled: "Client 未启用 Reporting 白名单。请在托盘远程连接页启用并配置白名单。",
ReportingConfigInvalid: "Client 的 Reporting 配置无效,请检查托盘远程连接页。",
AccountNotAuthorized: "当前账号未加入 Client 的 Reporting 白名单。",
ChatNotAuthorized: "当前会话不在 Client 的 Reporting 白名单中。",
ChatIdentityUnconfirmed: "当前会话身份尚未确认,暂不能读取。",
DataTypeNotAuthorized: "当前读取类型未被 Reporting 白名单授权。",
};
function taskFailureMessage(task) {
const code = task.result?.error_code || task.status;
return TASK_ERROR_MESSAGES[code] || task.result?.message || `读取任务${STATUS_LABELS[task.status] || "未完成"}。`;
}
async function waitForTask(token, taskId) {
const deadline = Date.now() + 30000;
let delay = 250;
let lastTask = { status: "Pending" };
while (Date.now() < deadline) {
const task = await api(`/v1/tasks/${encodeURIComponent(taskId)}`, { token });
lastTask = task;
if (task.status === "Succeeded") return task.result?.content || {};
if (task.status === "WaitingForClient") {
throw new ApiError("当前 Client 暂不可用,请切换 Client 后重试。", 409, "ClientNotReady");
}
if (TERMINAL_TASKS.has(task.status)) {
throw new ApiError(taskFailureMessage(task), 409, task.result?.error_code || task.status);
}
await wait(delay);
delay = Math.min(1000, delay + 150);
}
if (lastTask.status === "Running") {
throw new ApiError(`Client 仍在执行读取任务(${taskId.slice(0, 12)}…),请稍后在任务页查看结果。`, 408, "TaskRunning");
}
throw new ApiError(`任务仍在等待 Client 领取(${taskId.slice(0, 12)}…),请检查 Reporting 账号配置。`, 408, "TaskPending");
}
async function readWithTask(path, body, token) {
const created = await api(path, {
token,
method: "POST",
body: {
...body,
idempotency_key: `${path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
},
});
if (created.status === "WaitingForClient") {
throw new ApiError("当前 Client 暂不可用,请切换 Client 后重试。", 409, "ClientNotReady");
}
return waitForTask(token, created.task_id);
}
function dataPath(accountId, resource, query = "") {
return `/v1/data/accounts/${encodeURIComponent(accountId)}/${resource}${query}`;
}
function canUseLegacyReadFallback(reason) {
return ["AccountDataNotFound", "AccountDataUnavailable", "DataStatusFailed"].includes(reason.code);
}
async function readStoredOrFallback({ storedPath, legacyPath, legacyBody, token }) {
try {
return await api(storedPath, { token });
} catch (reason) {
if (!canUseLegacyReadFallback(reason)) throw reason;
return readWithTask(legacyPath, legacyBody, token);
}
}
function formatTime(value) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime())
? "—"
: date.toLocaleString("zh-CN", { hour12: false });
}
function shortId(value, length = 16) {
if (!value) return "—";
return value.length > length ? `${value.slice(0, length)}…` : value;
}
function statusTone(status) {
if (["Online", "Succeeded", "Accepted"].includes(status)) return "positive";
if (["Running", "Pending", "WaitingForClient", "Degraded"].includes(status)) return "warning";
if (
[
"Failed",
"Cancelled",
"Expired",
"ResultUnconfirmed",
"Offline",
"SessionLocked",
"WechatNotRunning",
"WechatNotLoggedIn",
].includes(status)
)
return "negative";
return "neutral";
}
function statusLabel(status) {
return STATUS_LABELS[status] || status || "未知";
}
function isClientAvailable(node) {
return Boolean(node && ["Online", "Degraded", "Registered"].includes(node.status));
}
function activeAccount(node) {
if (!node) return "";
if (node.active_account_id) return node.active_account_id;
return node.accounts?.find((account) => account.active && account.verified)?.account_id || "";
}
function normalizeContacts(content) {
return extractItems(content, ["items", "contacts", "sessions"]).map((item, index) => ({
id: String(item.contact_id ?? item.contactId ?? item.chat_id ?? item.chatId ?? item.id ?? index),
title: String(item.name ?? item.display_name ?? item.displayName ?? item.nickname ?? item.contact_id ?? item.id ?? "未命名联系人"),
type: item.chat_type ?? item.chatType ?? (item.is_group ? "Group" : "Private"),
detail: String(item.remark ?? item.alias ?? item.account_id ?? item.accountId ?? ""),
}));
}
function Icon({ name, size = 18 }) {
const paths = {
message: <><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v7a2.5 2.5 0 0 1-2.5 2.5H11l-4.5 3v-3h0A2.5 2.5 0 0 1 4 12.5z" /><path d="M8 8h8M8 11h5" /></>,
send: <><path d="m3 4 18 8-18 8 3.5-8z" /><path d="M6.5 12H21" /></>,
contacts: <><circle cx="9" cy="8" r="3" /><path d="M3.5 19a5.5 5.5 0 0 1 11 0M17 7v6M14 10h6" /></>,
tasks: <><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h5" /></>,
settings: <><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1" /><circle cx="12" cy="12" r="4" /></>,
refresh: <><path d="M20 11a8 8 0 0 0-14.5-4.5L4 8" /><path d="M4 4v4h4M4 13a8 8 0 0 0 14.5 4.5L20 16" /><path d="M20 20v-4h-4" /></>,
chevron: <path d="m9 6 6 6-6 6" />,
search: <><circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4.5 4.5" /></>,
shield: <><path d="M12 3 19 6v5c0 4.2-2.8 7.8-7 9-4.2-1.2-7-4.8-7-9V6z" /><path d="m9 12 2 2 4-4" /></>,
alert: <><path d="M12 4 21 20H3z" /><path d="M12 10v4M12 17v.2" /></>,
close: <><path d="m6 6 12 12M18 6 6 18" /></>,
logout: <><path d="M10 4H5v16h5M14 8l4 4-4 4M8 12h10" /></>,
arrow: <><path d="M4 12h15M13 6l6 6-6 6" /></>,
check: <path d="m5 12 4 4L19 6" />,
};
return (
<svg aria-hidden="true" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{paths[name] || paths.settings}
</svg>
);
}
function StatusBadge({ value }) {
return (
<span className={`status-badge ${statusTone(value)}`}>
<i />
{statusLabel(value)}
</span>
);
}
function Login({ onLogin }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event) => {
event.preventDefault();
setError("");
setBusy(true);
try {
const result = await api("/v1/auth/login", { method: "POST", body: { username, password } });
onLogin(result.access_token, username);
} catch (reason) {
setError(reason.message);
} finally {
setBusy(false);
}
};
return (
<main className="login-page">
<section className="login-panel" aria-labelledby="login-title">
<div className="brand-mark">W</div>
<p className="eyebrow">WXAGENT WORKSPACE</p>
<h1 id="login-title">进入工作台</h1>
<p className="login-intro">连接已授权的 Client,处理消息、通讯录和任务结果。</p>
<form onSubmit={submit} className="login-form">
<label>用户名<input autoFocus value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" /></label>
<label>密码<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" /></label>
{error && <div className="form-error" role="alert">{error}</div>}
<button className="primary-button full" disabled={busy || !username || !password}>{busy ? "登录中…" : "登录工作台"}</button>
</form>
<p className="login-footnote"><span className="secure-dot" /> 会话只保存在当前浏览器标签页</p>
</section>
</main>
);
}
function Sidebar({ view, setView, onLogout, username }) {
return (
<aside className="sidebar">
<div className="sidebar-brand">
<div className="brand-mark small">W</div>
<div><strong>WxAgent</strong><span>用户工作台</span></div>
</div>
<p className="nav-caption">工作区</p>
<nav aria-label="主导航">
{NAV_ITEMS.map((item) => (
<button key={item.id} className={view === item.id ? "nav-item active" : "nav-item"} onClick={() => setView(item.id)}>
<Icon name={item.icon} size={18} />
<span>{item.label}</span>
</button>
))}
</nav>
<div className="sidebar-spacer" />
<button className={view === "settings" ? "nav-item active" : "nav-item"} onClick={() => setView("settings")}>
<Icon name="settings" size={18} />
<span>设置与诊断</span>
</button>
<div className="connection-hint"><span className="secure-dot" /><div><strong>安全连接</strong><small>控制面 API 已认证</small></div></div>
<div className="user-menu">
<div className="avatar">{(username || "A").slice(0, 1).toUpperCase()}</div>
<div className="user-name"><strong>{username || "管理员"}</strong><small>已登录</small></div>
<button title="退出登录" aria-label="退出登录" onClick={onLogout}><Icon name="logout" size={17} /></button>
</div>
</aside>
);
}
function Topbar({ title, subtitle, nodes, selectedClientId, onClientChange, onRefresh, loading }) {
const selected = nodes.find((node) => node.node_id === selectedClientId);
return (
<header className="topbar">
<div className="topbar-copy"><h1>{title}</h1><p>{subtitle}</p></div>
<div className="topbar-actions">
<label className="client-switcher">
<span>当前 Client</span>
<select value={selectedClientId} onChange={(event) => onClientChange(event.target.value)} disabled={!nodes.length}>
{!nodes.length && <option value="">暂无 Client</option>}
{nodes.map((node) => <option key={node.node_id} value={node.node_id}>{node.node_id} · {statusLabel(node.status)}</option>)}
</select>
</label>
{selected && <StatusBadge value={selected.status} />}
<button className="refresh-button" onClick={onRefresh} disabled={loading} aria-label="刷新数据"><span className={loading ? "spin" : ""}><Icon name="refresh" size={16} /></span>{loading ? "同步中" : "刷新"}</button>
</div>
</header>
);
}
function ConnectionBanner({ nodes, selectedNode, onSettings }) {
if (!nodes.length) {
return <div className="connection-banner negative"><Icon name="alert" size={18} /><div><strong>还没有可用 Client</strong><span>请启动已配置的 Desktop Agent;浏览器不会模拟连接状态。</span></div><button className="text-button" onClick={onSettings}>查看连接说明</button></div>;
}
if (!isClientAvailable(selectedNode)) {
const other = nodes.some(isClientAvailable);
return <div className="connection-banner warning"><Icon name="alert" size={18} /><div><strong>当前 Client 不可用</strong><span>{other ? "其它 Client 不受影响,请从顶部切换。" : "所有 Client 当前都不可用,请先恢复连接。"}</span></div><button className="text-button" onClick={onSettings}>设置与诊断</button></div>;
}
if (!activeAccount(selectedNode)) {
return <div className="connection-banner warning"><Icon name="alert" size={18} /><div><strong>当前 Client 尚未确认微信账号</strong><span>只读数据需要已验证的活动账号,先完成账号绑定后再操作。</span></div><button className="text-button" onClick={onSettings}>查看账号</button></div>;
}
return null;
}
function ContextEmpty({ title = "没有选中的 Client", text = "请先连接并选择一个可用 Client。", onSettings }) {
return <div className="context-empty"><div className="empty-icon"><Icon name="shield" size={28} /></div><h3>{title}</h3><p>{text}</p>{onSettings && <button className="secondary-button" onClick={onSettings}>打开设置与诊断</button>}</div>;
}
function LoadingState({ text = "正在读取…" }) {
return <div className="loading-state"><span className="loader" />{text}</div>;
}
function Empty({ text, action }) {
return <div className="empty-state"><div className="empty-icon"><Icon name="search" size={26} /></div><p>{text}</p>{action}</div>;
}
function ReadError({ error, onRetry }) {
return <div className="inline-error" role="alert"><Icon name="alert" size={17} /><span>{error}</span>{onRetry && <button className="text-button" onClick={onRetry}>重试</button>}</div>;
}
function ReadNotice({ text, onRetry }) {
return <div className="read-notice" role="status"><Icon name="alert" size={16} /><span>{text}</span>{onRetry && <button className="text-button" onClick={onRetry}>重试</button>}</div>;
}
function coverageNotice(label, coverage) {
if (!coverage) return "";
const freshness = coverage.lastSuccessAt ? `最后同步 ${formatTime(coverage.lastSuccessAt)}` : "尚未成功同步";
const backlog = coverage.backlogCount == null ? "积压未知" : `积压 ${coverage.backlogCount}`;
const suffix = `${freshness} · ${backlog}`;
if (coverage.state === "complete") return coverage.source === "platform-cache" ? `${label}来自平台副本,${suffix}。` : "";
if (coverage.state === "partial") return `${label}同步不完整,已保留已有数据;${suffix}。`;
return `${label}同步状态未知,未用本次结果清除已有数据;${suffix}${coverage.errorMessage ? ` · ${coverage.errorMessage}` : ""}。`;
}
function MessagesView({ token, client, onSettings }) {
const clientId = client?.node_id || "";
const accountId = activeAccount(client);
const [sessions, setSessions] = useState([]);
const [sessionsState, setSessionsState] = useState({ loading: false, error: "", notice: "" });
const [query, setQuery] = useState("");
const [selectedChat, setSelectedChat] = useState(null);
const [messages, setMessages] = useState([]);
const [messagesState, setMessagesState] = useState({ loading: false, error: "", notice: "" });
const contextGenerationRef = useRef(0);
const sessionsRequestRef = useRef(0);
const messagesRequestRef = useRef(0);
const refreshRequestRef = useRef(0);
const requestDataRefresh = useCallback(async () => {
if (!accountId || !clientId) return;
const requestId = ++refreshRequestRef.current;
try {
await api(dataPath(accountId, "refresh"), { token, method: "POST" });
} catch (reason) {
if (requestId === refreshRequestRef.current) {
setSessionsState((current) => ({ ...current, notice: current.notice || `后台同步未受理:${reason.message}` }));
}
}
}, [accountId, clientId, token]);
const loadSessions = useCallback(async () => {
if (!clientId || !accountId) return;
const requestId = ++sessionsRequestRef.current;
const generation = contextGenerationRef.current;
setSessionsState((current) => ({ ...current, loading: true, error: "" }));
try {
const content = await readStoredOrFallback({
storedPath: dataPath(accountId, "conversations", "?limit=100"),
legacyPath: "/v1/reads/sessions",
legacyBody: { node_id: clientId, account_id: accountId, limit: 100 },
token,
});
if (requestId !== sessionsRequestRef.current || generation !== contextGenerationRef.current) return;
const next = normalizeSessions(content);
if (content.sync && content.sync.state !== "complete") void requestDataRefresh();
const coverage = resolveReadCoverage(content, next.length);
setSessions((previous) => shouldReplaceReadState(coverage) ? next : mergeStableItems(previous, next));
setSessionsState({ loading: false, error: "", notice: coverageNotice("会话", coverage) });
} catch (reason) {
if (requestId === sessionsRequestRef.current && generation === contextGenerationRef.current) {
setSessionsState({ loading: false, error: reason.message, notice: "" });
}
}
}, [accountId, clientId, requestDataRefresh, token]);
useEffect(() => {
contextGenerationRef.current += 1;
sessionsRequestRef.current += 1;
messagesRequestRef.current += 1;
setSelectedChat(null);
setSessions([]);
setMessages([]);
setSessionsState({ loading: false, error: "", notice: "" });
setMessagesState({ loading: false, error: "", notice: "" });
if (!clientId || !accountId) return;
loadSessions();
}, [clientId, accountId, loadSessions]);
useEffect(() => {
if (!selectedChat || selectedChat.clientId !== clientId || selectedChat.accountId !== accountId || !clientId || !accountId) return;
const requestId = ++messagesRequestRef.current;
const generation = contextGenerationRef.current;
setMessagesState((current) => ({ ...current, loading: true, error: "" }));
readStoredOrFallback({
storedPath: dataPath(accountId, "messages", `?chat_id=${encodeURIComponent(selectedChat.id)}&limit=100`),
legacyPath: "/v1/reads/messages",
legacyBody: { node_id: clientId, account_id: accountId, chat_id: selectedChat.id, limit: 100, include_content: true },
token,
}).then((content) => {
if (requestId !== messagesRequestRef.current || generation !== contextGenerationRef.current) return;
const next = normalizeMessages(content);
const coverage = resolveReadCoverage(content, next.length);
setMessages((previous) => shouldReplaceReadState(coverage) ? next : mergeStableItems(previous, next));
setMessagesState({ loading: false, error: "", notice: coverageNotice("消息", coverage) });
})
.catch((reason) => {
if (requestId === messagesRequestRef.current && generation === contextGenerationRef.current) {
setMessagesState({ loading: false, error: reason.message, notice: "" });
}
});
}, [accountId, clientId, selectedChat, token]);
if (!client) return <ContextEmpty onSettings={onSettings} />;
const visibleSessions = sessions.filter((session) => !query || `${session.title} ${session.id}`.toLowerCase().includes(query.toLowerCase()));
return (
<div className="message-workspace">
<section className="session-panel">
<div className="section-heading compact"><div><h2>会话</h2><p>{sessions.length ? `${sessions.length} 个会话` : "当前 Client 的会话"}</p></div><button className="icon-button" onClick={loadSessions} disabled={sessionsState.loading} aria-label="刷新会话"><Icon name="refresh" size={16} /></button></div>
<label className="search-box"><Icon name="search" size={16} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索会话" /></label>
{sessionsState.loading && sessions.length === 0 ? <LoadingState text="正在读取会话…" /> : sessionsState.error && sessions.length === 0 ? <ReadError error={sessionsState.error} onRetry={loadSessions} /> : <>{sessionsState.error && <ReadNotice text={`读取会话失败,继续显示上次数据:${sessionsState.error}`} onRetry={loadSessions} />}{!sessionsState.error && sessionsState.notice && <ReadNotice text={sessionsState.notice} onRetry={loadSessions} />}{sessionsState.loading && sessions.length > 0 && <div className="refresh-hint">正在刷新,会话列表保持可用…</div>}{visibleSessions.length === 0 ? <Empty text="暂无会话,或当前账号还没有可见数据。" /> : <div className="session-list">{visibleSessions.map((session) => <button key={session.id} className={selectedChat?.id === session.id ? "session-row selected" : "session-row"} onClick={() => setSelectedChat({ ...session, clientId, accountId })}><span className="session-avatar">{session.title.slice(0, 1).toUpperCase()}</span><span className="session-copy"><strong>{session.title}</strong><small>{session.preview}</small></span>{session.unread > 0 && <b>{session.unread}</b>}</button>)}</div>}</>}
</section>
<section className="conversation-panel">
{!selectedChat ? <ContextEmpty title="选择一个会话" text="从左侧选择会话后,读取该 Client 的消息。" /> : <><div className="conversation-head"><div><p className="eyebrow">当前 Client · {clientId}</p><h2>{selectedChat.title}</h2><span>{selectedChat.type === "Group" ? "群聊" : "私聊"} · {shortId(selectedChat.id, 28)}</span></div><StatusBadge value={client.status} /></div>{messagesState.loading && messages.length === 0 ? <LoadingState text="正在读取消息…" /> : messagesState.error && messages.length === 0 ? <ReadError error={messagesState.error} onRetry={() => setSelectedChat({ ...selectedChat })} /> : <>{messagesState.error && <ReadNotice text={`读取消息失败,继续显示上次数据:${messagesState.error}`} onRetry={() => setSelectedChat({ ...selectedChat })} />}{!messagesState.error && messagesState.notice && <ReadNotice text={messagesState.notice} onRetry={() => setSelectedChat({ ...selectedChat })} />}{messagesState.loading && messages.length > 0 && <div className="refresh-hint">正在刷新,消息历史保持可用…</div>}{messages.length === 0 ? <Empty text="这个会话暂时没有可显示的消息。" /> : <div className="message-list">{messages.map((message) => <article className="message-row" key={message.id}><div className="message-avatar">{message.sender.slice(0, 1).toUpperCase()}</div><div><div className="message-meta"><strong>{message.sender}</strong><span>{formatTime(message.at)}</span></div><p>{message.text || "(无正文)"}</p></div></article>)}</div>}</>}<div className="composer"><textarea disabled rows={2} placeholder="发送消息暂未开放,需完成 Windows 真机验收" /><button className="primary-button" disabled title="写操作尚未通过真机验收">发送</button></div></>}
</section>
</div>
);
}
function BroadcastView({ client, onSettings }) {
return <section className="workspace-panel gated-panel"><div className="gated-mark"><Icon name="shield" size={24} /></div><div><p className="eyebrow">CONTROLLED ACTION</p><h2>受控群发验证</h2><p>单 Client 的 Client 端已提供 broadcast-text:冻结获准对象名单,逐项串行发送,返回每个对象的结果,并支持停止和幂等重放。</p><div className="step-list"><span><b>1</b>冻结对象</span><span><b>2</b>预览确认</span><span><b>3</b>逐项发送</span><span><b>4</b>查看结果</span></div><button className="primary-button" disabled>{client ? "Web 提交仍需单独验收" : "请先连接 Client"}</button><button className="text-button" onClick={onSettings}>查看 Client 验证状态 <Icon name="arrow" size={14} /></button></div></section>;
}
function ContactsView({ token, client, onSettings }) {
const clientId = client?.node_id || "";
const accountId = activeAccount(client);
const [groupsOnly, setGroupsOnly] = useState(false);
const [query, setQuery] = useState("");
const [contacts, setContacts] = useState([]);
const [syncInfo, setSyncInfo] = useState(null);
const [hasMore, setHasMore] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncNotice, setSyncNotice] = useState("");
const [state, setState] = useState({ loading: false, error: "" });
const requestRef = useRef(0);
const load = useCallback(async (append = false) => {
if (!clientId || !accountId) return;
const requestId = ++requestRef.current;
setState({ loading: true, error: "" });
try {
const params = new URLSearchParams({ limit: "200", offset: String(append ? contacts.length : 0), groups_only: String(groupsOnly) });
if (query.trim()) params.set("contains", query.trim());
const content = await api(dataPath(accountId, "contacts", `?${params.toString()}`), { token });
if (requestId !== requestRef.current) return;
const nextContacts = normalizeContacts(content);
setContacts((current) => append ? [...current, ...nextContacts] : nextContacts);
setHasMore(Boolean(content.has_more));
setSyncInfo(content.sync || null);
setState({ loading: false, error: "" });
} catch (reason) {
if (requestId === requestRef.current) setState({ loading: false, error: reason.message });
}
}, [accountId, clientId, contacts.length, groupsOnly, query, token]);
const syncContacts = async () => {
if (!clientId || !accountId || syncing) return;
setSyncing(true);
setSyncNotice("");
try {
await api(dataPath(accountId, "refresh"), { token, method: "POST", body: { stream_key: "contacts" } });
setSyncNotice("通讯录同步任务已提交;完成后点击“刷新缓存”查看最新数据。");
} catch (reason) {
setState((current) => ({ ...current, error: reason.message }));
} finally {
setSyncing(false);
}
};
useEffect(() => { setContacts([]); setHasMore(false); setSyncInfo(null); setSyncNotice(""); if (clientId && accountId) load(); }, [clientId, accountId, groupsOnly]);
if (!client) return <ContextEmpty onSettings={onSettings} />;
const visible = contacts.filter((contact) => !query || `${contact.title} ${contact.id} ${contact.detail}`.toLowerCase().includes(query.toLowerCase()));
return <section className="workspace-panel"><div className="section-heading"><div><h2>通讯录</h2><p>平台缓存 · {syncInfo?.state || "尚未同步"} · 最近同步 {formatTime(syncInfo?.last_success_at)}</p></div><div className="heading-actions"><label className="check-filter"><input type="checkbox" checked={groupsOnly} onChange={(event) => setGroupsOnly(event.target.checked)} />只看群聊</label><button className="secondary-button" onClick={syncContacts} disabled={syncing || state.loading}>{syncing ? "提交中…" : "同步通讯录"}</button><button className="secondary-button" disabled title="添加好友尚未通过真机验收">添加好友</button></div></div><div className="toolbar"><label className="search-box wide"><Icon name="search" size={16} /><input value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => event.key === "Enter" && load()} placeholder="搜索联系人或群聊" /></label><button className="secondary-button" onClick={load} disabled={state.loading}>{state.loading ? "读取中…" : "刷新缓存"}</button><button className="secondary-button" onClick={() => load(true)} disabled={state.loading || !hasMore}>加载更多</button>{syncNotice && <span className="read-only-note" role="status">{syncNotice}</span>}</div>{state.loading ? <LoadingState text="正在读取通讯录…" /> : state.error ? <ReadError error={state.error} onRetry={load} /> : visible.length === 0 ? <Empty text={syncInfo?.state === "unknown" ? "平台尚未缓存通讯录,请点击“同步通讯录”。" : "暂无可见联系人或群聊。"} /> : <div className="contact-list">{visible.map((contact) => <div className="contact-row" key={contact.id}><span className="contact-avatar">{contact.title.slice(0, 1).toUpperCase()}</span><div><strong>{contact.title}</strong><small>{contact.type === "Group" ? "群聊" : "联系人"} · {shortId(contact.id, 28)}{contact.detail ? ` · ${contact.detail}` : ""}</small></div><button className="text-button" disabled title="写操作尚未通过真机验收">添加好友</button></div>)}</div>}</section>;
}
function TasksView({ tasks, selectedClientId }) {
const [filter, setFilter] = useState("all");
const visible = tasks.filter((task) => task.node_id === selectedClientId && (filter === "all" || task.status === filter));
return <section className="workspace-panel"><div className="section-heading"><div><h2>任务结果</h2><p>当前 Client 的任务状态和安全边界。</p></div><div className="heading-actions"><span className="read-only-note"><Icon name="shield" size={14} />只读视图</span><select value={filter} onChange={(event) => setFilter(event.target.value)}><option value="all">全部状态</option><option value="Pending">待处理</option><option value="WaitingForClient">等待 Client</option><option value="Running">执行中</option><option value="Succeeded">已完成</option><option value="Failed">失败</option><option value="ResultUnconfirmed">待核对</option></select></div></div>{visible.length === 0 ? <Empty text="当前 Client 暂无任务记录。" /> : <div className="task-list">{visible.map((task) => <article className="task-row" key={task.task_id}><div className="task-main"><div className="task-title"><strong>{task.kind}</strong><StatusBadge value={task.status} /></div><p>{task.account_id} · {shortId(task.task_id, 22)}</p><small>{task.status === "WaitingForClient" ? "Client 离线后等待显式恢复;不会自动重放写操作。" : task.result?.message || task.result?.error_code || `更新于 ${formatTime(task.updated_at)}`}</small></div><div className="task-meta"><span>第 {task.lease_generation || 0} 代租约</span><time>{formatTime(task.updated_at)}</time></div></article>)}</div>}</section>;
}
function NodesTable({ nodes }) {
return <div className="diagnostic-list">{nodes.length === 0 ? <Empty text="暂无注册 Client。" /> : nodes.map((node) => <article className="diagnostic-row" key={node.node_id}><div className="diagnostic-title"><span className="node-avatar">{node.node_id.slice(0, 1).toUpperCase()}</span><div><strong>{node.node_id}</strong><small>{node.active_account_id || "尚未确认活动账号"} · 最近心跳 {formatTime(node.last_heartbeat_at)}</small></div></div><StatusBadge value={node.status} /></article>)}</div>;
}
function EventsTable({ events, selectedClientId }) {
const visible = events.filter((event) => !selectedClientId || event.node_id === selectedClientId);
return <div className="diagnostic-list">{visible.length === 0 ? <Empty text="当前 Client 暂无事件。" /> : visible.slice(0, 30).map((event) => <article className="diagnostic-row" key={event.event_id}><div><strong>{shortId(event.chat_id, 24)}</strong><small>{event.node_id} · {event.chat_type === "Group" ? "群聊" : "私聊"} · {formatTime(event.received_at)}</small></div><span className="event-preview">{event.content || "无正文"}</span></article>)}</div>;
}
function AuditTable({ audit }) {
return <div className="diagnostic-list">{audit.length === 0 ? <Empty text="暂无审计记录。" /> : audit.slice(0, 30).map((item) => <article className="diagnostic-row" key={item.id}><div><strong>{item.action}</strong><small>{item.principal} · {item.resource}</small></div><span>{formatTime(item.at)}</span></article>)}</div>;
}
function SettingsView({ nodes, events, audit, selectedClientId, onSelectClient }) {
const [tab, setTab] = useState("connection");
return <section className="settings-layout"><aside className="settings-nav"><button className={tab === "connection" ? "settings-tab active" : "settings-tab"} onClick={() => setTab("connection")}><strong>连接与账号</strong><span>选择 Client、确认状态</span></button><button className={tab === "diagnostics" ? "settings-tab active" : "settings-tab"} onClick={() => setTab("diagnostics")}><strong>高级诊断</strong><span>节点、事件、AI、审计</span></button></aside><div className="settings-content">{tab === "connection" ? <><div className="workspace-panel"><div className="section-heading"><div><h2>连接与账号</h2><p>每个 Client 独立连接,单个 Client 离线不会阻断其它 Client。</p></div><StatusBadge value={nodes.some(isClientAvailable) ? "Online" : "Offline"} /></div><NodesTable nodes={nodes} /></div><div className="workspace-panel"><div className="section-heading"><div><h2>安全边界</h2><p>当前版本只开放真实后端已支持且可验证的只读能力。</p></div></div><ul className="boundary-list"><li><Icon name="check" size={15} />消息、通讯录、任务结果按选定 Client 隔离读取</li><li><Icon name="check" size={15} />Client 掉线只影响当前上下文,任务不会自动改投</li><li><Icon name="alert" size={15} />发送、群发、添加好友等待 Windows 真机验收</li></ul></div></> : <><div className="workspace-panel"><div className="section-heading"><div><h2>Client 诊断</h2><p>技术状态只在这里展示,不干扰普通操作。</p></div></div><div className="client-pills">{nodes.map((node) => <button key={node.node_id} className={node.node_id === selectedClientId ? "client-pill active" : "client-pill"} onClick={() => onSelectClient(node.node_id)}><span>{node.node_id}</span><StatusBadge value={node.status} /></button>)}</div></div><div className="workspace-panel"><div className="section-heading"><div><h2>AI 能力</h2><p>AI 入口保留在高级诊断中;当前不自动调用,也不影响消息、通讯录和任务操作。</p></div><span className="read-only-note"><Icon name="shield" size={14} />按能力矩阵开放</span></div></div><div className="workspace-panel"><div className="section-heading"><div><h2>白名单事件</h2><p>当前选中 Client 的已授权事件。</p></div></div><EventsTable events={events} selectedClientId={selectedClientId} /></div><div className="workspace-panel"><div className="section-heading"><div><h2>操作审计</h2><p>用于定位连接、任务和权限问题。</p></div></div><AuditTable audit={audit} /></div></>}</div></section>;
}
function App() {
const [token, setToken] = useState(() => sessionStorage.getItem("wxagent-token") || "");
const [username, setUsername] = useState(() => sessionStorage.getItem("wxagent-user") || "");
const [view, setView] = useState("messages");
const [nodes, setNodes] = useState([]);
const [selectedClientId, setSelectedClientId] = useState(() => sessionStorage.getItem("wxagent-client") || "");
const [tasks, setTasks] = useState([]);
const [events, setEvents] = useState([]);
const [audit, setAudit] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const logout = useCallback(() => {
sessionStorage.removeItem("wxagent-token");
sessionStorage.removeItem("wxagent-user");
sessionStorage.removeItem("wxagent-client");
setToken("");
setUsername("");
}, []);
const refresh = useCallback(async () => {
if (!token) return;
setLoading(true);
setError("");
try {
const [nodeData, taskData, eventData, auditData] = await Promise.all([
api("/v1/nodes", { token }),
api("/v1/tasks?limit=200", { token }),
api("/v1/events?limit=200", { token }),
api("/v1/audit?limit=200", { token }),
]);
setNodes(nodeData.nodes || []);
setTasks(taskData.tasks || []);
setEvents(eventData.events || []);
setAudit(auditData.audit || []);
} catch (reason) {
if (reason.status === 401) logout();
else setError(reason.message);
} finally {
setLoading(false);
}
}, [logout, token]);
useEffect(() => {
refresh();
if (!token) return undefined;
const id = window.setInterval(refresh, 15000);
return () => window.clearInterval(id);
}, [refresh, token]);
useEffect(() => {
if (!nodes.length) {
setSelectedClientId("");
return;
}
if (!nodes.some((node) => node.node_id === selectedClientId)) {
const next = nodes.find(isClientAvailable) || nodes[0];
setSelectedClientId(next.node_id);
sessionStorage.setItem("wxagent-client", next.node_id);
}
}, [nodes, selectedClientId]);
const handleClientChange = (clientId) => {
setSelectedClientId(clientId);
sessionStorage.setItem("wxagent-client", clientId);
};
const handleLogin = (nextToken, nextUser) => {
sessionStorage.setItem("wxagent-token", nextToken);
sessionStorage.setItem("wxagent-user", nextUser);
setToken(nextToken);
setUsername(nextUser);
};
const selectedNode = useMemo(() => nodes.find((node) => node.node_id === selectedClientId), [nodes, selectedClientId]);
const nav = NAV_ITEMS.find((item) => item.id === view);
const title = nav?.label || "设置与诊断";
const subtitle = nav?.description || "连接、账号和高级诊断信息";
if (!token) return <Login onLogin={handleLogin} />;
return <div className="app-shell"><Sidebar view={view} setView={setView} onLogout={logout} username={username} /><main className="main-area"><Topbar title={title} subtitle={subtitle} nodes={nodes} selectedClientId={selectedClientId} onClientChange={handleClientChange} onRefresh={refresh} loading={loading} />{error && <div className="global-error" role="alert"><Icon name="alert" size={16} /><span>{error}</span><button onClick={() => setError("")} aria-label="关闭错误"><Icon name="close" size={16} /></button></div>}<ConnectionBanner nodes={nodes} selectedNode={selectedNode} onSettings={() => setView("settings")} /><div className="page-content">{view === "messages" && <MessagesView token={token} client={selectedNode} onSettings={() => setView("settings")} />}{view === "broadcast" && <BroadcastView client={selectedNode} onSettings={() => setView("settings")} />}{view === "contacts" && <ContactsView token={token} client={selectedNode} onSettings={() => setView("settings")} />}{view === "tasks" && <TasksView tasks={tasks} selectedClientId={selectedClientId} />}{view === "settings" && <SettingsView nodes={nodes} events={events} audit={audit} selectedClientId={selectedClientId} onSelectClient={handleClientChange} />}</div><footer className="footer"><span>WxAgent 工作台 · Client 独立隔离</span><span>写操作需通过真实能力与真机验收</span></footer></main></div>;
}
createRoot(document.getElementById("root")).render(<App />);
createRoot(document.getElementById("root")).render(
<ConfigProvider
locale={zhCN}
csp={cspNonce && cspNonce !== "__WX_CSP_NONCE__" ? { nonce: cspNonce } : undefined}
theme={{ token: { colorPrimary: "#1677ff", borderRadius: 8, fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" } }}
>
<AntApp>
<ConsoleApp />
</AntApp>
</ConfigProvider>,
);
+1 -1
View File
@@ -1,4 +1,4 @@
function extractItems(content, keys) {
export function extractItems(content, keys) {
if (Array.isArray(content)) return content;
for (const key of keys) {
if (Array.isArray(content?.[key])) return content[key];
+6
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
extractItems,
mergeStableItems,
normalizeMessages,
normalizeSessions,
@@ -8,6 +9,11 @@ import {
shouldReplaceReadState,
} from "./readState.js";
test("contact API payloads expose their contact list", () => {
const contacts = [{ contact_id: "wxid-example" }];
assert.deepEqual(extractItems({ contacts }, ["items", "contacts"]), contacts);
});
test("legacy empty reads are treated as unknown, not complete", () => {
const coverage = resolveReadCoverage({ items: [] }, 0);
assert.equal(coverage.state, "unknown");
File diff suppressed because it is too large Load Diff
+206
View File
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { App as AntApp, Alert, Avatar, Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Switch, Tag, Typography } from "antd";
import { ProTable } from "@ant-design/pro-components";
import { DesktopOutlined, KeyOutlined, PlusOutlined, ReloadOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
import { api, formatTime, shortId, statusColor, statusLabel } from "../api.js";
const { Text } = Typography;
export function UsersView({ token, currentUser, nodes, onChanged }) {
const { message } = AntApp.useApp();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [resetTarget, setResetTarget] = useState(null);
const [form] = Form.useForm();
const [passwordForm] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
const result = await api("/v1/users", { token });
setUsers(result.users || []);
} catch (reason) {
message.error(reason.message);
} finally {
setLoading(false);
}
}, [message, token]);
useEffect(() => { void load(); }, [load]);
const nodeNames = useMemo(() => new Map(nodes.map((node) => [node.node_id, node])), [nodes]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ role: "member", active: true, node_ids: [] });
setDrawerOpen(true);
};
const openEdit = (user) => {
setEditing(user);
form.resetFields();
form.setFieldsValue({
username: user.username,
display_name: user.display_name,
role: user.role,
active: user.active,
node_ids: user.node_ids || [],
});
setDrawerOpen(true);
};
const save = async (values) => {
const body = {
username: values.username,
display_name: values.display_name,
role: values.role,
active: values.active,
node_ids: values.role === "member" ? values.node_ids || [] : [],
};
try {
if (editing) {
await api(`/v1/users/${encodeURIComponent(editing.id)}`, { token, method: "PATCH", body: {
display_name: body.display_name, role: body.role, active: body.active, node_ids: body.node_ids,
} });
} else {
await api("/v1/users", { token, method: "POST", body: { ...body, password: values.password } });
}
message.success(editing ? "用户权限已更新" : "用户已创建");
setDrawerOpen(false);
await load();
onChanged?.();
} catch (reason) {
message.error(reason.message);
}
};
const toggleActive = async (user, active) => {
try {
await api(`/v1/users/${encodeURIComponent(user.id)}`, { token, method: "PATCH", body: {
display_name: user.display_name, role: user.role, active, node_ids: user.node_ids || [],
} });
message.success(active ? "用户已启用" : "用户已停用,现有会话已撤销");
await load();
} catch (reason) {
message.error(reason.message);
}
};
const resetPassword = async (values) => {
try {
await api(`/v1/users/${encodeURIComponent(resetTarget.id)}/password`, { token, method: "POST", body: { password: values.password } });
message.success("密码已重置,该用户的现有会话已撤销");
setResetTarget(null);
passwordForm.resetFields();
} catch (reason) {
message.error(reason.message);
}
};
const columns = [
{ title: "用户", dataIndex: "username", key: "user", render: (_, user) => <Space><Avatar size="small" icon={<UserOutlined />}>{user.display_name?.slice(0, 1)}</Avatar><span><Text strong>{user.display_name}</Text><br /><Text type="secondary">{user.username}</Text></span></Space> },
{ title: "角色", dataIndex: "role", width: 130, render: (role) => <Tag color={role === "admin" ? "blue" : "default"}>{role === "admin" ? "管理员" : "成员"}</Tag> },
{ title: "可访问 Desktop Agent / Node", dataIndex: "node_ids", render: (ids, user) => user.role === "admin" ? <Text type="secondary">全部 Node</Text> : ids?.length ? <Space wrap>{ids.map((id) => <Tag key={id} color={nodeNames.has(id) ? "geekblue" : "default"}>{id}</Tag>)}</Space> : <Text type="secondary">尚未分配</Text> },
{ title: "状态", dataIndex: "active", width: 100, render: (active) => <Tag color={active ? "success" : "default"}>{active ? "启用" : "停用"}</Tag> },
{ title: "创建时间", dataIndex: "created_at", width: 175, render: formatTime },
{ title: "操作", key: "actions", width: 230, valueType: "option", render: (_, user) => <Space size={4}>
<Button type="link" size="small" onClick={() => openEdit(user)}>编辑与分配</Button>
<Button type="link" size="small" icon={<KeyOutlined />} onClick={() => { setResetTarget(user); passwordForm.resetFields(); }}>重置密码</Button>
<Popconfirm title={user.active ? "停用此账号?" : "重新启用此账号?"} description={user.active ? "该用户的现有会话将被撤销。" : undefined} onConfirm={() => void toggleActive(user, !user.active)} disabled={user.id === currentUser.id}>
<Button type="link" size="small" disabled={user.id === currentUser.id}>{user.active ? "停用" : "启用"}</Button>
</Popconfirm>
</Space> },
];
return (
<div className="admin-page-stack">
<Alert type="info" showIcon message="用户与 Node 权限使用真实控制面数据库" description="管理员拥有全部 Node 访问权;成员只可访问明确分配的 Desktop Agent / Node。环境变量中的旧 Web 账号仅在用户表首次初始化时导入为管理员。" />
<ProTable
rowKey="id"
headerTitle={`控制面用户 · ${users.length}`}
columns={columns}
dataSource={users}
loading={loading}
search={false}
options={false}
scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 500px))" }}
pagination={{ pageSize: 10, showSizeChanger: true }}
toolBarRender={() => [<Button key="reload" icon={<ReloadOutlined />} onClick={() => void load()}>刷新</Button>, <Button key="create" type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建用户</Button>]}
/>
<Drawer title={editing ? "编辑用户与 Node 分配" : "创建控制面用户"} width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)} destroyOnClose>
<Form form={form} layout="vertical" onFinish={save} requiredMark="optional">
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3, max: 64, pattern: /^[A-Za-z0-9._@-]+$/, message: "使用 3–64 位字母、数字或 . _ @ -" }]}>
<Input disabled={Boolean(editing)} autoComplete="username" />
</Form.Item>
<Form.Item name="display_name" label="显示名称" rules={[{ required: true, max: 120 }]}><Input maxLength={120} /></Form.Item>
{!editing && <Form.Item name="password" label="初始密码" rules={[{ required: true, min: 12, message: "密码至少 12 个字符" }]}><Input.Password autoComplete="new-password" /></Form.Item>}
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={[{ value: "member", label: "成员(按分配 Node 隔离)" }, { value: "admin", label: "管理员(可管理用户并访问全部 Node)" }]} disabled={editing?.id === currentUser.id} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(previous, next) => previous.role !== next.role}>
{({ getFieldValue }) => getFieldValue("role") === "member" ? (
<Form.Item name="node_ids" label="分配 Desktop Agent / Node">
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="选择此成员可访问的 Node" options={nodes.map((node) => ({ value: node.node_id, label: `${node.node_id} · ${statusLabel(node.status)}` }))} />
</Form.Item>
) : <Alert type="info" showIcon message="管理员可访问全部 Node;管理员账号不使用成员分配列表。" />}
</Form.Item>
{editing && <Form.Item name="active" label="账号状态" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" disabled={editing.id === currentUser.id} /></Form.Item>}
<div className="drawer-footer"><Button onClick={() => setDrawerOpen(false)}>取消</Button><Button type="primary" htmlType="submit">{editing ? "保存变更" : "创建用户"}</Button></div>
</Form>
</Drawer>
<Modal title={`重置密码 · ${resetTarget?.username || ""}`} open={Boolean(resetTarget)} onCancel={() => setResetTarget(null)} onOk={() => passwordForm.submit()} okText="重置并撤销会话" destroyOnClose>
<Form form={passwordForm} layout="vertical" onFinish={resetPassword}>
<Form.Item name="password" label="新密码" rules={[{ required: true, min: 12, message: "密码至少 12 个字符" }]}><Input.Password autoComplete="new-password" /></Form.Item>
</Form>
</Modal>
</div>
);
}
export function NodesView({ token, nodes, isAdmin, onGoUsers }) {
const { message } = AntApp.useApp();
const [users, setUsers] = useState([]);
const [loadingUsers, setLoadingUsers] = useState(false);
useEffect(() => {
if (!isAdmin) return;
let current = true;
setLoadingUsers(true);
api("/v1/users", { token }).then((data) => { if (current) setUsers(data.users || []); }).catch((reason) => message.error(reason.message)).finally(() => { if (current) setLoadingUsers(false); });
return () => { current = false; };
}, [isAdmin, message, token]);
const assignedUsers = useMemo(() => {
const names = new Map();
for (const user of users) {
if (user.role !== "member" || !user.active) continue;
for (const nodeID of user.node_ids || []) names.set(nodeID, [...(names.get(nodeID) || []), user.display_name || user.username]);
}
return names;
}, [users]);
const columns = [
{ title: "Desktop Agent / Node", dataIndex: "node_id", key: "node_id", render: (id) => <Space><Avatar size="small" icon={<DesktopOutlined />} /> <Text strong>{id}</Text></Space> },
{ title: "连接状态", dataIndex: "status", width: 130, render: (status) => <Tag color={statusColor(status)}>{statusLabel(status)}</Tag> },
{ title: "活动账号", dataIndex: "active_account_id", render: (id) => id || <Text type="secondary">未确认</Text> },
{ title: "最近心跳", dataIndex: "last_heartbeat_at", width: 180, render: formatTime },
{ title: "Agent 版本", dataIndex: "agent_version", width: 150, render: (value) => value || "—" },
{ title: "账号", dataIndex: "accounts", width: 90, render: (accounts) => accounts?.length || 0 },
...(isAdmin ? [{ title: "已分配成员", dataIndex: "node_id", render: (nodeID) => loadingUsers ? "读取中…" : (assignedUsers.get(nodeID)?.join("、") || <Text type="secondary">未分配</Text>) }] : []),
];
return (
<div className="admin-page-stack">
<Alert type="info" showIcon message="这里管理的是已连接的 Desktop Agent / Node" description="成员分配按 Node 生效,并同时约束该 Node 下已授权微信账号的消息、通讯录、事件与任务接口。AI Flow 不是本页面中的 Desktop Agent。" />
<ProTable
rowKey="node_id"
headerTitle={`已授权节点 · ${nodes.length}`}
columns={columns}
dataSource={nodes}
search={false}
options={false}
scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 500px))" }}
pagination={{ pageSize: 10 }}
toolBarRender={isAdmin ? () => [<Button key="users" icon={<TeamOutlined />} onClick={onGoUsers}>管理用户分配</Button>] : false}
/>
</div>
);
}
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar, Badge, Button, Empty, Input, Space, Spin, Tag, Typography } from "antd";
import { Bubble, Conversations, Sender } from "@ant-design/x";
import { ReloadOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
import {
activeAccount,
api,
coverageNotice,
dataPath,
formatTime,
isClientAvailable,
mergeStableItems,
normalizeMessages,
normalizeSessions,
readStoredOrFallback,
resolveReadCoverage,
shortId,
shouldReplaceReadState,
statusColor,
statusLabel,
} from "../api.js";
const { Text } = Typography;
function ReadState({ loading, error, notice, emptyText, onRetry }) {
if (loading) return <div className="workspace-state"><Spin /><Text type="secondary">正在读取…</Text></div>;
if (error) return <div className="workspace-state"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={error} /><Button type="link" onClick={onRetry}>重试</Button></div>;
if (notice) return <div className="read-coverage-note"><Text type="secondary">{notice}</Text><Button type="link" size="small" onClick={onRetry}>刷新</Button></div>;
return <div className="workspace-state"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} /></div>;
}
export default function MessagesView({ token, client, onSettings }) {
const clientId = client?.node_id || "";
const accountId = activeAccount(client);
const [sessions, setSessions] = useState([]);
const [sessionsState, setSessionsState] = useState({ loading: false, error: "", notice: "" });
const [query, setQuery] = useState("");
const [selectedChat, setSelectedChat] = useState(null);
const [messages, setMessages] = useState([]);
const [messagesState, setMessagesState] = useState({ loading: false, error: "", notice: "" });
const contextGenerationRef = useRef(0);
const sessionsRequestRef = useRef(0);
const messagesRequestRef = useRef(0);
const refreshRequestRef = useRef(0);
const historyRef = useRef(null);
const requestDataRefresh = useCallback(async () => {
if (!accountId || !clientId) return;
const requestId = ++refreshRequestRef.current;
try {
await api(dataPath(accountId, "refresh"), { token, method: "POST" });
} catch (reason) {
if (requestId === refreshRequestRef.current) {
setSessionsState((current) => ({ ...current, notice: current.notice || `后台同步未受理:${reason.message}` }));
}
}
}, [accountId, clientId, token]);
const loadSessions = useCallback(async () => {
if (!clientId || !accountId) return;
const requestId = ++sessionsRequestRef.current;
const generation = contextGenerationRef.current;
setSessionsState((current) => ({ ...current, loading: true, error: "" }));
try {
const content = await readStoredOrFallback({
storedPath: dataPath(accountId, "conversations", "?limit=100"),
legacyPath: "/v1/reads/sessions",
legacyBody: { node_id: clientId, account_id: accountId, limit: 100 },
token,
});
if (requestId !== sessionsRequestRef.current || generation !== contextGenerationRef.current) return;
const next = normalizeSessions(content);
if (content.sync && content.sync.state !== "complete") void requestDataRefresh();
const coverage = resolveReadCoverage(content, next.length);
setSessions((previous) => shouldReplaceReadState(coverage) ? next : mergeStableItems(previous, next));
setSessionsState({ loading: false, error: "", notice: coverageNotice("会话", coverage) });
} catch (reason) {
if (requestId === sessionsRequestRef.current && generation === contextGenerationRef.current) {
setSessionsState({ loading: false, error: reason.message, notice: "" });
}
}
}, [accountId, clientId, requestDataRefresh, token]);
useEffect(() => {
contextGenerationRef.current += 1;
sessionsRequestRef.current += 1;
messagesRequestRef.current += 1;
setSelectedChat(null);
setSessions([]);
setMessages([]);
setSessionsState({ loading: false, error: "", notice: "" });
setMessagesState({ loading: false, error: "", notice: "" });
if (clientId && accountId) void loadSessions();
}, [clientId, accountId, loadSessions]);
useEffect(() => {
if (!selectedChat || selectedChat.clientId !== clientId || selectedChat.accountId !== accountId || !clientId || !accountId) return;
const requestId = ++messagesRequestRef.current;
const generation = contextGenerationRef.current;
setMessagesState((current) => ({ ...current, loading: true, error: "" }));
readStoredOrFallback({
storedPath: dataPath(accountId, "messages", `?chat_id=${encodeURIComponent(selectedChat.id)}&limit=100`),
legacyPath: "/v1/reads/messages",
legacyBody: { node_id: clientId, account_id: accountId, chat_id: selectedChat.id, limit: 100, include_content: true },
token,
}).then((content) => {
if (requestId !== messagesRequestRef.current || generation !== contextGenerationRef.current) return;
const next = normalizeMessages(content);
const coverage = resolveReadCoverage(content, next.length);
setMessages((previous) => shouldReplaceReadState(coverage) ? next : mergeStableItems(previous, next));
setMessagesState({ loading: false, error: "", notice: coverageNotice("消息", coverage) });
}).catch((reason) => {
if (requestId === messagesRequestRef.current && generation === contextGenerationRef.current) {
setMessagesState({ loading: false, error: reason.message, notice: "" });
}
});
}, [accountId, clientId, selectedChat, token]);
useEffect(() => {
if (historyRef.current) historyRef.current.scrollTop = historyRef.current.scrollHeight;
}, [messages]);
const visibleSessions = useMemo(() => sessions.filter((session) =>
!query || `${session.title} ${session.id} ${session.preview}`.toLowerCase().includes(query.toLowerCase())), [query, sessions]);
const conversationItems = useMemo(() => visibleSessions.map((session, index) => ({
key: session.id,
group: "最近会话",
timestamp: Date.now() - index * 60_000,
icon: <Avatar size={34} icon={session.type === "Group" ? <TeamOutlined /> : <UserOutlined />}>{session.title?.slice(0, 1)}</Avatar>,
label: <div className="conversation-item-label"><div className="conversation-item-title"><Text ellipsis>{session.title}</Text>{session.unread > 0 && <Badge count={session.unread} size="small" />}</div><Text type="secondary" ellipsis>{session.preview || "暂无最近消息"}</Text></div>,
})), [visibleSessions]);
if (!client) {
return <Empty className="page-empty" image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前账号尚未分配可访问的 Desktop Agent / Node。" />;
}
const activeSession = sessions.find((session) => session.id === selectedChat?.id);
const selectConversation = (key) => {
const session = sessions.find((item) => item.id === key);
if (session) setSelectedChat({ ...session, clientId, accountId });
};
const retryMessages = () => selectedChat && setSelectedChat({ ...selectedChat });
return (
<div className="messages-layout">
<section className="conversation-rail">
<div className="conversation-rail-header">
<div><Text strong>会话</Text><Text type="secondary">{sessions.length} 个</Text></div>
<Button type="text" icon={<ReloadOutlined />} aria-label="刷新会话" loading={sessionsState.loading} onClick={() => void loadSessions()} />
</div>
<Input allowClear prefix={<SearchOutlined />} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索会话" />
{sessionsState.loading && sessions.length === 0 ? <ReadState loading /> : sessionsState.error && sessions.length === 0 ? <ReadState error={sessionsState.error} onRetry={loadSessions} /> : (
<>
{sessionsState.error && <div className="compact-alert"><Text type="danger">读取失败,保留已有数据:{sessionsState.error}</Text><Button type="link" size="small" onClick={() => void loadSessions()}>重试</Button></div>}
{!sessionsState.error && sessionsState.notice && <div className="compact-alert"><Text type="secondary">{sessionsState.notice}</Text></div>}
{sessionsState.loading && sessions.length > 0 && <Text className="refresh-caption" type="secondary">正在刷新,会话列表保持可用…</Text>}
{visibleSessions.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可见会话" /> : (
<Conversations items={conversationItems} activeKey={selectedChat?.id} onActiveChange={selectConversation} className="conversation-list" />
)}
</>
)}
</section>
<section className="conversation-main">
{!selectedChat ? (
<div className="conversation-placeholder"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="选择一个会话,查看已授权的消息历史" /></div>
) : (
<>
<div className="chat-header">
<Avatar size={40} icon={activeSession?.type === "Group" ? <TeamOutlined /> : <UserOutlined />}>{activeSession?.title?.slice(0, 1)}</Avatar>
<div className="chat-heading"><Text strong>{activeSession?.title}</Text><Text type="secondary">{activeSession?.type === "Group" ? "群聊" : "私聊"} · {shortId(selectedChat.id, 28)}</Text></div>
<Tag color={statusColor(client.status)}>{statusLabel(client.status)}</Tag>
</div>
{messagesState.loading && messages.length === 0 ? <ReadState loading /> : messagesState.error && messages.length === 0 ? <ReadState error={messagesState.error} onRetry={retryMessages} /> : (
<>
{messagesState.error && <div className="compact-alert"><Text type="danger">读取失败,保留已有数据:{messagesState.error}</Text><Button type="link" size="small" onClick={retryMessages}>重试</Button></div>}
{!messagesState.error && messagesState.notice && <div className="compact-alert"><Text type="secondary">{messagesState.notice}</Text></div>}
{messagesState.loading && messages.length > 0 && <Text className="refresh-caption" type="secondary">正在刷新,消息历史保持可用…</Text>}
{messages.length === 0 ? <div className="conversation-placeholder"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="这个会话暂时没有可显示的消息" /></div> : (
<div className="chat-history" ref={historyRef}>
{messages.map((item) => {
const sender = item.sender || "未知";
const fromCurrentUser = item.is_self === true || item.direction === "outgoing" || sender === "我";
return <Bubble key={item.id} placement={fromCurrentUser ? "end" : "start"} avatar={<Avatar size={30}>{sender.slice(0, 1)}</Avatar>} header={<Space size={8}><Text strong>{sender}</Text><Text type="secondary">{formatTime(item.at)}</Text></Space>} content={item.text || "(无正文)"} />;
})}
</div>
)}
</>
)}
<div className="composer-notice"><Text type="secondary">发送功能尚未开放,需完成 Windows 真机验收。</Text></div>
<Sender disabled placeholder="发送消息暂未开放" />
</>
)}
</section>
</div>
);
}
@@ -0,0 +1,200 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { App as AntApp, Alert, Button, Card, Empty, Input, Select, Space, Statistic, Table, Tabs, Tag, Typography } from "antd";
import { ProCard, ProTable } from "@ant-design/pro-components";
import {
CheckCircleOutlined,
DesktopOutlined,
MessageOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SearchOutlined,
TeamOutlined,
UnorderedListOutlined,
WarningOutlined,
} from "@ant-design/icons";
import { api, activeAccount, dataPath, formatTime, isClientAvailable, normalizeContacts, shortId, statusColor, statusLabel } from "../api.js";
const { Text, Title } = Typography;
export function OverviewView({ nodes, tasks, events, onNavigate, onRefresh, refreshing }) {
const online = nodes.filter(isClientAvailable).length;
const pending = tasks.filter((task) => ["Pending", "WaitingForClient", "Accepted", "Running"].includes(task.status)).length;
const failed = tasks.filter((task) => ["Failed", "ResultUnconfirmed"].includes(task.status)).length;
const recent = [...events].sort((a, b) => new Date(b.received_at || 0) - new Date(a.received_at || 0)).slice(0, 5);
return (
<div className="overview-stack">
<div className="overview-welcome">
<div><Title level={3}>实时运行状态</Title><Text type="secondary">真实连接状态 · 按用户分配范围隔离 · 管理操作留有审计记录</Text></div>
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={onRefresh}>刷新概览</Button>
</div>
<div className="overview-metric-grid">
<Card bordered><Statistic title="可用 Desktop Agent" value={online} suffix={`/ ${nodes.length}`} prefix={<DesktopOutlined />} /></Card>
<Card bordered><Statistic title="当前待处理任务" value={pending} prefix={<UnorderedListOutlined />} /></Card>
<Card bordered><Statistic title="待核对 / 失败" value={failed} prefix={<WarningOutlined />} valueStyle={{ color: failed ? "#cf1322" : undefined }} /></Card>
<Card bordered><Statistic title="近期授权事件" value={events.length} prefix={<MessageOutlined />} /></Card>
</div>
<div className="overview-primary-grid">
<ProCard title="Desktop Agent 连接" className="overview-agent-card" bordered extra={<Button type="link" onClick={() => onNavigate("agents")}>查看全部</Button>}>
{nodes.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有可访问的 Desktop Agent / Node" /> : (
<div className="overview-node-list">{nodes.slice(0, 6).map((node) => <div key={node.node_id} className="overview-node-row"><span className="node-mark"><DesktopOutlined /></span><span className="overview-node-copy"><Text strong>{node.node_id}</Text><Text type="secondary">{node.active_account_id || "尚未确认活动账号"}</Text></span><Tag color={statusColor(node.status)}>{statusLabel(node.status)}</Tag></div>)}</div>
)}
</ProCard>
<ProCard title="快速入口" className="overview-shortcuts-card" bordered>
<div className="quick-entry-grid">
<Button icon={<MessageOutlined />} onClick={() => onNavigate("messages")}>消息工作台</Button>
<Button icon={<TeamOutlined />} onClick={() => onNavigate("agents")}>Desktop Agent</Button>
<Button icon={<UnorderedListOutlined />} onClick={() => onNavigate("tasks")}>任务中心</Button>
<Button icon={<SafetyCertificateOutlined />} onClick={() => onNavigate("contacts")}>通讯录</Button>
</div>
<Alert className="overview-boundary" type="info" showIcon message="权限由服务端强制执行" description="界面筛选只用于展示;节点列表、任务、事件和账号数据 API 都按用户分配范围校验。" />
</ProCard>
</div>
<ProCard title="最近授权事件" bordered extra={<Text type="secondary">最多展示 5 条</Text>}>
{recent.length ? <div className="event-preview-list">{recent.map((event) => <div key={event.event_id} className="event-preview-row"><span className="event-dot" /><div><Text strong>{event.chat_id || event.event_type || "授权事件"}</Text><Text type="secondary">{event.node_id} · {event.chat_type === "Group" ? "群聊" : "私聊"}</Text></div><Text type="secondary">{formatTime(event.received_at)}</Text></div>)}</div> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无事件" />}
</ProCard>
</div>
);
}
export function ContactsView({ token, client }) {
const { message } = AntApp.useApp();
const clientId = client?.node_id || "";
const accountId = activeAccount(client);
const [groupsOnly, setGroupsOnly] = useState(false);
const [query, setQuery] = useState("");
const [contacts, setContacts] = useState([]);
const [syncInfo, setSyncInfo] = useState(null);
const [hasMore, setHasMore] = useState(false);
const [syncing, setSyncing] = useState(false);
const [state, setState] = useState({ loading: false, error: "" });
const [requestVersion, setRequestVersion] = useState(0);
const load = useCallback(async (append = false) => {
if (!clientId || !accountId) return;
setState({ loading: true, error: "" });
try {
const params = new URLSearchParams({ limit: "200", offset: String(append ? contacts.length : 0), groups_only: String(groupsOnly) });
if (query.trim()) params.set("contains", query.trim());
const content = await api(dataPath(accountId, "contacts", `?${params.toString()}`), { token });
const next = normalizeContacts(content);
setContacts((current) => append ? [...current, ...next] : next);
setHasMore(Boolean(content.has_more));
setSyncInfo(content.sync || null);
setState({ loading: false, error: "" });
} catch (reason) {
setState({ loading: false, error: reason.message });
}
}, [accountId, clientId, contacts.length, groupsOnly, query, token]);
useEffect(() => {
setContacts([]);
setHasMore(false);
setSyncInfo(null);
if (clientId && accountId) void load();
}, [clientId, accountId, groupsOnly, requestVersion]);
const syncContacts = async () => {
if (!clientId || !accountId || syncing) return;
setSyncing(true);
try {
await api(dataPath(accountId, "refresh"), { token, method: "POST", body: { stream_key: "contacts" } });
message.success("通讯录同步任务已提交;完成后刷新缓存查看最新数据。");
} catch (reason) {
message.error(reason.message);
} finally {
setSyncing(false);
}
};
const visible = contacts.filter((contact) => !query || `${contact.title} ${contact.id} ${contact.detail}`.toLowerCase().includes(query.toLowerCase()));
const columns = [
{ title: "联系人 / 群聊", dataIndex: "title", render: (_, contact) => <Space><span className="contact-avatar">{contact.title?.slice(0, 1)}</span><span><Text strong>{contact.title}</Text><br /><Text type="secondary">{contact.type === "Group" ? "群聊" : "联系人"}</Text></span></Space> },
{ title: "会话 ID", dataIndex: "id", render: (id) => <Text code>{shortId(id, 32)}</Text> },
{ title: "备注 / 账号", dataIndex: "detail", render: (value) => value || "—" },
];
if (!client) return <Empty className="page-empty" image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前没有可访问的 Desktop Agent / Node" />;
return (
<div className="page-stack">
<ProCard bordered title="通讯录缓存" subTitle={`${clientId} · ${accountId || "未确认活动账号"}`} extra={<Tag color={syncInfo?.state === "complete" ? "success" : "default"}>{syncInfo?.state || "尚未同步"}</Tag>}>
<div className="table-toolbar">
<Input.Search allowClear value={query} onChange={(event) => setQuery(event.target.value)} onSearch={() => void load()} placeholder="搜索联系人或群聊" style={{ maxWidth: 360 }} />
<Space wrap>
<Select value={groupsOnly} onChange={setGroupsOnly} options={[{ value: false, label: "全部联系人" }, { value: true, label: "只看群聊" }]} style={{ width: 145 }} />
<Button onClick={syncContacts} loading={syncing}>同步通讯录</Button>
<Button icon={<ReloadOutlined />} onClick={() => setRequestVersion((value) => value + 1)} loading={state.loading}>刷新缓存</Button>
<Button disabled title="写操作尚未通过 Windows 真机验收">添加好友</Button>
</Space>
</div>
{state.error && <Alert type="error" showIcon message={state.error} action={<Button type="link" onClick={() => void load()}>重试</Button>} />}
<ProTable rowKey="id" columns={columns} dataSource={visible} loading={state.loading} search={false} options={false} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 520px))" }} pagination={false} toolBarRender={false} />
<div className="table-footer"><Text type="secondary">最近同步:{formatTime(syncInfo?.last_success_at)} · {syncInfo?.backlog_count == null ? "积压未知" : `积压 ${syncInfo.backlog_count}`}</Text><Button type="link" disabled={!hasMore || state.loading} onClick={() => void load(true)}>加载更多</Button></div>
</ProCard>
</div>
);
}
export function TasksView({ tasks, selectedNodeId }) {
const [filter, setFilter] = useState("all");
const filtered = tasks.filter((task) => task.node_id === selectedNodeId && (filter === "all" || task.status === filter));
const columns = [
{ title: "任务类型", dataIndex: "kind", width: 170, render: (value) => <Text strong>{value}</Text> },
{ title: "状态", dataIndex: "status", width: 140, render: (value) => <Tag color={statusColor(value)}>{statusLabel(value)}</Tag> },
{ title: "账号 / Node", dataIndex: "account_id", render: (value, task) => <span>{value || "—"}<br /><Text type="secondary">{task.node_id}</Text></span> },
{ title: "任务 ID", dataIndex: "task_id", render: (value) => <Text code>{shortId(value, 24)}</Text> },
{ title: "执行说明", dataIndex: "result", render: (result, task) => task.status === "WaitingForClient" ? "Client 离线后等待显式恢复;不会自动重放写操作。" : result?.message || result?.error_code || "—" },
{ title: "更新时间", dataIndex: "updated_at", width: 175, render: formatTime },
];
if (!selectedNodeId) return <Empty className="page-empty" image={Empty.PRESENTED_IMAGE_SIMPLE} description="请先选择一个可访问的 Desktop Agent / Node" />;
return <div className="page-stack table-view"><ProTable rowKey="task_id" headerTitle={`任务结果 · ${filtered.length}`} columns={columns} dataSource={filtered} search={false} options={false} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 480px))" }} pagination={{ pageSize: 10 }} toolBarRender={() => [<Select key="status" value={filter} onChange={setFilter} style={{ width: 170 }} options={[{ value: "all", label: "全部状态" }, ...["Pending", "WaitingForClient", "Accepted", "Running", "Succeeded", "Failed", "ResultUnconfirmed"].map((value) => ({ value, label: statusLabel(value) }))]} />]} /></div>;
}
export function BroadcastView({ client }) {
return (
<div className="page-stack">
<Alert type="warning" showIcon message="Web 群发尚未开放" description="Desktop Agent 能力不等于 Web 写操作已验收。当前版本保留入口说明,不会创建或发送群发任务。" />
<ProCard title="受控群发验收门槛" bordered>
<Space direction="vertical" size="middle">
<Text>当前上下文:{client?.node_id || "未选择 Node"} · {activeAccount(client) || "未确认活动账号"}</Text>
<Text type="secondary">冻结获准对象名单 → 预览确认 → 串行执行 → 查看逐项结果。消息发送仍需在 Windows 已登录会话完成真机验收。</Text>
<Space wrap><Tag color="success" icon={<CheckCircleOutlined />}>范围约束</Tag><Tag color="processing">任务 API 不自动重放</Tag><Tag color="warning">等待 Web 验收</Tag></Space>
<Button type="primary" disabled>创建群发任务</Button>
</Space>
</ProCard>
</div>
);
}
export function DiagnosticsView({ nodes, events, audit, selectedNodeId, onSelectNode }) {
const visibleEvents = events.filter((event) => !selectedNodeId || event.node_id === selectedNodeId);
const eventColumns = [
{ title: "时间", dataIndex: "received_at", width: 180, render: formatTime },
{ title: "Node", dataIndex: "node_id", width: 180 },
{ title: "会话", dataIndex: "chat_id", render: (value) => shortId(value, 28) },
{ title: "类型", dataIndex: "chat_type", width: 100, render: (value) => value === "Group" ? "群聊" : "私聊" },
{ title: "授权内容", dataIndex: "content", ellipsis: true },
];
const auditColumns = [
{ title: "时间", dataIndex: "at", width: 180, render: formatTime },
{ title: "操作者", dataIndex: "principal", width: 170 },
{ title: "操作", dataIndex: "action", width: 180 },
{ title: "资源", dataIndex: "resource" },
{ title: "结果", dataIndex: "result", width: 100 },
];
const nodeColumns = [
{ title: "Node", dataIndex: "node_id" },
{ title: "状态", dataIndex: "status", render: (value) => <Tag color={statusColor(value)}>{statusLabel(value)}</Tag> },
{ title: "活动账号", dataIndex: "active_account_id", render: (value) => value || "—" },
{ title: "最后心跳", dataIndex: "last_heartbeat_at", render: formatTime },
];
return (
<div className="page-stack diagnostics-stack">
<Alert type="info" showIcon message="管理员诊断视图" description="事件按可访问 Node 过滤;审计记录包含跨用户管理信息,仅对管理员开放。" />
<Tabs className="diagnostics-tabs" items={[
{ key: "nodes", label: `Node 状态 · ${nodes.length}`, children: <div className="diagnostics-table"><Table size="small" rowKey="node_id" pagination={false} dataSource={nodes} columns={nodeColumns} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 460px))" }} /></div> },
{ key: "events", label: `授权事件 · ${visibleEvents.length}`, children: <div className="diagnostics-table"><ProTable rowKey="event_id" columns={eventColumns} dataSource={visibleEvents} search={false} options={false} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 460px))" }} pagination={{ pageSize: 8 }} toolBarRender={false} /></div> },
{ key: "audit", label: `操作审计 · ${audit.length}`, children: <div className="diagnostics-table"><ProTable rowKey="id" columns={auditColumns} dataSource={audit} search={false} options={false} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 460px))" }} pagination={{ pageSize: 8 }} toolBarRender={false} /></div> },
]} />
</div>
);
}
@@ -4,6 +4,34 @@ namespace WxAgent.Core;
public static class ReportingAuthorization
{
public static ReportingConfig ForVerifiedAccounts(ReportingConfig configured, IEnumerable<string> verifiedAccountIds)
{
ArgumentNullException.ThrowIfNull(configured);
ArgumentNullException.ThrowIfNull(verifiedAccountIds);
var accounts = verifiedAccountIds
.Where(accountId => !string.IsNullOrWhiteSpace(accountId))
.Distinct(StringComparer.Ordinal)
.Select(accountId => new AccountReportingConfig
{
AccountId = accountId,
Enabled = true,
AllowedChats =
[
new AllowedChat { Type = ReportingChatType.Group, ChatId = "*", Enabled = true, IdentityVerified = true },
new AllowedChat { Type = ReportingChatType.Private, ChatId = "*", Enabled = true, IdentityVerified = true }
]
})
.ToArray();
return configured with
{
Enabled = true,
ConfigVersion = Math.Max(1, configured.ConfigVersion),
Accounts = accounts
};
}
public static ReportingDecision Check(
ReportingConfig? config,
string accountId,
+11 -2
View File
@@ -17,12 +17,21 @@ public sealed class EventPump(IAgentBackend backend, EventHub hub, ServiceOption
{
try
{
var remote = options.Remote;
var reporting = options.Reporting;
if (remoteQueue is not null && remote is { IsConfigured: true })
{
var accounts = await backend.AccountsAsync(stoppingToken, refreshUiIdentity: false);
reporting = ReportingAuthorization.ForVerifiedAccounts(options.Reporting,
accounts.Where(RemoteAgentHostedService.HasLiveWeChatBinding).Select(account => account.AccountId));
}
await foreach (var item in source.ListenAsync(stoppingToken))
{
if (remoteQueue is not null && options.Remote is { IsConfigured: true } remote && item.Kind == "message"
if (remoteQueue is not null && remote is { IsConfigured: true } && item.Kind == "message"
&& item.ChatId is { Length: > 0 } chatId && item.ChatType is { } chatType)
{
_ = remoteQueue.Enqueue(options.Reporting, remote.NodeId!, item.AccountId, chatId, chatType,
_ = remoteQueue.Enqueue(reporting, remote.NodeId!, item.AccountId, chatId, chatType,
item.Kind, item.At, item.Content);
}
var localItem = item with { Content = null };
@@ -876,12 +876,11 @@ public sealed class RemoteAgentHostedService(
// the explicit accounts API remains the only path that may inspect the profile.
var accounts = await backend.AccountsAsync(cancellationToken, refreshUiIdentity: false);
var identities = accounts.Select(account => new RemoteAccountIdentity(
account.AccountId,
account.IsUiBindingKnown && account.Binding is not null && string.Equals(account.BindingStatus, "Bound", StringComparison.Ordinal))).ToArray();
account.AccountId, HasLiveWeChatBinding(account))).ToArray();
var activeAccountId = remote.ActiveAccountId;
var boundAccounts = accounts
.Where(account => account.IsUiBindingKnown && account.Binding is not null && string.Equals(account.BindingStatus, "Bound", StringComparison.Ordinal))
.Select(account => account.AccountId)
var boundAccounts = identities
.Where(identity => identity.Verified)
.Select(identity => identity.AccountId)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if ((string.IsNullOrWhiteSpace(activeAccountId) || !identities.Any(identity => identity.Verified && string.Equals(identity.AccountId, activeAccountId, StringComparison.Ordinal)))
@@ -922,30 +921,16 @@ public sealed class RemoteAgentHostedService(
private static ReportingConfig ConnectionAuthorizedReporting(ReportingConfig configured, BackendSnapshot? snapshot)
{
var accountIds = snapshot is not null
? snapshot.Accounts.Where(identity => identity.Verified).Select(identity => identity.AccountId)
: configured.Accounts.Where(account => account.Enabled).Select(account => account.AccountId);
var accounts = accountIds
.Distinct(StringComparer.Ordinal)
.Select(accountId => new AccountReportingConfig
{
AccountId = accountId,
Enabled = true,
AllowedChats =
[
new AllowedChat { Type = ReportingChatType.Group, ChatId = "*", Enabled = true, IdentityVerified = true },
new AllowedChat { Type = ReportingChatType.Private, ChatId = "*", Enabled = true, IdentityVerified = true }
]
})
.ToArray();
return configured with
{
Enabled = true,
ConfigVersion = Math.Max(1, configured.ConfigVersion),
Accounts = accounts
};
var accountIds = snapshot?.Accounts
.Where(identity => identity.Verified)
.Select(identity => identity.AccountId) ?? Enumerable.Empty<string>();
return ReportingAuthorization.ForVerifiedAccounts(configured, accountIds);
}
internal static bool HasLiveWeChatBinding(AccountInfo account) =>
account.IsUiBindingKnown && account.Binding is not null &&
string.Equals(account.BindingStatus, "Bound", StringComparison.Ordinal);
private static RemoteNodeRegistration CreateRegistration(RemoteAgentOptions remote, ReportingConfig reporting, BackendSnapshot snapshot) =>
new(remote.NodeId!, typeof(RemoteAgentHostedService).Assembly.GetName().Version?.ToString() ?? "dev",
RemoteProtocol.Version, ["heartbeat", "poll-tasks", "send-text", "read-sessions", "read-contacts", "read-messages", "db-messages", "db-merged", "report-message"], reporting.ConfigVersion,
+11 -63
View File
@@ -366,23 +366,19 @@ internal static class ServiceSettingsEditor
var token = current.AccessToken ?? ServiceOptions.GenerateToken();
var remote = current.Remote;
var reporting = (current.Reporting ?? new ReportingConfig()).NormalizeAndValidate();
var reportingAccount = reporting.Accounts.FirstOrDefault();
var boundAccounts = new AccountBindingStore(current).ReadAll();
var reportingAccountId = reportingAccount?.AccountId
?? (boundAccounts.Count == 1 ? boundAccounts[0].AccountId : "");
ServiceOptions? result = null;
using var form = new Form
{
Text = "WxAgent 服务设置",
Width = 720,
Height = 805,
Height = 660,
StartPosition = FormStartPosition.CenterScreen,
MinimizeBox = false,
MaximizeBox = false,
FormBorderStyle = FormBorderStyle.FixedDialog
};
var tabs = new TabControl { Left = 12, Top = 12, Width = 680, Height = 720 };
var tabs = new TabControl { Left = 12, Top = 12, Width = 680, Height = 560 };
var localTab = new TabPage("本地服务");
var remoteTab = new TabPage("远程连接");
tabs.TabPages.Add(localTab);
@@ -467,82 +463,34 @@ internal static class ServiceSettingsEditor
var reportingTitle = new Label
{
Left = 18, Top = 416, Width = 620, Height = 24,
Text = "远程读取授权(Agent 连接后自动授予已验证账号和会话)"
};
var reportingEnabled = new CheckBox
{
Left = 18, Top = 442, Width = 620,
Text = "使用 Agent 连接授权读取(无需单独确认)",
Checked = true,
Enabled = false
};
var reportingAccountEnabled = new CheckBox
{
Left = 150, Top = 470, Width = 485,
Text = "启用当前账号",
Checked = reportingAccount?.Enabled ?? true
};
var reportingAccountLabel = new Label { Left = 18, Top = 505, Width = 125, Text = "账号 ID" };
var reportingAccountBox = new TextBox { Left = 150, Top = 501, Width = 485, Text = reportingAccountId };
var groupChatsLabel = new Label { Left = 18, Top = 541, Width = 125, Text = "群聊白名单" };
var groupChatsBox = new TextBox
{
Left = 150, Top = 537, Width = 485, Height = 48,
Multiline = true, AcceptsReturn = true, ScrollBars = ScrollBars.Vertical,
Text = string.Join(Environment.NewLine, reportingAccount?.AllowedChats
.Where(chat => chat.Type == ReportingChatType.Group).Select(chat => chat.ChatId) ?? [])
};
var privateChatsLabel = new Label { Left = 18, Top = 593, Width = 125, Text = "私聊白名单" };
var privateChatsBox = new TextBox
{
Left = 150, Top = 589, Width = 485, Height = 48,
Multiline = true, AcceptsReturn = true, ScrollBars = ScrollBars.Vertical,
Text = string.Join(Environment.NewLine, reportingAccount?.AllowedChats
.Where(chat => chat.Type == ReportingChatType.Private).Select(chat => chat.ChatId) ?? [])
};
var reportingIdentityConfirmed = new CheckBox
{
Left = 150, Top = 645, Width = 485,
Text = "确认上述 chatId 已与微信身份核对",
Checked = reportingAccount is not null && reportingAccount.AllowedChats.Count > 0 && reportingAccount.AllowedChats.All(chat => chat.IdentityVerified)
Text = "授权方式"
};
var reportingNote = new Label
{
Left = 18, Top = 674, Width = 620, Height = 36,
Text = "连接成功即授权当前已验证账号的通讯录、会话和消息读取;下方旧范围仅为兼容显示。"
Left = 18, Top = 446, Width = 620, Height = 82,
Text = "连接控制面后,所有已完成身份绑定且当前绑定有效的账号均获得通讯录、会话和消息的只读同步授权,无需维护账号或聊天白名单。启用上方后台消息监听后,Agent 仅推送监听器实际采集到的实时事件。"
};
remoteTab.Controls.AddRange([
remoteEnabled, remoteAddressLabel, remoteAddressBox, remoteNodeLabel, remoteNodeBox,
remoteAccountLabel, remoteAccountBox, remoteTokenLabel, remoteTokenBox,
remoteTokenFileLabel, remoteTokenFileBox, allowInsecureHttp, remoteServerCaLabel,
remoteServerCaBox, remoteClientCertLabel, remoteClientCertBox, remoteClientKeyLabel,
remoteClientKeyBox, remoteNote, reportingTitle, reportingEnabled, reportingAccountEnabled,
reportingAccountLabel, reportingAccountBox, groupChatsLabel, groupChatsBox,
privateChatsLabel, privateChatsBox, reportingIdentityConfirmed, reportingNote
remoteClientKeyBox, remoteNote, reportingTitle, reportingNote
]);
var remoteInputs = new Control[]
{
remoteAddressBox, remoteNodeBox, remoteAccountBox, remoteTokenBox, remoteTokenFileBox,
allowInsecureHttp, remoteServerCaBox, remoteClientCertBox, remoteClientKeyBox
};
var reportingInputs = new Control[]
{
reportingAccountEnabled, reportingAccountBox, groupChatsBox, privateChatsBox,
reportingIdentityConfirmed
};
void SetRemoteEnabled()
{
foreach (var control in remoteInputs) control.Enabled = remoteEnabled.Checked;
reportingEnabled.Enabled = remoteEnabled.Checked;
var reportingInputsEnabled = false;
foreach (var control in reportingInputs) control.Enabled = reportingInputsEnabled;
}
remoteEnabled.CheckedChanged += (_, _) => SetRemoteEnabled();
reportingEnabled.CheckedChanged += (_, _) => SetRemoteEnabled();
SetRemoteEnabled();
var save = new Button { Left = 470, Top = 740, Width = 105, Text = "保存" };
var cancel = new Button { Left = 585, Top = 740, Width = 105, Text = "取消" };
var save = new Button { Left = 470, Top = 585, Width = 105, Text = "保存" };
var cancel = new Button { Left = 585, Top = 585, Width = 105, Text = "取消" };
copy.Click += (_, _) => { Clipboard.SetText(tokenBox.Text); copy.Text = "已复制"; };
regenerate.Click += (_, _) =>
{
@@ -573,9 +521,9 @@ internal static class ServiceSettingsEditor
AllowInsecureHttp = allowInsecureHttp.Checked
}
: null;
var reportingOptions = remoteEnabled.Checked
? reporting with { Enabled = true, ConfigVersion = checked(Math.Max(1, reporting.ConfigVersion) + 1) }
: reporting;
var reportingOptions = reporting with { Accounts = [] };
if (remoteEnabled.Checked)
reportingOptions = reportingOptions with { Enabled = true, ConfigVersion = checked(Math.Max(1, reporting.ConfigVersion) + 1) };
var edited = new ServiceOptions
{
ListenUrl = $"http://{formattedHost}:{portBox.Value}",
@@ -62,6 +62,31 @@ public sealed class RemoteReportingTests
Assert.False(ReportingAuthorization.IsAllowed(config, "account-b", "private-1", ReportingChatType.Private, ReportingDataType.Message));
}
[Fact]
public void ConnectionAuthorizationReplacesLegacyScopesWithVerifiedAccounts()
{
var configured = new ReportingConfig
{
Enabled = false,
ConfigVersion = 0,
Accounts = [new AccountReportingConfig
{
AccountId = "old-account", Enabled = true,
AllowedChats = [new AllowedChat { Type = ReportingChatType.Group, ChatId = "old-group", Enabled = true, IdentityVerified = true }]
}]
};
var authorized = ReportingAuthorization.ForVerifiedAccounts(configured, ["verified-account"]);
Assert.True(authorized.Enabled);
Assert.Equal(1, authorized.ConfigVersion);
Assert.Equal(new[] { "verified-account" }, authorized.Accounts.Select(account => account.AccountId));
Assert.True(ReportingAuthorization.IsAllowed(authorized, "verified-account", "any-group", ReportingChatType.Group, ReportingDataType.Message));
Assert.True(ReportingAuthorization.IsAllowed(authorized, "verified-account", "any-private", ReportingChatType.Private, ReportingDataType.Message));
Assert.False(ReportingAuthorization.IsAllowed(authorized, "old-account", "old-group", ReportingChatType.Group, ReportingDataType.Message));
Assert.Empty(ReportingAuthorization.ForVerifiedAccounts(configured, []).Accounts);
}
[Fact]
public void ReadTaskResultsRequireEveryWhitelistedScope()
{