portal: 'service consumer' checkbox for MQTT broker clients

Surfaces settings' new service-client flag in the MQTT Broker Clients tab —
a checkbox that stores the password encrypted so a stack app (e.g.
hvac-backend) can fetch its own login at runtime, plus a 'service' badge in
the client list. Device logins are unchanged (leave the box off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-13 13:42:58 +00:00
parent d1b253a00c
commit 411911c4c8

View file

@ -929,6 +929,7 @@ interface MqttClient {
topic_scope: string
can_publish: boolean
can_subscribe: boolean
is_service: boolean
active: boolean
created_at: string
revoked_at: string | null
@ -942,6 +943,7 @@ function MqttClientsTab() {
const [topicScope, setTopicScope] = useState('')
const [canPublish, setCanPublish] = useState(false)
const [canSubscribe, setCanSubscribe] = useState(true)
const [isService, setIsService] = useState(false)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const [created, setCreated] = useState<{ username: string; password: string } | null>(null)
@ -969,14 +971,14 @@ function MqttClientsTab() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(), topic_scope: topicScope.trim(),
can_publish: canPublish, can_subscribe: canSubscribe,
can_publish: canPublish, can_subscribe: canSubscribe, is_service: isService,
}),
})
const data = await res.json()
if (!res.ok) { setError(data.error || 'Failed to create client'); return }
setCreated({ username: data.username, password: data.password })
setShowForm(false)
setName(''); setTopicScope(''); setCanPublish(false); setCanSubscribe(true)
setName(''); setTopicScope(''); setCanPublish(false); setCanSubscribe(true); setIsService(false)
load()
} finally {
setCreating(false)
@ -1050,6 +1052,17 @@ function MqttClientsTab() {
<input type="checkbox" checked={canSubscribe} onChange={e => setCanSubscribe(e.target.checked)} /> Can subscribe
</label>
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.4rem', fontSize: '0.85rem', color: 'var(--text-dark)' }}>
<input type="checkbox" checked={isService} onChange={e => setIsService(e.target.checked)} style={{ marginTop: '0.2rem' }} />
<span>
Service consumer (stack app)
<span style={{ display: 'block', fontSize: '0.76rem', color: 'var(--text-mid)', fontWeight: 400 }}>
Stores the password encrypted so a stack app can fetch its own login at runtime needed for apps like
<code style={{ margin: '0 0.25em' }}>hvac-backend</code> that reconnect on restart. Name it to match the app's
client id; its username becomes the name's slug (no random suffix). Leave off for device logins (Shelly, gateways).
</span>
</span>
</label>
<div>
<button type="submit" disabled={creating} style={{
background: 'var(--gold)', color: 'var(--navy)', border: 'none',
@ -1119,6 +1132,15 @@ function MqttClientsTab() {
<span style={{ marginLeft: '0.5rem', fontFamily: 'monospace', fontSize: '0.78rem', color: 'var(--text-mid)', fontWeight: 400 }}>
{c.username}
</span>
{c.is_service && (
<span style={{
marginLeft: '0.5rem', fontSize: '0.68rem', fontWeight: 600, color: 'var(--navy)',
background: 'var(--surface-2)', border: '1px solid var(--card-border)',
borderRadius: '4px', padding: '0.05rem 0.4rem', verticalAlign: 'middle',
}}>
service
</span>
)}
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', fontFamily: 'monospace' }}>
{c.topic_scope}