mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
MeshCore: display per-channel key (PSK) with reveal + copy
The Observe MeshCore Channels list now shows each channel's PSK hex so operators can share it with people who want to join. Fetches the /api/meshcore/channels/detail endpoint (name + key) instead of the names-only list; keys are masked by default with a per-row eye reveal toggle and a copy-to-clipboard button. Channels with no retrievable key show a dash. Existing observe (checkbox), remove (trash), and add-row controls are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9498779608
commit
57125964af
1 changed files with 86 additions and 9 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Save, RotateCcw, RefreshCw, Check, ChevronRight, Trash2 } from 'lucide-react'
|
import { Save, RotateCcw, RefreshCw, Check, ChevronRight, Trash2, Eye, EyeOff, Copy } from 'lucide-react'
|
||||||
import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config'
|
import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config'
|
||||||
import SerialPortPicker from '@/components/SerialPortPicker'
|
import SerialPortPicker from '@/components/SerialPortPicker'
|
||||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
fetchConfig as apiFetchConfig,
|
fetchConfig as apiFetchConfig,
|
||||||
updateConfig as apiUpdateConfig,
|
updateConfig as apiUpdateConfig,
|
||||||
getMeshcoreChannels,
|
getMeshcoreChannels,
|
||||||
|
getMeshcoreChannelsDetail,
|
||||||
addMeshcoreChannel,
|
addMeshcoreChannel,
|
||||||
removeMeshcoreChannel,
|
removeMeshcoreChannel,
|
||||||
sendTestMessage,
|
sendTestMessage,
|
||||||
|
|
@ -61,6 +62,11 @@ export default function MeshCoreConnection() {
|
||||||
// Test send state
|
// Test send state
|
||||||
const [channelsActive, setChannelsActive] = useState(false)
|
const [channelsActive, setChannelsActive] = useState(false)
|
||||||
const [channels, setChannels] = useState<string[]>([])
|
const [channels, setChannels] = useState<string[]>([])
|
||||||
|
// Per-channel PSK hex (name -> key), captured from the companion so operators
|
||||||
|
// can share the key with people who want to join. Masked by default.
|
||||||
|
const [channelKeys, setChannelKeys] = useState<Record<string, string | null>>({})
|
||||||
|
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set())
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||||
const [selectedChannel, setSelectedChannel] = useState('')
|
const [selectedChannel, setSelectedChannel] = useState('')
|
||||||
const [testText, setTestText] = useState('')
|
const [testText, setTestText] = useState('')
|
||||||
const [testSending, setTestSending] = useState(false)
|
const [testSending, setTestSending] = useState(false)
|
||||||
|
|
@ -100,12 +106,26 @@ export default function MeshCoreConnection() {
|
||||||
|
|
||||||
const refreshChannels = useCallback(async () => {
|
const refreshChannels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getMeshcoreChannels()
|
// Prefer the detail endpoint (name + PSK key); it carries everything the
|
||||||
setChannelsActive(res.active)
|
// names-only list does. Fall back to names-only if detail is unavailable.
|
||||||
setChannels(res.channels)
|
const detail = await getMeshcoreChannelsDetail()
|
||||||
setSelectedChannel((prev) => (prev && res.channels.includes(prev) ? prev : res.channels[0] ?? ''))
|
const names = detail.channels.map((c) => c.name)
|
||||||
|
const keyMap: Record<string, string | null> = {}
|
||||||
|
for (const c of detail.channels) keyMap[c.name] = c.key
|
||||||
|
setChannelsActive(detail.active)
|
||||||
|
setChannels(names)
|
||||||
|
setChannelKeys(keyMap)
|
||||||
|
setSelectedChannel((prev) => (prev && names.includes(prev) ? prev : names[0] ?? ''))
|
||||||
} catch {
|
} catch {
|
||||||
setChannelsActive(false)
|
try {
|
||||||
|
const res = await getMeshcoreChannels()
|
||||||
|
setChannelsActive(res.active)
|
||||||
|
setChannels(res.channels)
|
||||||
|
setChannelKeys({})
|
||||||
|
setSelectedChannel((prev) => (prev && res.channels.includes(prev) ? prev : res.channels[0] ?? ''))
|
||||||
|
} catch {
|
||||||
|
setChannelsActive(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -113,6 +133,27 @@ export default function MeshCoreConnection() {
|
||||||
refreshChannels()
|
refreshChannels()
|
||||||
}, [refreshChannels])
|
}, [refreshChannels])
|
||||||
|
|
||||||
|
const toggleRevealKey = (name: string) => {
|
||||||
|
setRevealedKeys((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(name)) next.delete(name)
|
||||||
|
else next.add(name)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyKey = async (name: string, key: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key)
|
||||||
|
setCopiedKey(name)
|
||||||
|
setTimeout(() => setCopiedKey((prev) => (prev === name ? null : prev)), 1500)
|
||||||
|
} catch {
|
||||||
|
// Clipboard API can be blocked (non-secure context); reveal the key so
|
||||||
|
// the operator can copy it manually instead of failing silently.
|
||||||
|
setRevealedKeys((prev) => new Set(prev).add(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddChannel = async () => {
|
const handleAddChannel = async () => {
|
||||||
const name = newChannelName.trim()
|
const name = newChannelName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
@ -409,6 +450,8 @@ export default function MeshCoreConnection() {
|
||||||
<div className="border border-[#1e2a3a] p-2 space-y-1">
|
<div className="border border-[#1e2a3a] p-2 space-y-1">
|
||||||
{channels.map((ch) => {
|
{channels.map((ch) => {
|
||||||
const selected = (mcContext.observe_channels ?? []).includes(ch)
|
const selected = (mcContext.observe_channels ?? []).includes(ch)
|
||||||
|
const key = channelKeys[ch] ?? null
|
||||||
|
const revealed = revealedKeys.has(ch)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ch}
|
key={ch}
|
||||||
|
|
@ -416,7 +459,7 @@ export default function MeshCoreConnection() {
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
onClick={() => toggleObserveChannel(ch)}
|
onClick={() => toggleObserveChannel(ch)}
|
||||||
className="flex items-center gap-2 flex-1 cursor-pointer"
|
className="flex items-center gap-2 cursor-pointer shrink-0"
|
||||||
>
|
>
|
||||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||||
selected ? 'bg-accent border-accent' : 'border-slate-600'
|
selected ? 'bg-accent border-accent' : 'border-slate-600'
|
||||||
|
|
@ -425,13 +468,47 @@ export default function MeshCoreConnection() {
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-slate-200">{ch}</span>
|
<span className="text-sm text-slate-200">{ch}</span>
|
||||||
</label>
|
</label>
|
||||||
|
{/* Channel key (PSK hex) — share this to let others join. Masked by
|
||||||
|
default; reveal per-row with the eye, copy with the copy button. */}
|
||||||
|
<div className="flex items-center gap-1 flex-1 min-w-0 justify-end">
|
||||||
|
{key ? (
|
||||||
|
<>
|
||||||
|
<code
|
||||||
|
title={revealed ? key : 'Key hidden — click the eye to reveal'}
|
||||||
|
className="text-xs font-mono text-slate-400 truncate max-w-[16rem]"
|
||||||
|
>
|
||||||
|
{revealed ? key : '••••••••••••••••'}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={revealed ? 'Hide key' : 'Reveal key'}
|
||||||
|
aria-label={revealed ? `Hide key for ${ch}` : `Reveal key for ${ch}`}
|
||||||
|
onClick={() => toggleRevealKey(ch)}
|
||||||
|
className="p-1 text-slate-600 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Copy key to clipboard"
|
||||||
|
aria-label={`Copy key for ${ch}`}
|
||||||
|
onClick={() => handleCopyKey(ch, key)}
|
||||||
|
className="p-1 text-slate-600 hover:text-accent"
|
||||||
|
>
|
||||||
|
{copiedKey === ch ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-mono text-slate-600" title="No retrievable key for this channel">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={`Remove channel '${ch}' from the companion`}
|
title={`Remove channel '${ch}' from the companion`}
|
||||||
aria-label={`Remove channel ${ch}`}
|
aria-label={`Remove channel ${ch}`}
|
||||||
disabled={channelRemoving === ch}
|
disabled={channelRemoving === ch}
|
||||||
onClick={() => handleRemoveChannel(ch)}
|
onClick={() => handleRemoveChannel(ch)}
|
||||||
className="p-1 text-slate-600 hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="p-1 text-slate-600 hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -444,7 +521,7 @@ export default function MeshCoreConnection() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-600">Choose which MeshCore channels feed MeshAI's context. Empty = none are watched — pick channels to include their chatter in what the bot knows about the mesh. Leave busy/public channels out to keep them out of context.</p>
|
<p className="text-xs text-slate-600">Choose which MeshCore channels feed MeshAI's context. Empty = none are watched — pick channels to include their chatter in what the bot knows about the mesh. Leave busy/public channels out to keep them out of context. Each channel's key (PSK) is shown on the right — reveal and copy it to share with people who want to join.</p>
|
||||||
|
|
||||||
{/* Add a new channel (name + PSK) to the companion's channel table */}
|
{/* Add a new channel (name + PSK) to the companion's channel table */}
|
||||||
<div className="flex items-end gap-2 pt-2">
|
<div className="flex items-end gap-2 pt-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue