Merge pull request #1568 from yatul/feat/permanent-pairing

feat(pairing): let an operator make a device pairing permanent
This commit is contained in:
Clark Cant authored and GitHub committed 2026-09-25 14:32:39 +07:00
commit 2066342a71
29 files changed
+726 -49

No files matched your search

+3 -2
View File
@@ -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
View File
@@ -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. |
+4 -3
View File
@@ -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
}
+4
View File
@@ -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")
+4
View File
@@ -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
}
+59 -1
View File
@@ -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, &params)
@@ -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, &params)
}
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)
}
}
+4
View File
@@ -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
}
+8
View File
@@ -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})
}
+1
View File
@@ -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,
+20 -7
View File
@@ -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
+44 -13
View File
@@ -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)
}
+102
View File
@@ -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")
}
}
+41 -8
View File
@@ -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)
}
}
+1
View File
@@ -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"
+1
View File
@@ -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")}
+20 -1
View File
@@ -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"
}
}
+20 -1
View File
@@ -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": "페어링 만료를 변경할 수 없습니다"
}
}
+20 -1
View File
@@ -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": "Не удалось изменить срок привязки"
}
}
+20 -1
View File
@@ -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"
}
}
+20 -1
View File
@@ -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": "无法更改配对期限"
}
}
+37 -4
View File
@@ -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,
};
}
+73 -5
View File
@@ -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);
}}
/>
)}
+1
View File
@@ -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)