mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(meshcore): set/clear room-server passwords from the routing picker (#112)
Adds PUT/DELETE /api/meshcore/room-password/{pubkey} (dedicated route — the
generic secrets allowlist rejects dynamic per-room vars) and a password_set
flag on GET /api/meshcore/rooms. The routing picker gains an inline lock +
set/clear editor on room-mode cells; state is keyed by room pubkey and shared
across cells targeting the same room. Send-time login already reads the stored
password via secrets_store — no dispatch changes needed.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
This commit is contained in:
parent
2cc672e751
commit
22037c9a23
4 changed files with 156 additions and 9 deletions
|
|
@ -614,6 +614,7 @@ export interface MeshcoreRoom {
|
|||
pubkey: string
|
||||
prefix: string
|
||||
path_established: boolean
|
||||
password_set?: boolean
|
||||
}
|
||||
export interface MeshcoreRooms {
|
||||
active: boolean
|
||||
|
|
@ -624,6 +625,36 @@ export async function getMeshcoreRooms(): Promise<MeshcoreRooms> {
|
|||
return fetchJson<MeshcoreRooms>('/api/meshcore/rooms')
|
||||
}
|
||||
|
||||
// Set (or clear, if password is empty) a MeshCore room server's login password.
|
||||
// The value is never read back — only a boolean status is returned.
|
||||
export async function setRoomPassword(
|
||||
pubkey: string,
|
||||
password: string,
|
||||
): Promise<{ ok: boolean; password_set: boolean }> {
|
||||
const response = await fetch(`/api/meshcore/room-password/${encodeURIComponent(pubkey)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: password }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Clear a MeshCore room server's stored login password.
|
||||
export async function clearRoomPassword(
|
||||
pubkey: string,
|
||||
): Promise<{ ok: boolean; password_set: boolean }> {
|
||||
const response = await fetch(`/api/meshcore/room-password/${encodeURIComponent(pubkey)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export interface MeshcoreContact {
|
||||
name: string | null
|
||||
pubkey: string
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
type RegionCell,
|
||||
type RegionRoutes,
|
||||
} from './Notifications'
|
||||
import { getMeshcoreRooms, type MeshcoreRoom } from '@/lib/api'
|
||||
import { getMeshcoreRooms, setRoomPassword, clearRoomPassword, type MeshcoreRoom } from '@/lib/api'
|
||||
|
||||
// Merge only the MeshCore-owned fields of `mine` into `fresh`, preserving every
|
||||
// other (Meshtastic / Other-channels / general) field on the family. The
|
||||
|
|
@ -90,12 +90,6 @@ function mergeRegionRoutesForMc(
|
|||
return { mt_enabled, mc_enabled, cells: newCells }
|
||||
}
|
||||
|
||||
// TODO: room password UI needs a backend secrets endpoint. secrets_store.py
|
||||
// has set/get/clear_room_password() keyed by room pubkey, but NO HTTP route
|
||||
// exposes them (the generic /api/secrets endpoint is gated to a fixed
|
||||
// SECRET_LABELS allowlist, so it cannot set a dynamic per-room password).
|
||||
// Open rooms route fine without a password; password-protected rooms need
|
||||
// that endpoint before a set/clear affordance can be added here.
|
||||
export default function MeshCoreRouting() {
|
||||
const { setDirty } = useDirty()
|
||||
const [config, setConfig] = useState<NotificationsConfig | null>(null)
|
||||
|
|
@ -110,6 +104,10 @@ export default function MeshCoreRouting() {
|
|||
// targeted by writing the cell value as `room:<pubkey>`; channels stay bare.
|
||||
const [rooms, setRooms] = useState<MeshcoreRoom[]>([])
|
||||
const [roomsActive, setRoomsActive] = useState(false)
|
||||
// Inline room-password editor: open for at most one room pubkey at a time.
|
||||
const [pwdEditorPk, setPwdEditorPk] = useState<string | null>(null)
|
||||
const [pwdInput, setPwdInput] = useState('')
|
||||
const [pwdError, setPwdError] = useState<string | null>(null)
|
||||
|
||||
// Local map: family key -> explicitly toggled on/off for region expand.
|
||||
const [regionExpandedMap, setRegionExpandedMap] = useState<Record<string, boolean | undefined>>({})
|
||||
|
|
@ -519,7 +517,8 @@ export default function MeshCoreRouting() {
|
|||
</button>
|
||||
</div>
|
||||
{targetsRoom ? (
|
||||
roomsActive || room ? (
|
||||
<>
|
||||
{roomsActive || room ? (
|
||||
<div className="flex items-center gap-1 w-40">
|
||||
<select
|
||||
value={room ? room.pubkey : ''}
|
||||
|
|
@ -551,7 +550,86 @@ export default function MeshCoreRouting() {
|
|||
</div>
|
||||
) : (
|
||||
<span className="w-40 text-xs text-slate-600 italic truncate">MeshCore not connected</span>
|
||||
)
|
||||
)}
|
||||
{roomPubkeyOf(mcVal) && (() => {
|
||||
const pk = roomPubkeyOf(mcVal)
|
||||
const isSet = rooms.find((r) => r.pubkey === pk)?.password_set ?? false
|
||||
const editing = pwdEditorPk === pk
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
title={isSet ? 'Room password: set' : 'Room password: not set'}
|
||||
aria-label={isSet ? 'Room password: set' : 'Room password: not set'}
|
||||
onClick={() => {
|
||||
setPwdError(null)
|
||||
if (editing) { setPwdEditorPk(null) }
|
||||
else { setPwdEditorPk(pk); setPwdInput('') }
|
||||
}}
|
||||
className={`px-1 py-1 text-xs ${isSet ? 'text-accent' : 'text-slate-600 hover:text-slate-400'}`}
|
||||
>
|
||||
{isSet ? '🔒' : '🔓'}
|
||||
</button>
|
||||
{editing && (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="password"
|
||||
value={pwdInput}
|
||||
onChange={(e) => setPwdInput(e.target.value)}
|
||||
placeholder="room password"
|
||||
className="w-28 px-1.5 py-1 bg-[#0a0e17] border border-[#1e2a3a] rounded text-xs text-slate-200 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="Save room password"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setPwdError(null)
|
||||
await setRoomPassword(pk, pwdInput)
|
||||
await fetchRooms()
|
||||
setPwdEditorPk(null)
|
||||
setPwdInput('')
|
||||
} catch {
|
||||
setPwdError('save failed')
|
||||
}
|
||||
}}
|
||||
className="px-1.5 py-1 bg-accent hover:bg-accent/80 rounded text-xs text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Clear room password"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setPwdError(null)
|
||||
await clearRoomPassword(pk)
|
||||
await fetchRooms()
|
||||
setPwdEditorPk(null)
|
||||
setPwdInput('')
|
||||
} catch {
|
||||
setPwdError('clear failed')
|
||||
}
|
||||
}}
|
||||
className="px-1.5 py-1 border border-[#1e2a3a] rounded text-xs text-slate-400 hover:text-slate-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Cancel"
|
||||
onClick={() => { setPwdEditorPk(null); setPwdError(null) }}
|
||||
className="px-1.5 py-1 border border-[#1e2a3a] rounded text-xs text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{pwdError && <span className="text-[10px] text-red-400">{pwdError}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import Optional, Union
|
|||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
from meshai import secrets_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["mesh-send"])
|
||||
|
|
@ -72,6 +73,11 @@ async def meshcore_rooms(request: Request):
|
|||
rooms = list(mc.get_rooms())
|
||||
except Exception:
|
||||
rooms = []
|
||||
for r in rooms:
|
||||
try:
|
||||
r["password_set"] = secrets_store.room_password_is_set(r.get("pubkey") or "")
|
||||
except Exception:
|
||||
r["password_set"] = False
|
||||
return {"active": True, "rooms": rooms}
|
||||
return {"active": False, "rooms": []}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,3 +40,35 @@ async def delete_secret(env_var: str):
|
|||
raise HTTPException(status_code=400, detail="unknown secret var")
|
||||
secrets_store.delete_secret(env_var)
|
||||
return {"ok": True, "restart_required": True}
|
||||
|
||||
|
||||
_ROOM_PK_HEX = set("0123456789abcdefABCDEF")
|
||||
|
||||
|
||||
def _valid_room_pubkey(pk: str) -> bool:
|
||||
return len(pk) >= 12 and all(c in _ROOM_PK_HEX for c in pk)
|
||||
|
||||
|
||||
@router.put("/meshcore/room-password/{pubkey}")
|
||||
async def set_room_password_route(pubkey: str, body: SecretUpdate):
|
||||
"""Set (or, if the value is empty, clear) a MeshCore room server's password.
|
||||
The value is never read back — only a boolean status is returned."""
|
||||
pk = pubkey.strip()
|
||||
if not _valid_room_pubkey(pk):
|
||||
raise HTTPException(status_code=400, detail="invalid room pubkey")
|
||||
value = (body.value or "").strip()
|
||||
if value:
|
||||
secrets_store.set_room_password(pk, value)
|
||||
else:
|
||||
secrets_store.delete_room_password(pk)
|
||||
return {"ok": True, "password_set": secrets_store.room_password_is_set(pk)}
|
||||
|
||||
|
||||
@router.delete("/meshcore/room-password/{pubkey}")
|
||||
async def clear_room_password_route(pubkey: str):
|
||||
"""Clear a MeshCore room server's stored password."""
|
||||
pk = pubkey.strip()
|
||||
if not _valid_room_pubkey(pk):
|
||||
raise HTTPException(status_code=400, detail="invalid room pubkey")
|
||||
secrets_store.delete_room_password(pk)
|
||||
return {"ok": True, "password_set": False}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue