mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(secrets): GUI-managed .env secrets store — keys are config, but gitignored (#47)
API keys/secrets now live in /data/secrets/.env (gitignored, never in config
YAML), while remaining fully editable from the dashboard. Config YAML holds
only ${VAR} references.
Backend:
- meshai/secrets_store.py: get_status (SET/NOT-SET, never values), set_secret,
delete_secret over /data/secrets/.env (resolved like load_config); authoritative
SECRET_FIELD_TO_ENV map (traffic→TOMTOM_API_KEY, firms→FIRMS_MAP_KEY,
roads511→ROADS511_API_KEY, wzdx→WZDX_API_KEY, smtp→SMTP_PASSWORD,
mesh_sources→MESHMONITOR_API_TOKEN) + backend-dependent llm_env_var
- dashboard/api/secrets_routes.py: GET /api/secrets (status only), PUT/DELETE
/api/secrets/{env_var} (validated, restart_required); registered in server.py
- config_loader: save_section preserves ${VAR} secret refs on section save
(never rejects them); EXPECTED_SECRETS += ROADS511_API_KEY, WZDX_API_KEY
- config.example.yaml + docker-entrypoint default config use ${VAR} refs;
first-run bootstraps /data/secrets/.env; .gitignore covers it
Frontend:
- components/ManagedSecret.tsx: masked, Set/Not-set badge, reveal, Save->PUT,
"restart required"; carries no config value so secrets never enter a section
save payload
- wired into Environment (tomtom/roads511/wzdx/firms), Config LLM tab
(env var by backend), Notifications (smtp)
Restart required after a secret change (env read at config-load). 11 store
tests; suite at 10-failure baseline (1714 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a502778990
commit
3495eb31de
13 changed files with 477 additions and 21 deletions
117
work/dashboard-frontend/src/components/ManagedSecret.tsx
Normal file
117
work/dashboard-frontend/src/components/ManagedSecret.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
interface SecretEntry {
|
||||
env_var: string
|
||||
is_set: boolean
|
||||
fields?: string[]
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function ManagedSecret({ envVar, label = 'API Key', helper = '', info: _info = '' }: {
|
||||
envVar: string
|
||||
label?: string
|
||||
helper?: string
|
||||
info?: string
|
||||
}) {
|
||||
const [isSet, setIsSet] = useState<boolean | null>(null) // null = loading
|
||||
const [value, setValue] = useState('')
|
||||
const [show, setShow] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [restartMsg, setRestartMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/secrets')
|
||||
if (res.ok) {
|
||||
const data: SecretEntry[] = await res.json()
|
||||
const entry = data.find((e) => e.env_var === envVar)
|
||||
setIsSet(entry ? entry.is_set : false)
|
||||
}
|
||||
} catch {
|
||||
setIsSet(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [envVar])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!value.trim()) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setRestartMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/secrets/' + encodeURIComponent(envVar), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setValue('')
|
||||
setShow(false)
|
||||
setRestartMsg('Saved — restart required to take effect')
|
||||
await fetchStatus()
|
||||
} else {
|
||||
const body = await res.text()
|
||||
setError('Save failed: ' + (body || String(res.status)))
|
||||
}
|
||||
} catch {
|
||||
setError('Save failed: network error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const placeholder = isSet
|
||||
? 'env set — enter a new value to change'
|
||||
: 'not set — enter a value'
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
{label}
|
||||
{isSet === null ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded ml-2 bg-slate-800 text-slate-500">Loading</span>
|
||||
) : isSet ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded ml-2 bg-green-500/10 text-green-400">Set</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded ml-2 bg-slate-800 text-slate-500">Not set</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent placeholder-slate-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow(!show)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!value.trim() || saving}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
<p className="text-xs text-slate-600 font-mono">{envVar}</p>
|
||||
{restartMsg && <p className="text-xs text-yellow-400">{restartMsg}</p>}
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||
import { ManagedSecret } from '@/components/ManagedSecret'
|
||||
import { useDirty } from '@/context/DirtyContext'
|
||||
import NodePicker from '@/components/NodePicker'
|
||||
import ChannelPicker from '@/components/ChannelPicker'
|
||||
|
|
@ -1011,6 +1012,7 @@ function CommandsSection({ data, onChange }: { data: CommandsConfig; onChange: (
|
|||
}
|
||||
|
||||
function LLMSection({ data, onChange }: { data: LLMConfig; onChange: (d: LLMConfig) => void }) {
|
||||
const llmEnvVar = ({ openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', google: 'GOOGLE_API_KEY' } as Record<string, string>)[(data.backend || '').toLowerCase()] || 'LLM_API_KEY'
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionDescription text={SECTION_DESCRIPTIONS.llm} />
|
||||
|
|
@ -1036,13 +1038,10 @@ function LLMSection({ data, onChange }: { data: LLMConfig; onChange: (d: LLMConf
|
|||
info="The specific model to use. Common choices: gpt-4o-mini (fast, cheap), gpt-4o (better, costs more), claude-sonnet-4-20250514 (Anthropic equivalent). For local models via Ollama, use the model name you pulled (e.g. llama3.1)."
|
||||
/>
|
||||
</div>
|
||||
<TextInput
|
||||
<ManagedSecret
|
||||
envVar={llmEnvVar}
|
||||
label="API Key"
|
||||
value={data.api_key}
|
||||
onChange={(v) => onChange({ ...data, api_key: v })}
|
||||
type="password"
|
||||
helper="Supports ${ENV_VAR} syntax"
|
||||
info="Your API key from the provider. You can also use ${ENV_VAR} syntax to read from an environment variable instead of storing the key in the config file."
|
||||
helper="Secret stored in /data/secrets/.env; config holds the ${VAR} ref"
|
||||
/>
|
||||
<TextInput
|
||||
label="Base URL"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@/lib/api'
|
||||
import { TOGGLE_FAMILY_META, type NotificationToggle, type NotificationsConfig } from './Notifications'
|
||||
import AdapterConfig, { CURATED_KEYS } from './AdapterConfig'
|
||||
import { ManagedSecret } from '@/components/ManagedSecret'
|
||||
|
||||
type FeedSource = 'native' | 'central'
|
||||
|
||||
|
|
@ -1153,7 +1154,7 @@ const save = async () => {
|
|||
</div>
|
||||
)
|
||||
case 'traffic': return (<>
|
||||
<TextInput label="API Key" value={env.traffic.api_key} onChange={(v) => up({ traffic: { ...env.traffic, api_key: v } })} type="password" helper="developer.tomtom.com" />
|
||||
<ManagedSecret envVar="TOMTOM_API_KEY" label="API Key" helper="developer.tomtom.com" />
|
||||
<NumberInput label="Tick Seconds" value={env.traffic.tick_seconds} onChange={(v) => up({ traffic: { ...env.traffic, tick_seconds: v } })} min={60} />
|
||||
<div className="text-xs text-[#666] mt-2">Corridors:</div>
|
||||
{(env.traffic.corridors || []).map((c, i) => (
|
||||
|
|
@ -1197,7 +1198,7 @@ const save = async () => {
|
|||
</>)
|
||||
case 'roads511': return (<>
|
||||
<TextInput label="Base URL" value={env.roads511.base_url} onChange={(v) => up({ roads511: { ...env.roads511, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" />
|
||||
<TextInput label="API Key" value={env.roads511.api_key} onChange={(v) => up({ roads511: { ...env.roads511, api_key: v } })} type="password" helper="Leave empty if not required" />
|
||||
<ManagedSecret envVar="ROADS511_API_KEY" label="API Key" helper="Leave unset if 511 needs no key" />
|
||||
<NumberInput label="Tick Seconds" value={env.roads511.tick_seconds} onChange={(v) => up({ roads511: { ...env.roads511, tick_seconds: v } })} min={60} />
|
||||
<ListInput label="Endpoints" value={env.roads511.endpoints} onChange={(v) => up({ roads511: { ...env.roads511, endpoints: v } })} helper="e.g., /get/event" />
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
|
|
@ -1254,7 +1255,7 @@ const save = async () => {
|
|||
{env.wzdx?.feed_source !== 'central' && (
|
||||
<>
|
||||
<TextInput label="Base URL" value={env.wzdx?.base_url ?? ''} onChange={(v) => up({ wzdx: { ...env.wzdx!, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" />
|
||||
<TextInput label="API Key" value={env.wzdx?.api_key ?? ''} onChange={(v) => up({ wzdx: { ...env.wzdx!, api_key: v } })} type="password" helper="Leave empty if not required" />
|
||||
<ManagedSecret envVar="WZDX_API_KEY" label="API Key" helper="Leave unset if not required" />
|
||||
<NumberInput label="Tick Seconds" value={env.wzdx?.tick_seconds ?? 300} onChange={(v) => up({ wzdx: { ...env.wzdx!, tick_seconds: v } })} min={60} />
|
||||
<ListInput label="Endpoints" value={env.wzdx?.endpoints ?? ['/get/event']} onChange={(v) => up({ wzdx: { ...env.wzdx!, endpoints: v } })} helper="e.g., /get/event" />
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
|
|
@ -1312,7 +1313,7 @@ const save = async () => {
|
|||
</div>
|
||||
</>)
|
||||
case 'firms': return (<>
|
||||
<TextInput label="MAP Key" value={env.firms.map_key} onChange={(v) => up({ firms: { ...env.firms, map_key: v } })} type="password" helper="firms.modaps.eosdis.nasa.gov/api/area/" infoLink="https://firms.modaps.eosdis.nasa.gov/api/area/" />
|
||||
<ManagedSecret envVar="FIRMS_MAP_KEY" label="MAP Key" helper="NASA FIRMS MAP_KEY" />
|
||||
<NumberInput label="Tick Seconds" value={env.firms.tick_seconds} onChange={(v) => up({ firms: { ...env.firms, tick_seconds: v } })} min={300} />
|
||||
<SelectInput label="Satellite Source" value={env.firms.source} onChange={(v) => up({ firms: { ...env.firms, source: v } })} options={[{ value: 'VIIRS_SNPP_NRT', label: 'VIIRS SNPP (NRT)' }, { value: 'VIIRS_NOAA20_NRT', label: 'VIIRS NOAA-20 (NRT)' }, { value: 'MODIS_NRT', label: 'MODIS (NRT)' }]} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import ChannelPicker from '@/components/ChannelPicker'
|
||||
import NodePicker from '@/components/NodePicker'
|
||||
import { useDirty } from '@/context/DirtyContext'
|
||||
import { ManagedSecret } from '@/components/ManagedSecret'
|
||||
|
||||
// Types
|
||||
interface NotificationRuleConfig {
|
||||
|
|
@ -1341,12 +1342,10 @@ function NotificationRuleCard({
|
|||
value={rule.smtp_user || ''}
|
||||
onChange={(v) => onChange({ ...rule, smtp_user: v })}
|
||||
/>
|
||||
<TextInput
|
||||
<ManagedSecret
|
||||
envVar="SMTP_PASSWORD"
|
||||
label="Password"
|
||||
value={rule.smtp_password || ''}
|
||||
onChange={(v) => onChange({ ...rule, smtp_password: v })}
|
||||
type="password"
|
||||
info="Gmail users: use an App Password from myaccount.google.com/apppasswords"
|
||||
helper="SMTP password (App Password for Gmail)"
|
||||
/>
|
||||
</div>
|
||||
<Toggle
|
||||
|
|
@ -1763,7 +1762,7 @@ function OtherChannelsGrid({
|
|||
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
|
||||
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
|
||||
<TextInput label="Username" value={t.smtp_user || ''} onChange={(v) => upd(key, { smtp_user: v })} />
|
||||
<TextInput label="Password" value={t.smtp_password || ''} onChange={(v) => upd(key, { smtp_password: v })} type="password" />
|
||||
<ManagedSecret envVar="SMTP_PASSWORD" label="Password" helper="SMTP password (App Password for Gmail)" />
|
||||
<Toggle label="Use TLS" checked={t.smtp_tls ?? true} onChange={(v) => upd(key, { smtp_tls: v })} />
|
||||
<TextInput label="From address" value={t.from_address || ''} onChange={(v) => upd(key, { from_address: v })} placeholder="alerts@example.com" />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue