New POST /devices/{id}/reset-stats + ResetStatsModal lets staff clear
QA/bench test data accumulated before a device ships to a customer:
Firestore bell/playback counters and Postgres history tables (logs,
heartbeats, commands, boot events, alert events, ping samples,
diagnostics reports, control/reports, and opt-in live alerts). The two
firmware-side resets (telemetry.reset_boot_data, logs.clear) go over
the existing MQTT command/ack flow instead, since they need the device
online and duplicating that round-trip server-side would just be a
second, inferior implementation.
Also includes two incidental cleanups in devices/router.py: audit-log
entries for create/update now use console_name in their label (missed
by the earlier console_name commit), and add/remove-device-user rename
their local Firestore client from `db` to `fs` to stop shadowing the
`db: AsyncSession` dependency param in the same function scope.
AddDeviceUserModal's results list also gets a max-height + scroll so a
long match list doesn't grow the modal off-screen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
6.6 KiB
React
210 lines
6.6 KiB
React
// frontend/src/modals/bellcloud/devices/AddDeviceUserModal.jsx
|
|
// Search and add an app user to a device's user_list.
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import api from '@/lib/api'
|
|
import Modal from '@/components/ui/Modal'
|
|
import Button from '@/components/ui/Button'
|
|
import Spinner from '@/components/ui/Spinner'
|
|
|
|
// ─── User row ─────────────────────────────────────────────────────────────────
|
|
|
|
function UserRow({ user, onAdd, adding }) {
|
|
const initials = (user.display_name || user.email || '?').charAt(0).toUpperCase()
|
|
return (
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-3)',
|
|
padding: 'var(--space-3) var(--space-4)',
|
|
borderRadius: 'var(--radius-md)',
|
|
background: 'var(--color-bg-elevated)',
|
|
border: '1px solid var(--color-border)',
|
|
}}>
|
|
{/* Avatar */}
|
|
<div style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: '50%',
|
|
overflow: 'hidden',
|
|
background: 'var(--color-primary-subtle)',
|
|
flexShrink: 0,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: 'var(--font-size-sm)',
|
|
fontWeight: 'var(--font-weight-semibold)',
|
|
color: 'var(--color-primary)',
|
|
border: '1px solid rgba(192,193,255,0.20)',
|
|
}}>
|
|
{user.photo_url ? (
|
|
<img src={user.photo_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
) : initials}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<p style={{
|
|
margin: 0,
|
|
fontSize: 'var(--font-size-base)',
|
|
fontWeight: 'var(--font-weight-medium)',
|
|
color: 'var(--color-text-primary)',
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>
|
|
{user.display_name || '—'}
|
|
</p>
|
|
{user.email && (
|
|
<p style={{
|
|
margin: 0,
|
|
fontSize: 'var(--font-size-xs)',
|
|
color: 'var(--color-text-muted)',
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>
|
|
{user.email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add button */}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => onAdd(user)}
|
|
loading={adding === user.id}
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── AddDeviceUserModal ───────────────────────────────────────────────────────
|
|
|
|
export default function AddDeviceUserModal({ open, deviceId, existingUserIds = [], onClose, onAdded }) {
|
|
const [query, setQuery] = useState('')
|
|
const [results, setResults] = useState([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [adding, setAdding] = useState(null)
|
|
const [error, setError] = useState('')
|
|
const debounceRef = useRef(null)
|
|
const inputRef = useRef(null)
|
|
|
|
// Reset on open
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setQuery('')
|
|
setResults([])
|
|
setError('')
|
|
setAdding(null)
|
|
setTimeout(() => inputRef.current?.focus(), 80)
|
|
}, [open])
|
|
|
|
const search = useCallback(async (q) => {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const data = await api.get(`/devices/${deviceId}/user-search?q=${encodeURIComponent(q)}`)
|
|
// Filter out users already on the device
|
|
const existing = new Set(existingUserIds)
|
|
setResults((data.results || []).filter(u => !existing.has(u.id)))
|
|
} catch (err) {
|
|
setError(err.message || 'Search failed.')
|
|
setResults([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [deviceId, existingUserIds])
|
|
|
|
const handleInput = (e) => {
|
|
const val = e.target.value
|
|
setQuery(val)
|
|
clearTimeout(debounceRef.current)
|
|
debounceRef.current = setTimeout(() => search(val), 280)
|
|
}
|
|
|
|
// Load all users on open (empty query)
|
|
useEffect(() => {
|
|
if (open && deviceId) search('')
|
|
}, [open, deviceId, search])
|
|
|
|
const handleAdd = async (user) => {
|
|
setAdding(user.id)
|
|
setError('')
|
|
try {
|
|
await api.post(`/devices/${deviceId}/user-list`, { user_id: user.id })
|
|
onAdded(user)
|
|
// Remove from results so it disappears immediately
|
|
setResults(prev => prev.filter(u => u.id !== user.id))
|
|
} catch (err) {
|
|
setError(err.message || 'Failed to add user.')
|
|
} finally {
|
|
setAdding(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title="Add User to Device" size="sm">
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
|
|
|
{/* Search */}
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={handleInput}
|
|
placeholder="Search by name, email, or phone…"
|
|
className="input"
|
|
style={{ boxSizing: 'border-box' }}
|
|
/>
|
|
|
|
{error && (
|
|
<div style={{
|
|
padding: 'var(--space-2) var(--space-3)',
|
|
backgroundColor: 'var(--color-danger-bg)',
|
|
border: '1px solid var(--color-danger)',
|
|
borderRadius: 'var(--radius-md)',
|
|
color: 'var(--color-danger)',
|
|
fontSize: 'var(--font-size-sm)',
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Results */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', minHeight: 80, maxHeight: 260, overflowY: 'auto' }}>
|
|
{loading ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
|
|
<Spinner size="sm" />
|
|
</div>
|
|
) : results.length === 0 ? (
|
|
<div style={{
|
|
padding: 'var(--space-6)',
|
|
textAlign: 'center',
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: 'var(--font-size-sm)',
|
|
fontStyle: 'italic',
|
|
}}>
|
|
{query ? 'No users found matching your search.' : 'All users already added or no users exist.'}
|
|
</div>
|
|
) : (
|
|
results.map(user => (
|
|
<UserRow
|
|
key={user.id}
|
|
user={user}
|
|
onAdd={handleAdd}
|
|
adding={adding}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
<Button variant="secondary" size="md" onClick={onClose}>Close</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|