fix(desktop): fix pairing time scan, i18n keys, convert advanced to tab

SQLite pairing:
- Fix rows.Scan failing on Go time.Time strings with monotonic suffix
- Scan timestamps as strings, parse with parseTimeToMillis() that strips m=+
- Use time.Now().Round(0) for all SQLite time comparisons
- Wire SetOnRequest for SQLite store via interface assertion (not PG-only)

Desktop UI:
- Fix ChannelManagersTab i18n keys: managers.xxx → detail.managers.xxx
- Fix ChannelAdvancedDialog i18n keys and save logic (wrap in config)
- Convert Advanced from modal dialog to inline tab (4th tab)
- Add "advanced" tab label to all 3 locale files
This commit is contained in:
viettranx committed 2026-03-27 20:44:12 +07:00
1 parent 2ba883aff7
commit 0b3be862c9
9 files changed
+239 -232

No files matched your search

+40 -13
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -40,7 +41,7 @@ func (s *SQLitePairingStore) SetOnRequest(cb func(code, senderID, channel, chatI
func (s *SQLitePairingStore) RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) {
tid := tenantIDForInsert(ctx)
now := time.Now()
now := time.Now().Round(0) // Strip monotonic clock for correct SQLite string comparison
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
@@ -65,7 +66,7 @@ func (s *SQLitePairingStore) RequestPairing(ctx context.Context, senderID, chann
_, err = s.db.ExecContext(ctx,
`INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, tid,
uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL).Round(0), now, metaJSON, tid,
)
if err != nil {
return "", fmt.Errorf("create pairing request: %w", err)
@@ -77,7 +78,7 @@ func (s *SQLitePairingStore) RequestPairing(ctx context.Context, senderID, chann
}
func (s *SQLitePairingStore) ApprovePairing(ctx context.Context, code, approvedBy string) (*store.PairedDeviceData, error) {
now := time.Now()
now := time.Now().Round(0)
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
var reqID uuid.UUID
@@ -149,7 +150,7 @@ func (s *SQLitePairingStore) IsPaired(ctx context.Context, senderID, channel str
var count int64
err := s.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM paired_devices WHERE sender_id = ? AND channel = ? AND tenant_id = ? AND (expires_at IS NULL OR expires_at > ?)",
senderID, channel, tid, time.Now(),
senderID, channel, tid, time.Now().Round(0),
).Scan(&count)
if err != nil {
return false, fmt.Errorf("pairing check query: %w", err)
@@ -159,7 +160,7 @@ func (s *SQLitePairingStore) IsPaired(ctx context.Context, senderID, channel str
func (s *SQLitePairingStore) ListPending(ctx context.Context) []store.PairingRequestData {
tid := tenantIDForInsert(ctx)
now := time.Now()
now := time.Now().Round(0) // Strip monotonic clock for correct SQLite string comparison
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
@@ -174,13 +175,14 @@ func (s *SQLitePairingStore) ListPending(ctx context.Context) []store.PairingReq
var result []store.PairingRequestData
for rows.Next() {
var d store.PairingRequestData
var createdAt, expiresAt time.Time
var createdAtStr, expiresAtStr string
var metaJSON []byte
if err := rows.Scan(&d.Code, &d.SenderID, &d.Channel, &d.ChatID, &d.AccountID, &createdAt, &expiresAt, &metaJSON); err != nil {
if err := rows.Scan(&d.Code, &d.SenderID, &d.Channel, &d.ChatID, &d.AccountID, &createdAtStr, &expiresAtStr, &metaJSON); err != nil {
slog.Warn("pairing: scan error", "error", err)
continue
}
d.CreatedAt = createdAt.UnixMilli()
d.ExpiresAt = expiresAt.UnixMilli()
d.CreatedAt = parseTimeToMillis(createdAtStr)
d.ExpiresAt = parseTimeToMillis(expiresAtStr)
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &d.Metadata)
}
@@ -197,7 +199,7 @@ func (s *SQLitePairingStore) ListPending(ctx context.Context) []store.PairingReq
func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDeviceData {
tid := tenantIDForInsert(ctx)
now := time.Now()
now := time.Now().Round(0)
s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < ?", now)
@@ -212,12 +214,13 @@ func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDevic
var result []store.PairedDeviceData
for rows.Next() {
var d store.PairedDeviceData
var pairedAt time.Time
var pairedAtStr string
var metaJSON []byte
if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAt, &metaJSON); err != nil {
if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAtStr, &metaJSON); err != nil {
slog.Warn("pairing: scan paired error", "error", err)
continue
}
d.PairedAt = pairedAt.UnixMilli()
d.PairedAt = parseTimeToMillis(pairedAtStr)
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &d.Metadata)
}
@@ -232,6 +235,30 @@ func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDevic
return result
}
// parseTimeToMillis parses a Go time.Time string (possibly with monotonic clock suffix)
// from SQLite and returns Unix milliseconds. Falls back to 0 on parse failure.
func parseTimeToMillis(s string) int64 {
// Strip monotonic clock suffix "m=+xxx" if present
if idx := strings.Index(s, " m="); idx > 0 {
s = s[:idx]
}
// Try standard Go formats
for _, layout := range []string{
"2006-01-02 15:04:05.999999999 -0700 MST",
"2006-01-02 15:04:05.999999999 -0700 -07",
"2006-01-02 15:04:05.999999 -0700 -07",
"2006-01-02T15:04:05.999999999-07:00",
time.RFC3339Nano,
time.RFC3339,
} {
if t, err := time.Parse(layout, s); err == nil {
return t.UnixMilli()
}
}
slog.Warn("pairing: failed to parse time", "value", s)
return 0
}
func generatePairingCode() string {
b := make([]byte, codeLength)
rand.Read(b)
@@ -1,137 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { ChannelFields } from './channel-field-renderer'
import { configSchema, ESSENTIAL_CONFIG_KEYS, NETWORK_KEYS, LIMITS_KEYS, STREAMING_KEYS, BEHAVIOR_KEYS, ACCESS_KEYS } from './channel-schemas'
import type { ChannelInstanceData } from '../../../types/channel'
interface ChannelAdvancedDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
instance: ChannelInstanceData
onUpdate: (updates: Record<string, unknown>) => Promise<void>
}
const ESSENTIAL_CONFIG_KEYS_SET = new Set(['dm_policy', 'group_policy', 'require_mention', 'mention_mode'])
function getAdvancedFields(channelType: string) {
const allFields = configSchema[channelType] ?? []
const advanced = allFields.filter((f) => !ESSENTIAL_CONFIG_KEYS_SET.has(f.key))
return {
network: advanced.filter((f) => NETWORK_KEYS.has(f.key)),
limits: advanced.filter((f) => LIMITS_KEYS.has(f.key)),
streaming: advanced.filter((f) => STREAMING_KEYS.has(f.key)),
behavior: advanced.filter((f) => BEHAVIOR_KEYS.has(f.key)),
access: advanced.filter((f) => ACCESS_KEYS.has(f.key)),
}
}
function deriveInitialValues(instance: ChannelInstanceData): Record<string, unknown> {
const config = (instance.config ?? {}) as Record<string, unknown>
const { groups: _groups, ...rest } = config
return Object.fromEntries(
Object.entries(rest).filter(([k]) => !ESSENTIAL_CONFIG_KEYS_SET.has(k))
)
}
export function ChannelAdvancedDialog({ open, onOpenChange, instance, onUpdate }: ChannelAdvancedDialogProps) {
const { t } = useTranslation('channels')
const [values, setValues] = useState<Record<string, unknown>>({})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setValues(deriveInitialValues(instance))
setError('')
}
}, [open, instance])
if (!open) return null
const fields = getAdvancedFields(instance.channel_type)
const essentialKeys = ESSENTIAL_CONFIG_KEYS[instance.channel_type] ?? ESSENTIAL_CONFIG_KEYS['_default']
const handleChange = (key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }))
}
const handleSave = async () => {
setSaving(true)
setError('')
try {
const existingConfig = (instance.config ?? {}) as Record<string, unknown>
// Preserve essential keys + groups key
const essential = Object.fromEntries(
Object.entries(existingConfig).filter(([k]) => essentialKeys.includes(k) || k === 'groups')
)
await onUpdate({ ...essential, ...values })
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : t('advanced.saveFailed'))
} finally {
setSaving(false)
}
}
const groups = [
{ key: 'network', label: t('advanced.network'), fields: fields.network },
{ key: 'limits', label: t('advanced.limits'), fields: fields.limits },
{ key: 'streaming', label: t('advanced.streaming'), fields: fields.streaming },
{ key: 'behavior', label: t('advanced.behavior'), fields: fields.behavior },
{ key: 'access', label: t('advanced.access'), fields: fields.access },
].filter((g) => g.fields.length > 0)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-lg w-full mx-4 flex flex-col" style={{ maxHeight: '85vh' }}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4 shrink-0">
<h3 className="text-sm font-semibold text-text-primary">{t('advanced.title')}</h3>
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors cursor-pointer">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto overscroll-contain px-5 py-4 space-y-5">
{groups.length === 0 ? (
<p className="text-sm text-text-muted text-center py-6">{t('advanced.noFields')}</p>
) : (
groups.map((g) => (
<div key={g.key}>
<p className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-3">{g.label}</p>
<ChannelFields
fields={g.fields}
values={values}
onChange={handleChange}
idPrefix={`adv-${g.key}`}
isEdit
/>
</div>
))
)}
{error && <p className="text-xs text-error">{error}</p>}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 border-t border-border px-5 py-3 shrink-0">
<button
onClick={() => onOpenChange(false)}
className="px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
>
{t('common.cancel')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 bg-accent text-white text-sm rounded-lg disabled:opacity-50 cursor-pointer hover:opacity-90 transition-opacity"
>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,98 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { ChannelFields } from './channel-field-renderer'
import { configSchema, NETWORK_KEYS, LIMITS_KEYS, STREAMING_KEYS, BEHAVIOR_KEYS, ACCESS_KEYS } from './channel-schemas'
import type { ChannelInstanceData } from '../../../types/channel'
interface ChannelAdvancedTabProps {
instance: ChannelInstanceData
onUpdate: (updates: Record<string, unknown>) => Promise<void>
}
const ESSENTIAL_KEYS = new Set(['dm_policy', 'group_policy', 'require_mention', 'mention_mode'])
function getAdvancedFields(channelType: string) {
const allFields = configSchema[channelType] ?? []
const advanced = allFields.filter((f) => !ESSENTIAL_KEYS.has(f.key))
return {
network: advanced.filter((f) => NETWORK_KEYS.has(f.key)),
limits: advanced.filter((f) => LIMITS_KEYS.has(f.key)),
streaming: advanced.filter((f) => STREAMING_KEYS.has(f.key)),
behavior: advanced.filter((f) => BEHAVIOR_KEYS.has(f.key)),
access: advanced.filter((f) => ACCESS_KEYS.has(f.key)),
}
}
function deriveInitialValues(instance: ChannelInstanceData): Record<string, unknown> {
const config = (instance.config ?? {}) as Record<string, unknown>
const { groups: _groups, ...rest } = config
return Object.fromEntries(
Object.entries(rest).filter(([k]) => !ESSENTIAL_KEYS.has(k)),
)
}
export function ChannelAdvancedTab({ instance, onUpdate }: ChannelAdvancedTabProps) {
const { t } = useTranslation('channels')
const [values, setValues] = useState<Record<string, unknown>>({})
const [saving, setSaving] = useState(false)
useEffect(() => {
setValues(deriveInitialValues(instance))
}, [instance])
const handleChange = useCallback((key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }))
}, [])
const fields = getAdvancedFields(instance.channel_type)
const handleSave = async () => {
setSaving(true)
try {
const existingConfig = (instance.config ?? {}) as Record<string, unknown>
const cleanAdvanced = Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== undefined && v !== '' && v !== null),
)
const merged = { ...existingConfig, ...cleanAdvanced }
await onUpdate({ config: merged })
} catch {
// toast shown by hook
} finally {
setSaving(false)
}
}
const sections = [
{ key: 'network', label: t('detail.network'), desc: t('detail.networkDesc'), fields: fields.network },
{ key: 'limits', label: t('detail.limits'), desc: t('detail.limitsDesc'), fields: fields.limits },
{ key: 'streaming', label: t('detail.streaming'), desc: t('detail.streamingDesc'), fields: fields.streaming },
{ key: 'behavior', label: t('detail.behavior'), desc: t('detail.behaviorDesc'), fields: fields.behavior },
{ key: 'access', label: t('detail.accessControl'), desc: t('detail.accessControlDesc'), fields: fields.access },
].filter((s) => s.fields.length > 0)
if (sections.length === 0) {
return <p className="text-xs text-text-muted py-4">{t('detail.noAdvanced')}</p>
}
return (
<div className="space-y-5">
{sections.map((s) => (
<section key={s.key} className="space-y-3 rounded-lg border border-border p-4">
<div>
<h3 className="text-xs font-semibold text-text-secondary">{s.label}</h3>
<p className="text-[11px] text-text-muted mt-0.5">{s.desc}</p>
</div>
<ChannelFields fields={s.fields} values={values} onChange={handleChange} idPrefix={`adv-${s.key}`} contextValues={values} />
</section>
))}
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer"
>
{saving ? t('form.saving') : t('detail.saveConfig')}
</button>
</div>
)
}
@@ -5,7 +5,7 @@ import { useAgentCrud } from '../../../hooks/use-agent-crud'
import { ChannelGeneralTab } from './ChannelGeneralTab'
import { ChannelCredentialsTab } from './ChannelCredentialsTab'
import { ChannelManagersTab } from './ChannelManagersTab'
import { ChannelAdvancedDialog } from './ChannelAdvancedDialog'
import { ChannelAdvancedTab } from './ChannelAdvancedTab'
import type { ChannelStatus } from '../../../types/channel'
interface ChannelDetailPanelProps {
@@ -15,7 +15,7 @@ interface ChannelDetailPanelProps {
onDelete: () => void
}
const TABS = ['general', 'credentials', 'managers'] as const
const TABS = ['general', 'credentials', 'managers', 'advanced'] as const
type TabKey = (typeof TABS)[number]
export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: ChannelDetailPanelProps) {
@@ -23,7 +23,6 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha
const { instance, loading, updateInstance, listManagerGroups, listManagers, addManager, removeManager, listContacts } = useChannelDetail(instanceId)
const { agents } = useAgentCrud()
const [activeTab, setActiveTab] = useState<TabKey>('general')
const [advancedOpen, setAdvancedOpen] = useState(false)
if (loading || !instance) {
return (
@@ -65,14 +64,9 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha
<span className="text-[11px] text-text-muted shrink-0">{statusText}</span>
<span className="text-[11px] text-text-muted shrink-0">· {agentName}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button onClick={() => setAdvancedOpen(true)} className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors cursor-pointer">
{t('detail.advanced')}
</button>
<button onClick={onDelete} className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-error hover:bg-error/10 transition-colors cursor-pointer">
{t('delete.confirmLabel')}
</button>
</div>
<button onClick={onDelete} className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-error hover:bg-error/10 transition-colors cursor-pointer shrink-0">
{t('delete.confirmLabel')}
</button>
</div>
{/* Tabs */}
@@ -105,15 +99,8 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha
listContacts={listContacts}
/>
)}
{activeTab === 'advanced' && <ChannelAdvancedTab instance={instance} onUpdate={updateInstance} />}
</div>
{/* Advanced dialog */}
<ChannelAdvancedDialog
open={advancedOpen}
onOpenChange={setAdvancedOpen}
instance={instance}
onUpdate={updateInstance}
/>
</div>
)
}
@@ -24,9 +24,7 @@ export function ChannelManagersTab({
const [managersMap, setManagersMap] = useState<Record<string, GroupManagerData[]>>({})
const [loadingMap, setLoadingMap] = useState<Record<string, boolean>>({})
const [contactOptions, setContactOptions] = useState<{ value: string; label: string }[]>([])
// Per-group inline add: userId
const [inlineUserId, setInlineUserId] = useState<Record<string, string>>({})
// Standalone add form
const [newGroupId, setNewGroupId] = useState('')
const [newUserId, setNewUserId] = useState('')
const [addingMap, setAddingMap] = useState<Record<string, boolean>>({})
@@ -34,8 +32,7 @@ export function ChannelManagersTab({
const loadGroups = useCallback(async () => {
try {
const data = await listManagerGroups()
setGroups(data)
setGroups(await listManagerGroups())
} catch {
setGroups([])
}
@@ -49,6 +46,7 @@ export function ChannelManagersTab({
if (next && !managersMap[groupId]) {
setLoadingMap((prev) => ({ ...prev, [groupId]: true }))
try {
setManagersMap((prev) => ({ ...prev, [groupId]: [] }))
const data = await listManagers(groupId)
setManagersMap((prev) => ({ ...prev, [groupId]: data }))
} finally {
@@ -58,6 +56,7 @@ export function ChannelManagersTab({
}
const handleContactSearch = useCallback(async (search: string) => {
if (!search || search.length < 2) return
try {
const contacts = await listContacts(search)
setContactOptions(contacts.map((c) => ({
@@ -77,11 +76,10 @@ export function ChannelManagersTab({
try {
await addManager(groupId, userId)
setInlineUserId((prev) => ({ ...prev, [groupId]: '' }))
const data = await listManagers(groupId)
setManagersMap((prev) => ({ ...prev, [groupId]: data }))
setManagersMap((prev) => ({ ...prev, [groupId]: [...(prev[groupId] ?? []), { user_id: userId }] }))
await loadGroups()
} catch (err) {
setError(err instanceof Error ? err.message : t('managers.addFailed'))
setError(err instanceof Error ? err.message : t('detail.managers.addForm.errors.failedAdd'))
} finally {
setAddingMap((prev) => ({ ...prev, [groupId]: false }))
}
@@ -97,7 +95,7 @@ export function ChannelManagersTab({
}))
await loadGroups()
} catch (err) {
setError(err instanceof Error ? err.message : t('managers.removeFailed'))
setError(err instanceof Error ? err.message : 'Failed to remove')
}
}
@@ -112,73 +110,98 @@ export function ChannelManagersTab({
setNewGroupId('')
setNewUserId('')
await loadGroups()
// Auto-expand new group
setExpanded((prev) => ({ ...prev, [gid]: true }))
const data = await listManagers(gid)
setManagersMap((prev) => ({ ...prev, [gid]: data }))
} catch (err) {
setError(err instanceof Error ? err.message : t('managers.addFailed'))
setError(err instanceof Error ? err.message : t('detail.managers.addForm.errors.failedAdd'))
} finally {
setAddingMap((prev) => ({ ...prev, _new: false }))
}
}
const inputClass = 'bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent'
const inputClass = 'w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent'
return (
<div className="space-y-4">
<p className="text-xs text-text-muted">{t('detail.managers.description')}</p>
{error && <p className="text-xs text-error">{error}</p>}
{groups.length === 0 ? (
<p className="text-sm text-text-muted py-6 text-center">{t('managers.empty')}</p>
<div className="py-6 text-center">
<p className="text-xs text-text-muted">{t('detail.managers.noManagerGroups')}</p>
<p className="text-[11px] text-text-muted/70 mt-1">{t('detail.managers.noManagerGroupsHint')}</p>
</div>
) : (
<div className="space-y-2">
{groups.map((g) => (
<div key={g.group_id} className="border border-border rounded-lg overflow-hidden">
<button
onClick={() => handleToggle(g.group_id)}
className="w-full flex items-center justify-between px-4 py-3 bg-surface-secondary hover:bg-surface-tertiary transition-colors text-left cursor-pointer"
className="w-full flex items-center justify-between px-4 py-3 hover:bg-surface-tertiary/30 transition-colors text-left cursor-pointer"
>
<span className="text-sm font-medium text-text-primary font-mono">{shortGroupId(g.group_id)}</span>
<span className="text-xs font-medium text-text-primary font-mono">{shortGroupId(g.group_id)}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-text-muted">{g.writer_count} {t('managers.writers')}</span>
<svg className={`w-4 h-4 text-text-muted transition-transform ${expanded[g.group_id] ? 'rotate-180' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="m6 9 6 6 6-6" /></svg>
<span className="rounded-full bg-surface-tertiary px-2 py-0.5 text-[11px] text-text-muted tabular-nums">
{g.writer_count === 1
? t('detail.managers.managersCount', { count: g.writer_count })
: t('detail.managers.managersCountPlural', { count: g.writer_count })}
</span>
<svg className={`w-3.5 h-3.5 text-text-muted transition-transform ${expanded[g.group_id] ? 'rotate-90' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}><path d="M9 18l6-6-6-6" /></svg>
</div>
</button>
{expanded[g.group_id] && (
<div className="px-4 pb-3 pt-2 bg-surface-primary space-y-3">
<div className="border-t border-border bg-surface-tertiary/10 px-4 pb-3 pt-2 space-y-3">
{loadingMap[g.group_id] ? (
<p className="text-xs text-text-muted py-2">{t('common.loading')}</p>
<p className="text-xs text-text-muted py-2">{t('detail.managers.loadingManagers')}</p>
) : (managersMap[g.group_id] ?? []).length === 0 ? (
<p className="text-xs text-text-muted py-2">{t('managers.noManagers')}</p>
<p className="text-xs text-text-muted py-2">{t('detail.managers.noManagers')}</p>
) : (
<div className="divide-y divide-border">
{(managersMap[g.group_id] ?? []).map((m) => (
<div key={m.user_id} className="flex items-center justify-between py-2">
<div>
<span className="text-xs font-mono text-text-primary">{m.user_id}</span>
{m.display_name && <span className="text-xs text-text-muted ml-2">{m.display_name}</span>}
{m.username && <span className="text-xs text-text-muted ml-1">@{m.username}</span>}
</div>
<button onClick={() => handleRemove(g.group_id, m.user_id)} className="text-xs text-error hover:opacity-80 cursor-pointer transition-opacity">
{t('managers.remove')}
</button>
</div>
))}
<div className="rounded-md border border-border bg-surface-secondary overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-tertiary/40">
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.userId')}</th>
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.name')}</th>
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.username')}</th>
<th className="px-3 py-1.5 w-10" />
</tr>
</thead>
<tbody>
{(managersMap[g.group_id] ?? []).map((m) => (
<tr key={m.user_id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/20">
<td className="px-3 py-1.5 font-mono">{m.user_id}</td>
<td className="px-3 py-1.5 text-text-muted">{m.display_name || '—'}</td>
<td className="px-3 py-1.5 text-text-muted">{m.username ? `@${m.username}` : '—'}</td>
<td className="px-3 py-1.5 text-right">
<button onClick={() => handleRemove(g.group_id, m.user_id)} className="text-[11px] text-text-muted hover:text-error transition-colors cursor-pointer">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex gap-2 pt-1">
{/* Inline add */}
<div className="flex gap-2 items-end">
<div className="flex-1">
<Combobox
value={inlineUserId[g.group_id] ?? ''}
onChange={(v) => { setInlineUserId((prev) => ({ ...prev, [g.group_id]: v })); handleContactSearch(v) }}
options={contactOptions}
placeholder={t('managers.userIdPlaceholder')}
placeholder={t('detail.managers.addForm.userIdPlaceholder')}
/>
</div>
<button
onClick={() => handleInlineAdd(g.group_id)}
disabled={addingMap[g.group_id] || !inlineUserId[g.group_id]?.trim()}
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:opacity-90 transition-opacity"
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:bg-accent-hover transition-colors shrink-0"
>
{t('managers.add')}
{t('detail.managers.addForm.add')}
</button>
</div>
</div>
@@ -190,29 +213,32 @@ export function ChannelManagersTab({
{/* Standalone add form for new groups */}
<div className="border border-border rounded-lg p-4 space-y-3">
<p className="text-xs font-medium text-text-secondary">{t('managers.addToGroup')}</p>
<input
value={newGroupId}
onChange={(e) => setNewGroupId(e.target.value)}
placeholder={t('managers.groupIdPlaceholder')}
className={`w-full ${inputClass}`}
/>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={newUserId}
onChange={(v) => { setNewUserId(v); handleContactSearch(v) }}
options={contactOptions}
placeholder={t('managers.userIdPlaceholder')}
/>
<p className="text-xs font-medium text-text-secondary">{t('detail.managers.addForm.title')}</p>
<p className="text-[11px] text-text-muted">{t('detail.managers.addForm.hint')}</p>
<div className="space-y-2">
<input
value={newGroupId}
onChange={(e) => setNewGroupId(e.target.value)}
placeholder={t('detail.managers.addForm.groupIdPlaceholder')}
className={inputClass}
/>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={newUserId}
onChange={(v) => { setNewUserId(v); handleContactSearch(v) }}
options={contactOptions}
placeholder={t('detail.managers.addForm.userIdPlaceholder')}
/>
</div>
<button
onClick={handleStandaloneAdd}
disabled={addingMap['_new'] || !newGroupId.trim() || !newUserId.trim()}
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:bg-accent-hover transition-colors shrink-0"
>
{t('detail.managers.addForm.addManager')}
</button>
</div>
<button
onClick={handleStandaloneAdd}
disabled={addingMap['_new'] || !newGroupId.trim() || !newUserId.trim()}
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:opacity-90 transition-opacity"
>
{t('managers.add')}
</button>
</div>
</div>
</div>
@@ -10,11 +10,14 @@ export function usePairedDevices() {
const fetchDevices = useCallback(async () => {
try {
const ws = getWsClient()
const res = (await ws.call('device.pair.list')) as { pending: PendingPairing[]; paired: PairedDevice[] }
const raw = await ws.call('device.pair.list')
console.log('[paired-devices] raw response:', raw)
const res = raw as { pending: PendingPairing[]; paired: PairedDevice[] }
console.log('[paired-devices] pending:', res.pending?.length, 'paired:', res.paired?.length)
setPendingPairings(res.pending ?? [])
setPairedDevices(res.paired ?? [])
} catch {
// ignore — gateway may not be connected
} catch (err) {
console.error('[paired-devices] error:', err)
} finally {
setLoading(false)
}
@@ -41,7 +41,8 @@
"tabs": {
"general": "General",
"credentials": "Credentials",
"managers": "Managers"
"managers": "Managers",
"advanced": "Advanced"
},
"general": {
"identity": "Identity",
@@ -41,7 +41,8 @@
"tabs": {
"general": "Chung",
"credentials": "Xác thực",
"managers": "Quản lý"
"managers": "Quản lý",
"advanced": "Nâng cao"
},
"general": {
"identity": "Thông tin",
@@ -41,7 +41,8 @@
"tabs": {
"general": "常规",
"credentials": "凭据",
"managers": "管理员"
"managers": "管理员",
"advanced": "高级"
},
"general": {
"identity": "身份信息",