diff --git a/docs/04-gateway-protocol.md b/docs/04-gateway-protocol.md index 7a703a04..bf6db71b 100644 --- a/docs/04-gateway-protocol.md +++ b/docs/04-gateway-protocol.md @@ -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 diff --git a/docs/09-security.md b/docs/09-security.md index 809ce0b0..03844124 100644 --- a/docs/09-security.md +++ b/docs/09-security.md @@ -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. | diff --git a/docs/19-websocket-rpc.md b/docs/19-websocket-rpc.md index 9b09783a..bfbaa674 100644 --- a/docs/19-websocket-rpc.md +++ b/docs/19-websocket-rpc.md @@ -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+) diff --git a/internal/channels/bitrix24/pairing_test.go b/internal/channels/bitrix24/pairing_test.go index d7a99fbb..2011dbaa 100644 --- a/internal/channels/bitrix24/pairing_test.go +++ b/internal/channels/bitrix24/pairing_test.go @@ -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 } diff --git a/internal/channels/feishu/bot_policy_test.go b/internal/channels/feishu/bot_policy_test.go index 2b157920..bd3b07b4 100644 --- a/internal/channels/feishu/bot_policy_test.go +++ b/internal/channels/feishu/bot_policy_test.go @@ -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 } diff --git a/internal/channels/policy_test.go b/internal/channels/policy_test.go index 33541d4e..ad3dbce5 100644 --- a/internal/channels/policy_test.go +++ b/internal/channels/policy_test.go @@ -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") diff --git a/internal/channels/slack/policy_test.go b/internal/channels/slack/policy_test.go index 1c4ecbb2..87f872c7 100644 --- a/internal/channels/slack/policy_test.go +++ b/internal/channels/slack/policy_test.go @@ -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 diff --git a/internal/channels/whatsapp/inbound_policy_test.go b/internal/channels/whatsapp/inbound_policy_test.go index 59b6b918..f9189b44 100644 --- a/internal/channels/whatsapp/inbound_policy_test.go +++ b/internal/channels/whatsapp/inbound_policy_test.go @@ -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 } diff --git a/internal/gateway/methods/pairing.go b/internal/gateway/methods/pairing.go index 5374f5b4..9eded369 100644 --- a/internal/gateway/methods/pairing.go +++ b/internal/gateway/methods/pairing.go @@ -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) { diff --git a/internal/gateway/methods/pairing_update_test.go b/internal/gateway/methods/pairing_update_test.go new file mode 100644 index 00000000..9a526ecb --- /dev/null +++ b/internal/gateway/methods/pairing_update_test.go @@ -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) + } +} diff --git a/internal/http/auth_test.go b/internal/http/auth_test.go index 795b25b6..7d51065c 100644 --- a/internal/http/auth_test.go +++ b/internal/http/auth_test.go @@ -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 } diff --git a/internal/mcp/crud_pairing.go b/internal/mcp/crud_pairing.go index a39b6bd4..cb9ce4e3 100644 --- a/internal/mcp/crud_pairing.go +++ b/internal/mcp/crud_pairing.go @@ -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}) } diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index b9f99a1a..8e9aebc4 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -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, diff --git a/internal/store/pairing_store.go b/internal/store/pairing_store.go index b94c4c67..7f1fb490 100644 --- a/internal/store/pairing_store.go +++ b/internal/store/pairing_store.go @@ -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 diff --git a/internal/store/pg/pairing.go b/internal/store/pg/pairing.go index 9a97043c..e503f62b 100644 --- a/internal/store/pg/pairing.go +++ b/internal/store/pg/pairing.go @@ -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) } diff --git a/internal/store/pg/pairing_permanent_test.go b/internal/store/pg/pairing_permanent_test.go new file mode 100644 index 00000000..8dadf96f --- /dev/null +++ b/internal/store/pg/pairing_permanent_test.go @@ -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") + } +} diff --git a/internal/store/sqlitestore/pairing.go b/internal/store/sqlitestore/pairing.go index 8be7b592..e17cabdb 100644 --- a/internal/store/sqlitestore/pairing.go +++ b/internal/store/sqlitestore/pairing.go @@ -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) } diff --git a/internal/store/sqlitestore/pairing_permanent_test.go b/internal/store/sqlitestore/pairing_permanent_test.go new file mode 100644 index 00000000..ba91d952 --- /dev/null +++ b/internal/store/sqlitestore/pairing_permanent_test.go @@ -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) + } +} diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 4c2d52cf..03e3f0c5 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -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" diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 7132e7c6..97ea36ab 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -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", diff --git a/ui/web/src/components/shared/confirm-dialog.tsx b/ui/web/src/components/shared/confirm-dialog.tsx index be593184..34b6e990 100644 --- a/ui/web/src/components/shared/confirm-dialog.tsx +++ b/ui/web/src/components/shared/confirm-dialog.tsx @@ -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({ {title} {description} + {children} onOpenChange(false)} disabled={loading}> {t("cancel")} diff --git a/ui/web/src/i18n/locales/en/nodes.json b/ui/web/src/i18n/locales/en/nodes.json index ce95fb29..9a30b275 100644 --- a/ui/web/src/i18n/locales/en/nodes.json +++ b/ui/web/src/i18n/locales/en/nodes.json @@ -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" } } diff --git a/ui/web/src/i18n/locales/ko/nodes.json b/ui/web/src/i18n/locales/ko/nodes.json index fe3d221b..4a0da5c3 100644 --- a/ui/web/src/i18n/locales/ko/nodes.json +++ b/ui/web/src/i18n/locales/ko/nodes.json @@ -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": "페어링 만료를 변경할 수 없습니다" } } diff --git a/ui/web/src/i18n/locales/ru/nodes.json b/ui/web/src/i18n/locales/ru/nodes.json index cf8d9845..85d3bef2 100644 --- a/ui/web/src/i18n/locales/ru/nodes.json +++ b/ui/web/src/i18n/locales/ru/nodes.json @@ -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": "Не удалось изменить срок привязки" } } diff --git a/ui/web/src/i18n/locales/vi/nodes.json b/ui/web/src/i18n/locales/vi/nodes.json index 441259e2..b69507a7 100644 --- a/ui/web/src/i18n/locales/vi/nodes.json +++ b/ui/web/src/i18n/locales/vi/nodes.json @@ -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" } } diff --git a/ui/web/src/i18n/locales/zh/nodes.json b/ui/web/src/i18n/locales/zh/nodes.json index f236f2e3..6121e8de 100644 --- a/ui/web/src/i18n/locales/zh/nodes.json +++ b/ui/web/src/i18n/locales/zh/nodes.json @@ -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": "无法更改配对期限" } } diff --git a/ui/web/src/pages/nodes/hooks/use-nodes.ts b/ui/web/src/pages/nodes/hooks/use-nodes.ts index e78e38d8..aa2e9c21 100644 --- a/ui/web/src/pages/nodes/hooks/use-nodes.ts +++ b/ui/web/src/pages/nodes/hooks/use-nodes.ts @@ -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, + }; } diff --git a/ui/web/src/pages/nodes/nodes-page.tsx b/ui/web/src/pages/nodes/nodes-page.tsx index 94445e5b..ba718291 100644 --- a/ui/web/src/pages/nodes/nodes-page.tsx +++ b/ui/web/src/pages/nodes/nodes-page.tsx @@ -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(null); const [approveTarget, setApproveTarget] = useState(null); + const [approvePermanent, setApprovePermanent] = useState(false); + const [expiryTarget, setExpiryTarget] = useState(null); const [denyTarget, setDenyTarget] = useState(null); return ( @@ -89,7 +102,10 @@ export function NodesPage() { setApproveTarget(p)} + onClick={() => { + setApprovePermanent(false); + setApproveTarget(p); + }} className="gap-1" > {t("approve")} @@ -115,6 +131,7 @@ export function NodesPage() { {t("columns.senderId")} {t("columns.paired")} {t("columns.by")} + {t("columns.expires")} {t("columns.actions")} @@ -129,7 +146,34 @@ export function NodesPage() { {formatDate(new Date(d.paired_at))} {d.paired_by ? formatUserLabel(d.paired_by, resolve) : "--"} - + + {d.expires_at == null ? ( + + {t("never")} + + ) : d.expires_at > 0 ? ( + formatDate(new Date(d.expires_at)) + ) : ( + "--" + )} + + + setExpiryTarget(d)} + className="gap-1" + > + {d.expires_at == null ? ( + <> + {t("makeExpiring")} + > + ) : ( + <> + {t("makePermanent")} + > + )} + { - await approvePairing(approveTarget.code); + await approvePairing(approveTarget.code, approvePermanent); setApproveTarget(null); }} + > + + + + {t("confirmApprove.permanent")} + + + + )} + + {expiryTarget && ( + 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); + }} /> )} diff --git a/websocket-protocol.md b/websocket-protocol.md index 06e3067e..3d5f8be7 100644 --- a/websocket-protocol.md +++ b/websocket-protocol.md @@ -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)