mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 22:13:16 +00:00
Merge pull request #1568 from yatul/feat/permanent-pairing
feat(pairing): let an operator make a device pairing permanent
This commit is contained in:
29 files changed
+726
-49
No files matched your search
@@ -112,7 +112,7 @@ flowchart LR
|
||||
|------|--------------------|
|
||||
| viewer | `agents.list`, `config.get`, `sessions.list`, `sessions.preview`, `health`, `status`, `providers.models`, `skills.list`, `skills.get`, `channels.list`, `channels.status`, `cron.list`, `cron.status`, `cron.runs`, `usage.get`, `usage.summary` |
|
||||
| operator | All viewer methods plus: `chat.send`, `chat.abort`, `chat.history`, `chat.inject`, `sessions.delete`, `sessions.reset`, `sessions.patch`, `cron.create`, `cron.update`, `cron.delete`, `cron.toggle`, `cron.run`, `skills.update`, `send`, `exec.approval.list`, `exec.approval.approve`, `exec.approval.deny`, `device.pair.request`, `device.pair.list` |
|
||||
| admin | All operator methods plus: `config.apply`, `config.patch`, `config.permissions.*`, `agents.create`, `agents.update`, `agents.delete`, `agents.files.*`, `teams.*`, `channels.toggle`, `device.pair.approve`, `device.pair.revoke` |
|
||||
| admin | All operator methods plus: `config.apply`, `config.patch`, `config.permissions.*`, `agents.create`, `agents.update`, `agents.delete`, `agents.files.*`, `teams.*`, `channels.toggle`, `device.pair.approve`, `device.pair.revoke`, `device.pair.update` |
|
||||
|
||||
---
|
||||
|
||||
@@ -233,9 +233,10 @@ flowchart TD
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `device.pair.request` | Request a pairing code |
|
||||
| `device.pair.approve` | Approve a pairing request |
|
||||
| `device.pair.approve` | Approve a pairing request (optional `permanent`) |
|
||||
| `device.pair.list` | List paired devices |
|
||||
| `device.pair.revoke` | Revoke a paired device |
|
||||
| `device.pair.update` | Make a paired device permanent or restore the default TTL |
|
||||
| `browser.pairing.status` | Poll browser pairing approval status |
|
||||
|
||||
### Exec Approval
|
||||
|
||||
+1
-1
@@ -451,7 +451,7 @@ Browser pairing allows web UI clients to authenticate without full admin credent
|
||||
|-----------|--------|
|
||||
| Pairing code | 8-character alphanumeric code (A-Z, 2-9, excludes I/O/L for clarity), generated via `generatePairingCode()` in `internal/store/pg/pairing.go` |
|
||||
| Code TTL | 60 minutes; expired codes are auto-pruned from database |
|
||||
| Paired device TTL | 30 days; provides defense-in-depth expiry (paired devices auto-cleaned if unused) |
|
||||
| Paired device TTL | 30 days by default; provides defense-in-depth expiry (expired pairings are auto-cleaned). An admin can opt a single pairing out of expiry (`device.pair.approve` with `permanent`, or `device.pair.update`); such a pairing lasts until revoked |
|
||||
| Pending limit | Max 3 pending pairing requests per account; prevents spam/enumeration |
|
||||
| HTTP access | Paired browsers access HTTP APIs via `X-GoClaw-Sender-Id` header (requires `channel=browser`). Fail-closed: `IsPaired()` check blocks unpaired sessions. Logs failed HTTP pairing auth attempts for security monitoring. |
|
||||
| Approval flow | Requires WebSocket `device.pair.approve` method from authenticated admin session, triggered by `pairing.approve` command. Admin approval adds sender to `paired_devices` table with `paired_by` audit field. |
|
||||
|
||||
@@ -378,10 +378,11 @@ Get JSON schema for config form generation.
|
||||
| Method | Description | Auth |
|
||||
|--------|-------------|------|
|
||||
| `device.pair.request` | Request pairing (from device) | Unauthenticated |
|
||||
| `device.pair.approve` | Approve request (from admin) | Admin |
|
||||
| `device.pair.approve` | Approve request (from admin); optional `permanent: true` skips the 30-day TTL | Admin |
|
||||
| `device.pair.deny` | Deny request | Admin |
|
||||
| `device.pair.list` | List pending + paired devices | Admin |
|
||||
| `device.pair.revoke` | Revoke device | Admin |
|
||||
| `device.pair.update` | `{senderId, channel, permanent}` — `true` clears expiry, `false` restarts the 30-day TTL from now; an expired pairing is not revived | Admin |
|
||||
| `browser.pairing.status` | Poll pairing status | Unauthenticated |
|
||||
|
||||
### Pairing Flow
|
||||
@@ -391,7 +392,7 @@ sequenceDiagram
|
||||
Device->>Gateway: device.pair.request {senderId, channel}
|
||||
Gateway-->>Device: {code: "A1B2C3D4"}
|
||||
Device->>Gateway: browser.pairing.status {sender_id} (poll)
|
||||
Admin->>Gateway: device.pair.approve {code, approvedBy}
|
||||
Admin->>Gateway: device.pair.approve {code, approvedBy, permanent?}
|
||||
Gateway-->>Device: {status: "approved"}
|
||||
```
|
||||
|
||||
@@ -816,7 +817,7 @@ Methods are gated by role. The role is determined at `connect` time from the tok
|
||||
|
||||
### Admin-Only Methods
|
||||
|
||||
`config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `channels.toggle`, `device.pair.approve`, `device.pair.deny`, `device.pair.revoke`, `teams.*`, `api_keys.*`, `tenants.*`
|
||||
`config.apply`, `config.patch`, `agents.create`, `agents.update`, `agents.delete`, `channels.toggle`, `device.pair.approve`, `device.pair.deny`, `device.pair.revoke`, `device.pair.update`, `teams.*`, `api_keys.*`, `tenants.*`
|
||||
|
||||
### Write Methods (Operator+)
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ func (s *fakeBitrixPairingStore) ApprovePairing(context.Context, string, string)
|
||||
}
|
||||
func (s *fakeBitrixPairingStore) DenyPairing(context.Context, string) error { return nil }
|
||||
func (s *fakeBitrixPairingStore) RevokePairing(context.Context, string, string) error { return nil }
|
||||
func (s *fakeBitrixPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeBitrixPairingStore) IsPaired(context.Context, string, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -231,6 +231,10 @@ func (s *feishuPolicyPairingStore) RequestPairing(_ context.Context, senderID, c
|
||||
return "PAIR1234", nil
|
||||
}
|
||||
|
||||
func (s *feishuPolicyPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *feishuPolicyPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) {
|
||||
return s.paired[senderID+"|"+channel], nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ func newMockPairingStore() *mockPairingStore {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) {
|
||||
if m.failIsPaired {
|
||||
return false, errors.New("pairing service error")
|
||||
|
||||
@@ -30,6 +30,10 @@ func (s *slackPolicyPairingStore) DenyPairing(context.Context, string) error { r
|
||||
|
||||
func (s *slackPolicyPairingStore) RevokePairing(context.Context, string, string) error { return nil }
|
||||
|
||||
func (s *slackPolicyPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *slackPolicyPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) {
|
||||
if s.paired[senderID] == nil {
|
||||
return false, nil
|
||||
|
||||
@@ -36,6 +36,10 @@ func (s whatsappInboundPairingStore) RevokePairing(context.Context, string, stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s whatsappInboundPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s whatsappInboundPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) {
|
||||
return s.paired[senderID+"|"+channel], nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package methods
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
||||
@@ -26,7 +27,8 @@ func isValidSenderID(id string) bool {
|
||||
// senderID identifies the paired entity (e.g., "group:XXXX" for group pairings).
|
||||
type PairingApproveCallback func(ctx context.Context, channel, chatID, senderID string)
|
||||
|
||||
// PairingMethods handles device.pair.request, device.pair.approve, device.pair.list, device.pair.revoke.
|
||||
// PairingMethods handles device.pair.request, device.pair.approve, device.pair.list, device.pair.revoke,
|
||||
// device.pair.update.
|
||||
type PairingMethods struct {
|
||||
service store.PairingStore
|
||||
msgBus *bus.MessageBus
|
||||
@@ -55,6 +57,7 @@ func (m *PairingMethods) Register(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodPairingDeny, m.handleDeny)
|
||||
router.Register(protocol.MethodPairingList, m.handleList)
|
||||
router.Register(protocol.MethodPairingRevoke, m.handleRevoke)
|
||||
router.Register(protocol.MethodPairingUpdate, m.handleUpdate)
|
||||
router.Register(protocol.MethodBrowserPairingStatus, m.handleBrowserPairingStatus)
|
||||
}
|
||||
|
||||
@@ -100,6 +103,7 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie
|
||||
var params struct {
|
||||
Code string `json:"code"`
|
||||
ApprovedBy string `json:"approvedBy"`
|
||||
Permanent bool `json:"permanent"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
json.Unmarshal(req.Params, ¶ms)
|
||||
@@ -118,6 +122,14 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
|
||||
return
|
||||
}
|
||||
if params.Permanent {
|
||||
if err := m.service.SetPairingPermanent(ctx, paired.SenderID, paired.Channel, true); err != nil {
|
||||
// The pairing itself is in place with the default TTL; report the partial result.
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "paired, but could not make it permanent: "+err.Error()))
|
||||
return
|
||||
}
|
||||
paired.ExpiresAt = nil
|
||||
}
|
||||
|
||||
// Notify the user via channel (matching TS notifyPairingApproved).
|
||||
// Use Background context: the CLI client may disconnect before the notification is sent.
|
||||
@@ -220,6 +232,52 @@ func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Clien
|
||||
}))
|
||||
}
|
||||
|
||||
// handleUpdate switches an existing pairing between permanent (never expires)
|
||||
// and the default TTL counted from now.
|
||||
func (m *PairingMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
var params struct {
|
||||
SenderID string `json:"senderId"`
|
||||
Channel string `json:"channel"`
|
||||
Permanent *bool `json:"permanent"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
json.Unmarshal(req.Params, ¶ms)
|
||||
}
|
||||
|
||||
if params.SenderID == "" || params.Channel == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired)))
|
||||
return
|
||||
}
|
||||
if !isValidSenderID(params.SenderID) {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid sender_id format"))
|
||||
return
|
||||
}
|
||||
if params.Permanent == nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "permanent is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.service.SetPairingPermanent(ctx, params.SenderID, params.Channel, *params.Permanent); err != nil {
|
||||
code := protocol.ErrInternal
|
||||
if errors.Is(err, store.ErrPairedDeviceNotFound) {
|
||||
code = protocol.ErrNotFound
|
||||
}
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, code, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if m.broadcaster != nil {
|
||||
m.broadcaster(*protocol.NewEvent(protocol.EventDevicePairRes, map[string]any{"action": "updated"}))
|
||||
}
|
||||
|
||||
emitAudit(m.msgBus, client, "pairing.updated", "pairing", params.SenderID)
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"updated": true,
|
||||
"permanent": *params.Permanent,
|
||||
}))
|
||||
}
|
||||
|
||||
// handleBrowserPairingStatus lets a pending browser client check if its pairing code has been approved.
|
||||
// Called by unauthenticated clients during the browser pairing flow.
|
||||
func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// stubPermanentPairingStore answers SetPairingPermanent with a fixed error.
|
||||
type stubPermanentPairingStore struct {
|
||||
store.PairingStore
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubPermanentPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func callPairingUpdate(t *testing.T, storeErr error) protocol.ResponseFrame {
|
||||
t.Helper()
|
||||
m := NewPairingMethods(&stubPermanentPairingStore{err: storeErr}, bus.New(), nil)
|
||||
client, out := gateway.NewCapturingTestClient(permissions.RoleAdmin, store.MasterTenantID, "admin-1", 1)
|
||||
raw, _ := json.Marshal(map[string]any{"senderId": "u1", "channel": "telegram", "permanent": true})
|
||||
|
||||
m.handleUpdate(t.Context(), client, &protocol.RequestFrame{ID: "r1", Params: raw})
|
||||
|
||||
var frame protocol.ResponseFrame
|
||||
if err := json.Unmarshal(<-out, &frame); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
func TestPairingUpdate_ErrorMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
storeErr error
|
||||
wantCode string
|
||||
}{
|
||||
{"not found", fmt.Errorf("%w: telegram/u1", store.ErrPairedDeviceNotFound), protocol.ErrNotFound},
|
||||
{"db failure", errors.New("connection reset"), protocol.ErrInternal},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
frame := callPairingUpdate(t, tc.storeErr)
|
||||
if frame.OK || frame.Error == nil || frame.Error.Code != tc.wantCode {
|
||||
t.Fatalf("want error %s, got ok=%v error=%+v", tc.wantCode, frame.OK, frame.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingUpdate_OK(t *testing.T) {
|
||||
frame := callPairingUpdate(t, nil)
|
||||
if !frame.OK {
|
||||
t.Fatalf("want ok, got error %+v", frame.Error)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,10 @@ func (m *mockPairingStore) ApprovePairing(context.Context, string, string) (*sto
|
||||
}
|
||||
func (m *mockPairingStore) DenyPairing(context.Context, string) error { return nil }
|
||||
func (m *mockPairingStore) RevokePairing(context.Context, string, string) error { return nil }
|
||||
func (s *mockPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) {
|
||||
return m.paired[senderID+":"+channel], nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -89,6 +90,7 @@ func registerPairingCRUDTools(srv *mcpserver.MCPServer, deps pairingCRUDDeps) {
|
||||
mcpgo.WithDescription("Approve a pending pairing code."),
|
||||
mcpgo.WithString("code", mcpgo.Required(), mcpgo.Description("Pairing code.")),
|
||||
mcpgo.WithString("approved_by", mcpgo.Description("Approver identifier; defaults to \"operator\".")),
|
||||
mcpgo.WithBoolean("permanent", mcpgo.Description("Pairing never expires; defaults to false (30-day TTL).")),
|
||||
), handlePairingDeviceApprove(deps))
|
||||
|
||||
srv.AddTool(mcpgo.NewTool("goclaw_pairing_device_deny",
|
||||
@@ -149,6 +151,12 @@ func handlePairingDeviceApprove(deps pairingCRUDDeps) mcpserver.ToolHandlerFunc
|
||||
if err != nil {
|
||||
return toolError("pairing.approve", err)
|
||||
}
|
||||
if req.GetBool("permanent", false) {
|
||||
if err := deps.pairing.SetPairingPermanent(ctx, paired.SenderID, paired.Channel, true); err != nil {
|
||||
return toolError("pairing.approve", fmt.Errorf("paired, but could not make it permanent: %w", err))
|
||||
}
|
||||
paired.ExpiresAt = nil
|
||||
}
|
||||
notifyPairingApproved(ctx, deps, paired)
|
||||
return jsonToolResult(map[string]any{"paired": paired})
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ func isAdminMethod(method string) bool {
|
||||
protocol.MethodPairingDeny,
|
||||
protocol.MethodPairingList,
|
||||
protocol.MethodPairingRevoke,
|
||||
protocol.MethodPairingUpdate,
|
||||
|
||||
// Teams — create/delete/update/member management.
|
||||
protocol.MethodTeamsCreate,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrPairedDeviceNotFound is returned by SetPairingPermanent when there is no
|
||||
// live (non-expired) pairing for the sender/channel.
|
||||
var ErrPairedDeviceNotFound = errors.New("paired device not found")
|
||||
|
||||
// PairingRequest represents a pending pairing code.
|
||||
type PairingRequestData struct {
|
||||
@@ -15,13 +22,16 @@ type PairingRequestData struct {
|
||||
}
|
||||
|
||||
// PairedDeviceData represents an approved pairing.
|
||||
// ExpiresAt is Unix ms; nil means the pairing never expires, 0 means it
|
||||
// expires but the stored date could not be read.
|
||||
type PairedDeviceData struct {
|
||||
SenderID string `json:"sender_id" db:"sender_id"`
|
||||
Channel string `json:"channel" db:"channel"`
|
||||
ChatID string `json:"chat_id" db:"chat_id"`
|
||||
PairedAt int64 `json:"paired_at" db:"paired_at"`
|
||||
PairedBy string `json:"paired_by" db:"paired_by"`
|
||||
Metadata map[string]string `json:"metadata,omitempty" db:"metadata"`
|
||||
SenderID string `json:"sender_id" db:"sender_id"`
|
||||
Channel string `json:"channel" db:"channel"`
|
||||
ChatID string `json:"chat_id" db:"chat_id"`
|
||||
PairedAt int64 `json:"paired_at" db:"paired_at"`
|
||||
PairedBy string `json:"paired_by" db:"paired_by"`
|
||||
ExpiresAt *int64 `json:"expires_at" db:"expires_at"`
|
||||
Metadata map[string]string `json:"metadata,omitempty" db:"metadata"`
|
||||
}
|
||||
|
||||
// PairingStore manages device pairing.
|
||||
@@ -31,6 +41,9 @@ type PairingStore interface {
|
||||
DenyPairing(ctx context.Context, code string) error
|
||||
RevokePairing(ctx context.Context, senderID, channel string) error
|
||||
IsPaired(ctx context.Context, senderID, channel string) (bool, error)
|
||||
// SetPairingPermanent clears the expiry of an existing pairing (permanent=true)
|
||||
// or restarts the default TTL from now (permanent=false).
|
||||
SetPairingPermanent(ctx context.Context, senderID, channel string, permanent bool) error
|
||||
ListPending(ctx context.Context) []PairingRequestData
|
||||
ListPaired(ctx context.Context) []PairedDeviceData
|
||||
// MigrateGroupChatID updates all references from oldChatID to newChatID
|
||||
|
||||
@@ -116,13 +116,15 @@ func (s *PGPairingStore) ApprovePairing(ctx context.Context, code, approvedBy st
|
||||
json.Unmarshal(metaJSON, &meta)
|
||||
}
|
||||
|
||||
expiresAtMs := expiresAt.UnixMilli()
|
||||
return &store.PairedDeviceData{
|
||||
SenderID: senderID,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
PairedAt: now.UnixMilli(),
|
||||
PairedBy: approvedBy,
|
||||
Metadata: meta,
|
||||
SenderID: senderID,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
PairedAt: now.UnixMilli(),
|
||||
PairedBy: approvedBy,
|
||||
ExpiresAt: &expiresAtMs,
|
||||
Metadata: meta,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -152,6 +154,30 @@ func (s *PGPairingStore) RevokePairing(ctx context.Context, senderID, channel st
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPairingPermanent clears (permanent=true) or restarts (permanent=false)
|
||||
// the expiry of a live pairing. An already expired pairing is not revived.
|
||||
func (s *PGPairingStore) SetPairingPermanent(ctx context.Context, senderID, channel string, permanent bool) error {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
var expiresAt *time.Time
|
||||
if !permanent {
|
||||
t := time.Now().Add(pairedDeviceTTL)
|
||||
expiresAt = &t
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paired_devices SET expires_at = $1
|
||||
WHERE sender_id = $2 AND channel = $3 AND tenant_id = $4 AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
expiresAt, senderID, channel, tid,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: %s/%s", store.ErrPairedDeviceNotFound, channel, senderID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGPairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
var count int64
|
||||
@@ -180,12 +206,13 @@ type pairingRequestRow struct {
|
||||
|
||||
// pairedDeviceRow is an sqlx scan struct for paired_devices.
|
||||
type pairedDeviceRow struct {
|
||||
SenderID string `json:"sender_id" db:"sender_id"`
|
||||
Channel string `json:"channel" db:"channel"`
|
||||
ChatID string `json:"chat_id" db:"chat_id"`
|
||||
PairedBy string `json:"paired_by" db:"paired_by"`
|
||||
PairedAt time.Time `json:"paired_at" db:"paired_at"`
|
||||
Metadata []byte `json:"metadata" db:"metadata"`
|
||||
SenderID string `json:"sender_id" db:"sender_id"`
|
||||
Channel string `json:"channel" db:"channel"`
|
||||
ChatID string `json:"chat_id" db:"chat_id"`
|
||||
PairedBy string `json:"paired_by" db:"paired_by"`
|
||||
PairedAt time.Time `json:"paired_at" db:"paired_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
|
||||
Metadata []byte `json:"metadata" db:"metadata"`
|
||||
}
|
||||
|
||||
func (s *PGPairingStore) ListPending(ctx context.Context) []store.PairingRequestData {
|
||||
@@ -224,7 +251,7 @@ func (s *PGPairingStore) ListPaired(ctx context.Context) []store.PairedDeviceDat
|
||||
|
||||
var rows []pairedDeviceRow
|
||||
err := pkgSqlxDB.SelectContext(ctx, &rows,
|
||||
`SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') AS metadata
|
||||
`SELECT sender_id, channel, chat_id, paired_by, paired_at, expires_at, COALESCE(metadata, '{}') AS metadata
|
||||
FROM paired_devices WHERE tenant_id = $1 ORDER BY paired_at DESC`, tid)
|
||||
if err != nil {
|
||||
return []store.PairedDeviceData{}
|
||||
@@ -236,6 +263,10 @@ func (s *PGPairingStore) ListPaired(ctx context.Context) []store.PairedDeviceDat
|
||||
SenderID: r.SenderID, Channel: r.Channel, ChatID: r.ChatID,
|
||||
PairedBy: r.PairedBy, PairedAt: r.PairedAt.UnixMilli(),
|
||||
}
|
||||
if r.ExpiresAt != nil {
|
||||
ms := r.ExpiresAt.UnixMilli()
|
||||
result[i].ExpiresAt = &ms
|
||||
}
|
||||
if len(r.Metadata) > 0 {
|
||||
json.Unmarshal(r.Metadata, &result[i].Metadata)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func newTestPGPairingStore(t *testing.T) (*PGPairingStore, context.Context) {
|
||||
t.Helper()
|
||||
db := hooksTestDB(t)
|
||||
|
||||
tenantID := uuid.Must(uuid.NewV7())
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO tenants (id, name, slug, status) VALUES ($1,$2,$3,'active')`,
|
||||
tenantID, "pairing-test-"+tenantID.String()[:8], "pt-"+tenantID.String()); err != nil {
|
||||
t.Fatalf("seed tenant: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM paired_devices WHERE tenant_id = $1", tenantID)
|
||||
db.Exec("DELETE FROM pairing_requests WHERE tenant_id = $1", tenantID)
|
||||
db.Exec("DELETE FROM tenants WHERE id = $1", tenantID)
|
||||
})
|
||||
|
||||
return NewPGPairingStore(db), store.WithTenantID(context.Background(), tenantID)
|
||||
}
|
||||
|
||||
func pairTestPGDevice(t *testing.T, s *PGPairingStore, ctx context.Context, senderID string) *store.PairedDeviceData {
|
||||
t.Helper()
|
||||
code, err := s.RequestPairing(ctx, senderID, "telegram", "chat-1", "default", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestPairing: %v", err)
|
||||
}
|
||||
paired, err := s.ApprovePairing(ctx, code, "operator")
|
||||
if err != nil {
|
||||
t.Fatalf("ApprovePairing: %v", err)
|
||||
}
|
||||
return paired
|
||||
}
|
||||
|
||||
func findPGPaired(list []store.PairedDeviceData, senderID string) *store.PairedDeviceData {
|
||||
for i := range list {
|
||||
if list[i].SenderID == senderID {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPGPairing_SetPermanentRoundTrip(t *testing.T) {
|
||||
s, ctx := newTestPGPairingStore(t)
|
||||
|
||||
paired := pairTestPGDevice(t, s, ctx, "u1")
|
||||
if paired.ExpiresAt == nil {
|
||||
t.Fatal("ApprovePairing returned nil ExpiresAt, want default TTL")
|
||||
}
|
||||
|
||||
if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); err != nil {
|
||||
t.Fatalf("SetPairingPermanent(true): %v", err)
|
||||
}
|
||||
got := findPGPaired(s.ListPaired(ctx), "u1")
|
||||
if got == nil || got.ExpiresAt != nil {
|
||||
t.Fatalf("after permanent: want u1 with nil ExpiresAt, got %+v", got)
|
||||
}
|
||||
if ok, err := s.IsPaired(ctx, "u1", "telegram"); err != nil || !ok {
|
||||
t.Fatalf("IsPaired after permanent = %v, %v; want true", ok, err)
|
||||
}
|
||||
|
||||
if err := s.SetPairingPermanent(ctx, "u1", "telegram", false); err != nil {
|
||||
t.Fatalf("SetPairingPermanent(false): %v", err)
|
||||
}
|
||||
got = findPGPaired(s.ListPaired(ctx), "u1")
|
||||
if got == nil || got.ExpiresAt == nil {
|
||||
t.Fatalf("after reverting: want u1 with expiry, got %+v", got)
|
||||
}
|
||||
want := time.Now().Add(pairedDeviceTTL)
|
||||
if d := time.UnixMilli(*got.ExpiresAt).Sub(want); d > time.Minute || d < -time.Minute {
|
||||
t.Fatalf("expires_at off by %v from now+TTL", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGPairing_SetPermanentDoesNotReviveExpired(t *testing.T) {
|
||||
s, ctx := newTestPGPairingStore(t)
|
||||
pairTestPGDevice(t, s, ctx, "u1")
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, "UPDATE paired_devices SET expires_at = NOW() - INTERVAL '1 hour' WHERE sender_id = $1 AND tenant_id = $2",
|
||||
"u1", store.TenantIDFromContext(ctx)); err != nil {
|
||||
t.Fatalf("expire pairing: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); !errors.Is(err, store.ErrPairedDeviceNotFound) {
|
||||
t.Fatalf("SetPairingPermanent on expired pairing: want ErrPairedDeviceNotFound, got %v", err)
|
||||
}
|
||||
if ok, _ := s.IsPaired(ctx, "u1", "telegram"); ok {
|
||||
t.Fatal("expired pairing was revived")
|
||||
}
|
||||
}
|
||||
@@ -110,13 +110,15 @@ func (s *SQLitePairingStore) ApprovePairing(ctx context.Context, code, approvedB
|
||||
json.Unmarshal(metaJSON, &meta)
|
||||
}
|
||||
|
||||
expiresAtMs := expiresAt.UnixMilli()
|
||||
return &store.PairedDeviceData{
|
||||
SenderID: senderID,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
PairedAt: now.UnixMilli(),
|
||||
PairedBy: approvedBy,
|
||||
Metadata: meta,
|
||||
SenderID: senderID,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
PairedAt: now.UnixMilli(),
|
||||
PairedBy: approvedBy,
|
||||
ExpiresAt: &expiresAtMs,
|
||||
Metadata: meta,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -145,6 +147,30 @@ func (s *SQLitePairingStore) RevokePairing(ctx context.Context, senderID, channe
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPairingPermanent clears (permanent=true) or restarts (permanent=false)
|
||||
// the expiry of a live pairing. An already expired pairing is not revived.
|
||||
func (s *SQLitePairingStore) SetPairingPermanent(ctx context.Context, senderID, channel string, permanent bool) error {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
now := time.Now().Round(0)
|
||||
var expiresAt any
|
||||
if !permanent {
|
||||
expiresAt = now.Add(pairedDeviceTTL)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paired_devices SET expires_at = ?
|
||||
WHERE sender_id = ? AND channel = ? AND tenant_id = ? AND (expires_at IS NULL OR expires_at > ?)`,
|
||||
expiresAt, senderID, channel, tid, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: %s/%s", store.ErrPairedDeviceNotFound, channel, senderID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLitePairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
var count int64
|
||||
@@ -204,7 +230,7 @@ func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDevic
|
||||
s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < ?", now)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}')
|
||||
`SELECT sender_id, channel, chat_id, paired_by, paired_at, expires_at, COALESCE(metadata, '{}')
|
||||
FROM paired_devices WHERE tenant_id = ? ORDER BY paired_at DESC`, tid)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -215,12 +241,19 @@ func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDevic
|
||||
for rows.Next() {
|
||||
var d store.PairedDeviceData
|
||||
var pairedAtStr string
|
||||
var expiresAtStr sql.NullString
|
||||
var metaJSON []byte
|
||||
if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAtStr, &metaJSON); err != nil {
|
||||
if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAtStr, &expiresAtStr, &metaJSON); err != nil {
|
||||
slog.Warn("pairing: scan paired error", "error", err)
|
||||
continue
|
||||
}
|
||||
d.PairedAt = parseTimeToMillis(pairedAtStr)
|
||||
if expiresAtStr.Valid {
|
||||
// A parse miss yields 0: the pairing still expires (SQL compares the
|
||||
// raw value), only the date is unknown. Never report it as permanent.
|
||||
ms := parseTimeToMillis(expiresAtStr.String)
|
||||
d.ExpiresAt = &ms
|
||||
}
|
||||
if len(metaJSON) > 0 {
|
||||
json.Unmarshal(metaJSON, &d.Metadata)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build sqlite || sqliteonly
|
||||
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func newTestSQLitePairingStore(t *testing.T) (*SQLitePairingStore, context.Context) {
|
||||
t.Helper()
|
||||
|
||||
db, err := OpenDB(filepath.Join(t.TempDir(), "pairing.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if err := EnsureSchema(db); err != nil {
|
||||
t.Fatalf("EnsureSchema error: %v", err)
|
||||
}
|
||||
|
||||
ctx := store.WithTenantID(context.Background(), store.MasterTenantID)
|
||||
return NewSQLitePairingStore(db), ctx
|
||||
}
|
||||
|
||||
func pairTestDevice(t *testing.T, s *SQLitePairingStore, ctx context.Context, senderID string) *store.PairedDeviceData {
|
||||
t.Helper()
|
||||
|
||||
code, err := s.RequestPairing(ctx, senderID, "telegram", "chat-1", "default", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestPairing: %v", err)
|
||||
}
|
||||
paired, err := s.ApprovePairing(ctx, code, "operator")
|
||||
if err != nil {
|
||||
t.Fatalf("ApprovePairing: %v", err)
|
||||
}
|
||||
return paired
|
||||
}
|
||||
|
||||
func findPaired(list []store.PairedDeviceData, senderID string) *store.PairedDeviceData {
|
||||
for i := range list {
|
||||
if list[i].SenderID == senderID {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSQLitePairing_ApproveSetsDefaultTTL(t *testing.T) {
|
||||
s, ctx := newTestSQLitePairingStore(t)
|
||||
|
||||
paired := pairTestDevice(t, s, ctx, "u1")
|
||||
if paired.ExpiresAt == nil {
|
||||
t.Fatal("ApprovePairing returned nil ExpiresAt, want default TTL")
|
||||
}
|
||||
|
||||
got := findPaired(s.ListPaired(ctx), "u1")
|
||||
if got == nil || got.ExpiresAt == nil {
|
||||
t.Fatalf("ListPaired: want u1 with expiry, got %+v", got)
|
||||
}
|
||||
want := time.Now().Add(pairedDeviceTTL)
|
||||
if d := time.UnixMilli(*got.ExpiresAt).Sub(want); d > time.Minute || d < -time.Minute {
|
||||
t.Fatalf("expires_at off by %v from now+TTL", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLitePairing_SetPermanentRoundTrip(t *testing.T) {
|
||||
s, ctx := newTestSQLitePairingStore(t)
|
||||
pairTestDevice(t, s, ctx, "u1")
|
||||
|
||||
if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); err != nil {
|
||||
t.Fatalf("SetPairingPermanent(true): %v", err)
|
||||
}
|
||||
got := findPaired(s.ListPaired(ctx), "u1")
|
||||
if got == nil || got.ExpiresAt != nil {
|
||||
t.Fatalf("after permanent: want u1 with nil ExpiresAt, got %+v", got)
|
||||
}
|
||||
if ok, err := s.IsPaired(ctx, "u1", "telegram"); err != nil || !ok {
|
||||
t.Fatalf("IsPaired after permanent = %v, %v; want true", ok, err)
|
||||
}
|
||||
|
||||
if err := s.SetPairingPermanent(ctx, "u1", "telegram", false); err != nil {
|
||||
t.Fatalf("SetPairingPermanent(false): %v", err)
|
||||
}
|
||||
got = findPaired(s.ListPaired(ctx), "u1")
|
||||
if got == nil || got.ExpiresAt == nil {
|
||||
t.Fatalf("after reverting: want u1 with expiry, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLitePairing_SetPermanentDoesNotReviveExpired(t *testing.T) {
|
||||
s, ctx := newTestSQLitePairingStore(t)
|
||||
pairTestDevice(t, s, ctx, "u1")
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, "UPDATE paired_devices SET expires_at = ? WHERE sender_id = ?",
|
||||
time.Now().Add(-time.Hour).Round(0), "u1"); err != nil {
|
||||
t.Fatalf("expire pairing: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); !errors.Is(err, store.ErrPairedDeviceNotFound) {
|
||||
t.Fatalf("SetPairingPermanent on expired pairing: want ErrPairedDeviceNotFound, got %v", err)
|
||||
}
|
||||
if ok, _ := s.IsPaired(ctx, "u1", "telegram"); ok {
|
||||
t.Fatal("expired pairing was revived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLitePairing_SetPermanentUnknownDevice(t *testing.T) {
|
||||
s, ctx := newTestSQLitePairingStore(t)
|
||||
|
||||
if err := s.SetPairingPermanent(ctx, "nobody", "telegram", true); !errors.Is(err, store.ErrPairedDeviceNotFound) {
|
||||
t.Fatalf("want ErrPairedDeviceNotFound for unknown device, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An expiry the parser cannot read must not turn into "never expires".
|
||||
func TestSQLitePairing_UnreadableExpiryIsNotPermanent(t *testing.T) {
|
||||
s, ctx := newTestSQLitePairingStore(t)
|
||||
pairTestDevice(t, s, ctx, "u1")
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, "UPDATE paired_devices SET expires_at = ? WHERE sender_id = ?",
|
||||
"9999-garbage", "u1"); err != nil {
|
||||
t.Fatalf("corrupt expiry: %v", err)
|
||||
}
|
||||
|
||||
got := findPaired(s.ListPaired(ctx), "u1")
|
||||
if got == nil || got.ExpiresAt == nil || *got.ExpiresAt != 0 {
|
||||
t.Fatalf("want u1 with ExpiresAt=0 (unknown date), got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ const (
|
||||
MethodPairingDeny = "device.pair.deny"
|
||||
MethodPairingList = "device.pair.list"
|
||||
MethodPairingRevoke = "device.pair.revoke"
|
||||
MethodPairingUpdate = "device.pair.update"
|
||||
|
||||
MethodBrowserPairingStatus = "browser.pairing.status"
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export const Methods = {
|
||||
PAIRING_DENY: "device.pair.deny",
|
||||
PAIRING_LIST: "device.pair.list",
|
||||
PAIRING_REVOKE: "device.pair.revoke",
|
||||
PAIRING_UPDATE: "device.pair.update",
|
||||
|
||||
BROWSER_PAIRING_STATUS: "browser.pairing.status",
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,6 +19,7 @@ interface ConfirmDialogProps {
|
||||
variant?: "default" | "destructive";
|
||||
onConfirm: () => void;
|
||||
loading?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
@@ -29,6 +31,7 @@ export function ConfirmDialog({
|
||||
variant = "default",
|
||||
onConfirm,
|
||||
loading,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
@@ -38,6 +41,7 @@ export function ConfirmDialog({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{children}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
{t("cancel")}
|
||||
|
||||
@@ -12,15 +12,20 @@
|
||||
"senderId": "Sender ID",
|
||||
"paired": "Paired",
|
||||
"by": "By",
|
||||
"expires": "Expires",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"approve": "Approve",
|
||||
"deny": "Deny",
|
||||
"revoke": "Revoke",
|
||||
"never": "Never",
|
||||
"makePermanent": "Make permanent",
|
||||
"makeExpiring": "Set expiry",
|
||||
"confirmApprove": {
|
||||
"title": "Approve Pairing",
|
||||
"description": "Approve pairing request from {{channel}}:{{senderId}} (code: {{code}})? This device will be able to interact with agents.",
|
||||
"confirmLabel": "Approve"
|
||||
"confirmLabel": "Approve",
|
||||
"permanent": "Never expires (otherwise 30 days)"
|
||||
},
|
||||
"confirmDeny": {
|
||||
"title": "Deny Pairing",
|
||||
@@ -31,5 +36,19 @@
|
||||
"title": "Revoke Device",
|
||||
"description": "Revoke pairing for {{channel}}:{{senderId}}? The device will need to re-pair.",
|
||||
"confirmLabel": "Revoke"
|
||||
},
|
||||
"confirmPermanent": {
|
||||
"title": "Make Pairing Permanent",
|
||||
"description": "Make the pairing for {{channel}}:{{senderId}} permanent? It will stay until revoked manually.",
|
||||
"confirmLabel": "Make permanent"
|
||||
},
|
||||
"confirmExpiring": {
|
||||
"title": "Set Pairing Expiry",
|
||||
"description": "Return the pairing for {{channel}}:{{senderId}} to the standard term? It will expire in 30 days from now.",
|
||||
"confirmLabel": "Set expiry"
|
||||
},
|
||||
"toast": {
|
||||
"approveFailed": "Could not approve pairing",
|
||||
"updateFailed": "Could not change pairing expiry"
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,20 @@
|
||||
"senderId": "발신자 ID",
|
||||
"paired": "페어링됨",
|
||||
"by": "처리자",
|
||||
"expires": "만료",
|
||||
"actions": "작업"
|
||||
},
|
||||
"approve": "승인",
|
||||
"deny": "거부",
|
||||
"revoke": "취소",
|
||||
"never": "없음",
|
||||
"makePermanent": "영구로 설정",
|
||||
"makeExpiring": "만료 설정",
|
||||
"confirmApprove": {
|
||||
"title": "페어링 승인",
|
||||
"description": "{{channel}}:{{senderId}}의 페어링 요청을 승인하시겠습니까 (코드: {{code}})? 이 기기가 에이전트와 상호작용할 수 있게 됩니다.",
|
||||
"confirmLabel": "승인"
|
||||
"confirmLabel": "승인",
|
||||
"permanent": "만료 없음 (기본값 30일)"
|
||||
},
|
||||
"confirmDeny": {
|
||||
"title": "페어링 거부",
|
||||
@@ -31,5 +36,19 @@
|
||||
"title": "기기 취소",
|
||||
"description": "{{channel}}:{{senderId}}의 페어링을 취소하시겠습니까? 기기는 다시 페어링해야 합니다.",
|
||||
"confirmLabel": "취소"
|
||||
},
|
||||
"confirmPermanent": {
|
||||
"title": "영구 페어링",
|
||||
"description": "{{channel}}:{{senderId}} 페어링을 영구로 설정하시겠습니까? 수동으로 해제할 때까지 유지됩니다.",
|
||||
"confirmLabel": "영구로 설정"
|
||||
},
|
||||
"confirmExpiring": {
|
||||
"title": "페어링 만료 설정",
|
||||
"description": "{{channel}}:{{senderId}} 페어링을 기본 기간으로 되돌리시겠습니까? 지금부터 30일 후 만료됩니다.",
|
||||
"confirmLabel": "만료 설정"
|
||||
},
|
||||
"toast": {
|
||||
"approveFailed": "페어링을 승인할 수 없습니다",
|
||||
"updateFailed": "페어링 만료를 변경할 수 없습니다"
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,20 @@
|
||||
"senderId": "ID отправителя",
|
||||
"paired": "Привязано",
|
||||
"by": "Кем",
|
||||
"expires": "Истекает",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"approve": "Одобрить",
|
||||
"deny": "Отклонить",
|
||||
"revoke": "Отозвать",
|
||||
"never": "Никогда",
|
||||
"makePermanent": "Сделать бессрочной",
|
||||
"makeExpiring": "Вернуть срок",
|
||||
"confirmApprove": {
|
||||
"title": "Одобрить привязку",
|
||||
"description": "Одобрить запрос на привязку от {{channel}}:{{senderId}} (код: {{code}})? Это устройство сможет взаимодействовать с агентами.",
|
||||
"confirmLabel": "Одобрить"
|
||||
"confirmLabel": "Одобрить",
|
||||
"permanent": "Бессрочно (иначе на 30 дней)"
|
||||
},
|
||||
"confirmDeny": {
|
||||
"title": "Отклонить привязку",
|
||||
@@ -31,5 +36,19 @@
|
||||
"title": "Отозвать устройство",
|
||||
"description": "Отозвать привязку для {{channel}}:{{senderId}}? Устройству потребуется повторная привязка.",
|
||||
"confirmLabel": "Отозвать"
|
||||
},
|
||||
"confirmPermanent": {
|
||||
"title": "Бессрочная привязка",
|
||||
"description": "Сделать привязку {{channel}}:{{senderId}} бессрочной? Она будет действовать, пока её не отзовут вручную.",
|
||||
"confirmLabel": "Сделать бессрочной"
|
||||
},
|
||||
"confirmExpiring": {
|
||||
"title": "Вернуть срок привязки",
|
||||
"description": "Вернуть привязке {{channel}}:{{senderId}} стандартный срок? Она истечёт через 30 дней, считая от сейчас.",
|
||||
"confirmLabel": "Вернуть срок"
|
||||
},
|
||||
"toast": {
|
||||
"approveFailed": "Не удалось одобрить привязку",
|
||||
"updateFailed": "Не удалось изменить срок привязки"
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,20 @@
|
||||
"senderId": "Sender ID",
|
||||
"paired": "Đã ghép nối",
|
||||
"by": "Bởi",
|
||||
"expires": "Hết hạn",
|
||||
"actions": "Thao tác"
|
||||
},
|
||||
"approve": "Phê duyệt",
|
||||
"deny": "Từ chối",
|
||||
"revoke": "Thu hồi",
|
||||
"never": "Không bao giờ",
|
||||
"makePermanent": "Đặt vĩnh viễn",
|
||||
"makeExpiring": "Đặt thời hạn",
|
||||
"confirmApprove": {
|
||||
"title": "Phê duyệt ghép nối",
|
||||
"description": "Phê duyệt yêu cầu ghép nối từ {{channel}}:{{senderId}} (mã: {{code}})? Thiết bị này sẽ có thể tương tác với các agent.",
|
||||
"confirmLabel": "Phê duyệt"
|
||||
"confirmLabel": "Phê duyệt",
|
||||
"permanent": "Không hết hạn (mặc định 30 ngày)"
|
||||
},
|
||||
"confirmDeny": {
|
||||
"title": "Từ chối ghép nối",
|
||||
@@ -31,5 +36,19 @@
|
||||
"title": "Thu hồi thiết bị",
|
||||
"description": "Thu hồi ghép nối cho {{channel}}:{{senderId}}? Thiết bị sẽ cần ghép nối lại.",
|
||||
"confirmLabel": "Thu hồi"
|
||||
},
|
||||
"confirmPermanent": {
|
||||
"title": "Ghép nối vĩnh viễn",
|
||||
"description": "Đặt ghép nối {{channel}}:{{senderId}} thành vĩnh viễn? Nó sẽ tồn tại cho đến khi bị thu hồi thủ công.",
|
||||
"confirmLabel": "Đặt vĩnh viễn"
|
||||
},
|
||||
"confirmExpiring": {
|
||||
"title": "Đặt thời hạn ghép nối",
|
||||
"description": "Đưa ghép nối {{channel}}:{{senderId}} về thời hạn mặc định? Nó sẽ hết hạn sau 30 ngày kể từ bây giờ.",
|
||||
"confirmLabel": "Đặt thời hạn"
|
||||
},
|
||||
"toast": {
|
||||
"approveFailed": "Không thể phê duyệt ghép nối",
|
||||
"updateFailed": "Không thể thay đổi thời hạn ghép nối"
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,20 @@
|
||||
"senderId": "发送者 ID",
|
||||
"paired": "配对时间",
|
||||
"by": "操作人",
|
||||
"expires": "过期时间",
|
||||
"actions": "操作"
|
||||
},
|
||||
"approve": "批准",
|
||||
"deny": "拒绝",
|
||||
"revoke": "撤销",
|
||||
"never": "永不",
|
||||
"makePermanent": "设为永久",
|
||||
"makeExpiring": "设置期限",
|
||||
"confirmApprove": {
|
||||
"title": "批准配对",
|
||||
"description": "批准来自 {{channel}}:{{senderId}} 的配对请求(代码:{{code}})?该设备将能与Agent交互。",
|
||||
"confirmLabel": "批准"
|
||||
"confirmLabel": "批准",
|
||||
"permanent": "永不过期(默认 30 天)"
|
||||
},
|
||||
"confirmDeny": {
|
||||
"title": "拒绝配对",
|
||||
@@ -31,5 +36,19 @@
|
||||
"title": "撤销设备",
|
||||
"description": "撤销 {{channel}}:{{senderId}} 的配对?该设备将需要重新配对。",
|
||||
"confirmLabel": "撤销"
|
||||
},
|
||||
"confirmPermanent": {
|
||||
"title": "永久配对",
|
||||
"description": "将 {{channel}}:{{senderId}} 的配对设为永久?在手动撤销之前一直有效。",
|
||||
"confirmLabel": "设为永久"
|
||||
},
|
||||
"confirmExpiring": {
|
||||
"title": "设置配对期限",
|
||||
"description": "将 {{channel}}:{{senderId}} 的配对恢复为默认期限?将从现在起 30 天后过期。",
|
||||
"confirmLabel": "设置期限"
|
||||
},
|
||||
"toast": {
|
||||
"approveFailed": "无法批准配对",
|
||||
"updateFailed": "无法更改配对期限"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { useWs } from "@/hooks/use-ws";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { Methods, Events } from "@/api/protocol";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import i18next from "i18next";
|
||||
|
||||
export interface PendingPairing {
|
||||
code: string;
|
||||
@@ -20,6 +22,8 @@ export interface PairedDevice {
|
||||
chat_id: string;
|
||||
paired_at: number;
|
||||
paired_by: string;
|
||||
/** Unix ms; null means the pairing never expires, 0 means it expires on an unknown date. */
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
export function useNodes() {
|
||||
@@ -59,9 +63,16 @@ export function useNodes() {
|
||||
});
|
||||
|
||||
const approvePairing = useCallback(
|
||||
async (code: string) => {
|
||||
await ws.call(Methods.PAIRING_APPROVE, { code });
|
||||
load();
|
||||
async (code: string, permanent = false) => {
|
||||
try {
|
||||
await ws.call(Methods.PAIRING_APPROVE, { code, permanent });
|
||||
} catch (err) {
|
||||
// With permanent=true the device may already be paired with the default
|
||||
// TTL when this fails; the reload below shows the actual state.
|
||||
toast.error(i18next.t("nodes:toast.approveFailed"), err instanceof Error ? err.message : "");
|
||||
} finally {
|
||||
load();
|
||||
}
|
||||
},
|
||||
[ws, load],
|
||||
);
|
||||
@@ -82,5 +93,27 @@ export function useNodes() {
|
||||
[ws, load],
|
||||
);
|
||||
|
||||
return { pendingPairings, pairedDevices, loading, refresh: load, approvePairing, denyPairing, revokePairing };
|
||||
const setPairingPermanent = useCallback(
|
||||
async (senderId: string, channel: string, permanent: boolean) => {
|
||||
try {
|
||||
await ws.call(Methods.PAIRING_UPDATE, { senderId, channel, permanent });
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("nodes:toast.updateFailed"), err instanceof Error ? err.message : "");
|
||||
} finally {
|
||||
load();
|
||||
}
|
||||
},
|
||||
[ws, load],
|
||||
);
|
||||
|
||||
return {
|
||||
pendingPairings,
|
||||
pairedDevices,
|
||||
loading,
|
||||
refresh: load,
|
||||
approvePairing,
|
||||
denyPairing,
|
||||
revokePairing,
|
||||
setPairingPermanent,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Link as LinkIcon, RefreshCw, Check, X, Trash2 } from "lucide-react";
|
||||
import { Link as LinkIcon, RefreshCw, Check, X, Trash2, Infinity as InfinityIcon, Timer } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
@@ -20,7 +22,16 @@ import { formatUserLabel } from "@/lib/format-user-label";
|
||||
|
||||
export function NodesPage() {
|
||||
const { t } = useTranslation("nodes");
|
||||
const { pendingPairings, pairedDevices, loading, refresh, approvePairing, denyPairing, revokePairing } = useNodes();
|
||||
const {
|
||||
pendingPairings,
|
||||
pairedDevices,
|
||||
loading,
|
||||
refresh,
|
||||
approvePairing,
|
||||
denyPairing,
|
||||
revokePairing,
|
||||
setPairingPermanent,
|
||||
} = useNodes();
|
||||
const spinning = useMinLoading(loading);
|
||||
const senderIds = useMemo(() => [
|
||||
...pendingPairings.map((p) => p.sender_id),
|
||||
@@ -32,6 +43,8 @@ export function NodesPage() {
|
||||
const showSkeleton = useDeferredLoading(loading && isEmpty);
|
||||
const [revokeTarget, setRevokeTarget] = useState<PairedDevice | null>(null);
|
||||
const [approveTarget, setApproveTarget] = useState<PendingPairing | null>(null);
|
||||
const [approvePermanent, setApprovePermanent] = useState(false);
|
||||
const [expiryTarget, setExpiryTarget] = useState<PairedDevice | null>(null);
|
||||
const [denyTarget, setDenyTarget] = useState<PendingPairing | null>(null);
|
||||
|
||||
return (
|
||||
@@ -89,7 +102,10 @@ export function NodesPage() {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setApproveTarget(p)}
|
||||
onClick={() => {
|
||||
setApprovePermanent(false);
|
||||
setApproveTarget(p);
|
||||
}}
|
||||
className="gap-1"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" /> {t("approve")}
|
||||
@@ -115,6 +131,7 @@ export function NodesPage() {
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.senderId")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.paired")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.by")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.expires")}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -129,7 +146,34 @@ export function NodesPage() {
|
||||
{formatDate(new Date(d.paired_at))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{d.paired_by ? formatUserLabel(d.paired_by, resolve) : "--"}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{d.expires_at == null ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<InfinityIcon className="h-3 w-3" /> {t("never")}
|
||||
</Badge>
|
||||
) : d.expires_at > 0 ? (
|
||||
formatDate(new Date(d.expires_at))
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpiryTarget(d)}
|
||||
className="gap-1"
|
||||
>
|
||||
{d.expires_at == null ? (
|
||||
<>
|
||||
<Timer className="h-3.5 w-3.5" /> {t("makeExpiring")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InfinityIcon className="h-3.5 w-3.5" /> {t("makePermanent")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -158,9 +202,33 @@ export function NodesPage() {
|
||||
description={t("confirmApprove.description", { channel: approveTarget.channel, senderId: approveTarget.sender_id, code: approveTarget.code })}
|
||||
confirmLabel={t("confirmApprove.confirmLabel")}
|
||||
onConfirm={async () => {
|
||||
await approvePairing(approveTarget.code);
|
||||
await approvePairing(approveTarget.code, approvePermanent);
|
||||
setApproveTarget(null);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="approve-permanent" checked={approvePermanent} onCheckedChange={setApprovePermanent} />
|
||||
<Label htmlFor="approve-permanent" className="font-normal">
|
||||
{t("confirmApprove.permanent")}
|
||||
</Label>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
|
||||
{expiryTarget && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={() => setExpiryTarget(null)}
|
||||
title={t(expiryTarget.expires_at == null ? "confirmExpiring.title" : "confirmPermanent.title")}
|
||||
description={t(
|
||||
expiryTarget.expires_at == null ? "confirmExpiring.description" : "confirmPermanent.description",
|
||||
{ channel: expiryTarget.channel, senderId: expiryTarget.sender_id },
|
||||
)}
|
||||
confirmLabel={t(expiryTarget.expires_at == null ? "confirmExpiring.confirmLabel" : "confirmPermanent.confirmLabel")}
|
||||
onConfirm={async () => {
|
||||
await setPairingPermanent(expiryTarget.sender_id, expiryTarget.channel, expiryTarget.expires_at != null);
|
||||
setExpiryTarget(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ The first request must be a `connect` handshake. Authentication supports three p
|
||||
| `device.pair.approve` | Approve a pairing code |
|
||||
| `device.pair.list` | List pending and approved pairings |
|
||||
| `device.pair.revoke` | Revoke a pairing |
|
||||
| `device.pair.update` | Make a pairing permanent or restore the default TTL |
|
||||
|
||||
## Events (server push)
|
||||
|
||||
|
||||
Reference in new issue
Block a user