Admin settings: simplified MQTT client form, reveal/reset password, open access
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e3b47f6e19
commit
60c27df45c
1 changed files with 57 additions and 46 deletions
|
|
@ -946,13 +946,10 @@ function MqttClientsTab() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
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)
|
||||
const [revealed, setRevealed] = useState<{ id: number; username: string; password: string } | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
|
@ -968,23 +965,18 @@ function MqttClientsTab() {
|
|||
e.preventDefault()
|
||||
setError('')
|
||||
if (!name.trim()) { setError('Name is required'); return }
|
||||
if (!topicScope.trim()) { setError('Topic scope is required'); return }
|
||||
if (!canPublish && !canSubscribe) { setError('Enable at least one of publish or subscribe'); return }
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await fetch('/settings/api/mqtt-clients', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(), topic_scope: topicScope.trim(),
|
||||
can_publish: canPublish, can_subscribe: canSubscribe, is_service: isService,
|
||||
}),
|
||||
body: JSON.stringify({ name: name.trim(), 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 })
|
||||
setRevealed({ id: data.id, username: data.username, password: data.password })
|
||||
setShowForm(false)
|
||||
setName(''); setTopicScope(''); setCanPublish(false); setCanSubscribe(true); setIsService(false)
|
||||
setName(''); setIsService(false)
|
||||
load()
|
||||
} finally {
|
||||
setCreating(false)
|
||||
|
|
@ -1016,9 +1008,32 @@ function MqttClientsTab() {
|
|||
load()
|
||||
}
|
||||
|
||||
async function reveal(c: MqttClient) {
|
||||
const res = await fetch(`/settings/api/mqtt-clients/${c.id}/password`, { credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { alert(data.error || 'Failed to load password'); return }
|
||||
setRevealed({ id: c.id, username: c.username, password: data.password })
|
||||
}
|
||||
|
||||
async function resetPassword(c: MqttClient) {
|
||||
if (!confirm(`Generate a new password for "${c.username}"? Anything still using the old one will be refused until updated.`)) return
|
||||
const res = await fetch(`/settings/api/mqtt-clients/${c.id}/password`, { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { alert(data.error || 'Failed to reset password'); return }
|
||||
setRevealed({ id: c.id, username: c.username, password: data.password })
|
||||
}
|
||||
|
||||
async function openAccess(c: MqttClient) {
|
||||
if (!confirm(`Give "${c.username}" access to all topics? Its current topic restrictions are removed.`)) return
|
||||
const res = await fetch(`/settings/api/mqtt-clients/${c.id}/open-access`, { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { alert(data.error || 'Failed'); return }
|
||||
load()
|
||||
}
|
||||
|
||||
function copyPassword() {
|
||||
if (!created) return
|
||||
navigator.clipboard.writeText(created.password)
|
||||
if (!revealed) return
|
||||
navigator.clipboard.writeText(revealed.password)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
|
@ -1031,7 +1046,7 @@ function MqttClientsTab() {
|
|||
MQTT Broker Clients
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)' }}>
|
||||
Per-device/app broker logins, scoped to a topic filter — no more manually running mosquitto_ctrl.
|
||||
Broker logins with access to all topics. Reuse one login across a batch of similar devices — passwords can be revealed any time.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowForm(!showForm); setError('') }} style={{
|
||||
|
|
@ -1053,27 +1068,14 @@ function MqttClientsTab() {
|
|||
<XCircle size={14} strokeWidth={1.75} /> {error}
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>Name</span>
|
||||
<input value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Kitchen Flash Reader" style={inputStyle} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-mid)' }}>
|
||||
Topic scope
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-mid)' }}> — one per line if it needs more than one</span>
|
||||
Name
|
||||
<span style={{ fontWeight: 400 }}> — becomes the username (lowercased, hyphenated)</span>
|
||||
</span>
|
||||
<textarea value={topicScope} onChange={e => setTopicScope(e.target.value)} rows={2}
|
||||
placeholder={'e.g. shellies/#\nhvac/mhi/#'} style={{ ...inputStyle, fontFamily: 'monospace', resize: 'vertical' }} />
|
||||
<input value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Tasmota Lights" style={inputStyle} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '1.5rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem', color: 'var(--text-dark)' }}>
|
||||
<input type="checkbox" checked={canPublish} onChange={e => setCanPublish(e.target.checked)} /> Can publish
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem', color: 'var(--text-dark)' }}>
|
||||
<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>
|
||||
|
|
@ -1081,7 +1083,7 @@ function MqttClientsTab() {
|
|||
<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).
|
||||
client id. Leave off for device logins (Shelly, Tasmota, gateways).
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
|
@ -1097,24 +1099,24 @@ function MqttClientsTab() {
|
|||
</form>
|
||||
)}
|
||||
|
||||
{created && (
|
||||
{revealed && (
|
||||
<div style={{
|
||||
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: 'var(--radius)',
|
||||
padding: '1.25rem', marginBottom: '1.25rem', boxShadow: 'var(--shadow-sm)',
|
||||
}}>
|
||||
<p style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-dark)', marginBottom: '0.5rem' }}>
|
||||
Client created — copy this password now, it won't be shown again
|
||||
Login for {revealed.username}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-mid)' }}>
|
||||
Username: <code style={{ background: 'var(--surface-2)', padding: '0.1em 0.4em', borderRadius: 3 }}>{created.username}</code>
|
||||
Username: <code style={{ background: 'var(--surface-2)', padding: '0.1em 0.4em', borderRadius: 3 }}>{revealed.username}</code>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<code style={{
|
||||
flex: 1, fontSize: '0.8rem', color: 'var(--text-dark)', background: 'var(--body-bg)',
|
||||
border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.55rem 0.75rem',
|
||||
wordBreak: 'break-all', fontFamily: 'monospace',
|
||||
}}>{created.password}</code>
|
||||
}}>{revealed.password}</code>
|
||||
<button onClick={copyPassword} style={{
|
||||
background: copied ? '#16a34a' : 'var(--body-bg)', border: '1px solid var(--card-border)',
|
||||
borderRadius: '5px', padding: '0.4rem 0.7rem', fontSize: '0.78rem', fontWeight: 600,
|
||||
|
|
@ -1125,7 +1127,7 @@ function MqttClientsTab() {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setCreated(null)} style={{
|
||||
<button onClick={() => setRevealed(null)} style={{
|
||||
background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '0.78rem',
|
||||
cursor: 'pointer', marginTop: '0.75rem', padding: 0, textDecoration: 'underline',
|
||||
}}>
|
||||
|
|
@ -1165,7 +1167,7 @@ function MqttClientsTab() {
|
|||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', fontFamily: 'monospace' }}>
|
||||
{c.topic_scope.split('\n').join(', ')}
|
||||
{c.rolename === 'open' ? 'all topics' : c.topic_scope.split('\n').join(', ')}
|
||||
<span style={{ marginLeft: '0.6rem', fontFamily: 'sans-serif' }}>
|
||||
{[c.can_publish && 'publish', c.can_subscribe && 'subscribe'].filter(Boolean).join(' + ')}
|
||||
</span>
|
||||
|
|
@ -1173,13 +1175,16 @@ function MqttClientsTab() {
|
|||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{c.active && (
|
||||
<button onClick={() => addTopic(c)} title="Add another topic scope to this client" style={{
|
||||
background: 'transparent', color: 'var(--text-mid)',
|
||||
border: '1px solid var(--card-border)', borderRadius: '6px',
|
||||
padding: '0.35rem 0.85rem', fontSize: '0.78rem', fontWeight: 600, cursor: 'pointer',
|
||||
}}>
|
||||
+ Topic
|
||||
</button>
|
||||
<>
|
||||
<button onClick={() => reveal(c)} title="Show this client's password" style={smallBtn}>Password</button>
|
||||
<button onClick={() => resetPassword(c)} title="Generate and apply a new password" style={smallBtn}>Reset</button>
|
||||
{c.rolename !== 'open' && (
|
||||
<>
|
||||
<button onClick={() => openAccess(c)} title="Remove topic restrictions" style={smallBtn}>Open access</button>
|
||||
<button onClick={() => addTopic(c)} title="Add another topic scope to this client" style={smallBtn}>+ Topic</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{c.active ? (
|
||||
<button onClick={() => revoke(c)} style={{
|
||||
|
|
@ -1207,6 +1212,12 @@ function MqttClientsTab() {
|
|||
)
|
||||
}
|
||||
|
||||
const smallBtn: React.CSSProperties = {
|
||||
background: 'transparent', color: 'var(--text-mid)',
|
||||
border: '1px solid var(--card-border)', borderRadius: '6px',
|
||||
padding: '0.35rem 0.85rem', fontSize: '0.78rem', fontWeight: 600, cursor: 'pointer',
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
|
||||
borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.85rem',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue