From 3094753587aeb87ed4b565019fd74b49f8323414 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Wed, 27 May 2026 15:33:24 -0400 Subject: [PATCH] Add Register and Forget actions to devices list Adds a context menu to each device card with contextual actions: - Unregistered devices show a Register option (dialog with owner field and device type dropdown) - Registered devices show a Forget option (confirmation dialog) Also fixes CORS to allow PUT and DELETE methods, adds device type tooltips on the icon, and renames the "New" filter/badge to "Not registered". Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 7 +- backend/src/web_server.rs | 6 + backend/src/web_server/devices.rs | 1 - frontend/lib/devices/device_list.dart | 242 +++++++++++++++++++++++--- frontend/lib/utils/oott_api.dart | 18 ++ 5 files changed, 242 insertions(+), 32 deletions(-) diff --git a/TODO.md b/TODO.md index 3057978..3efe226 100644 --- a/TODO.md +++ b/TODO.md @@ -10,8 +10,11 @@ ## Frontend -- [ ] List recorded devices -- [ ] Forget a device +- [x] List recorded devices +- [x] Register a device +- [x] Forget a device +- [ ] Extract the snack bar confirmations as a utility widget so it can be reused +- [ ] In the devices list add filters by owner and device type - [ ] View detailed log of device activity (based on event log in the backend) - [ ] Scan process monitor and summary page - [ ] Is the scan process running diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index 17286fb..93f616b 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -80,6 +80,12 @@ pub async fn serve() -> Result<(), Box> { // Allow all origins and headers for API let cors_layer = CorsLayer::new() .allow_origin(Any) + .allow_methods([ + http::Method::GET, + http::Method::POST, + http::Method::PUT, + http::Method::DELETE, + ]) .allow_headers([http::header::AUTHORIZATION, http::header::CONTENT_TYPE]); let router = Router::new() diff --git a/backend/src/web_server/devices.rs b/backend/src/web_server/devices.rs index eb80d88..9207d47 100644 --- a/backend/src/web_server/devices.rs +++ b/backend/src/web_server/devices.rs @@ -164,7 +164,6 @@ pub async fn unregister(Path(mac_address): Path) -> impl IntoResponse { device.is_registered = false; device.owner = "".to_string(); - device.device_type = "".to_string(); match db::devices::update(device) { Ok(_) => (axum::http::StatusCode::OK, "Device un-registered"), diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index 0fe97ad..5a34fe1 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -5,6 +5,17 @@ import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; import '../widgets/status_badge.dart'; +const _deviceTypes = [ + 'phone', + 'laptop', + 'tablet', + 'server', + 'router', + 'tv', + 'printer', + 'unknown', +]; + enum _DeviceFilter { newDevices, registered, all } class DeviceList extends StatefulWidget { @@ -58,6 +69,146 @@ class _DeviceListState extends State { } } + Future _confirmForget(BuildContext context, Device device) async { + final messenger = ScaffoldMessenger.of(context); + final colorScheme = Theme.of(context).colorScheme; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Forget Device'), + content: Text( + 'This device will be unregistered and will no longer be linked to ' + '${device.owner}. Are you sure?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text('Forget', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + try { + await BackendAPI.instance.forgetDevice(device.macAddress); + if (!mounted) return; + messenger.showSnackBar( + const SnackBar( + content: Text('Device forgotten'), + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + ), + ); + _loadDevices(); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text('Failed to forget device: $e'), + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + ), + ); + } + } + + Future _showRegisterDialog(BuildContext context, Device device) async { + final messenger = ScaffoldMessenger.of(context); + final formKey = GlobalKey(); + String owner = ''; + String deviceType = _deviceTypes.contains(device.deviceType) + ? device.deviceType + : 'unknown'; + + final saved = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('Register Device'), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + decoration: const InputDecoration(labelText: 'Owner'), + validator: (value) => value == null || value.trim().isEmpty + ? 'Owner is required' + : null, + onSaved: (value) => owner = value?.trim() ?? '', + ), + const SizedBox(height: 16), + InputDecorator( + decoration: const InputDecoration(labelText: 'Device Type'), + child: DropdownButton( + value: deviceType, + isExpanded: true, + underline: const SizedBox(), + items: _deviceTypes + .map((t) => DropdownMenuItem(value: t, child: Text(t))) + .toList(), + onChanged: (value) => + setDialogState(() => deviceType = value ?? deviceType), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + formKey.currentState?.save(); + Navigator.of(context).pop(true); + } + }, + child: const Text('Save'), + ), + ], + ), + ), + ); + + if (saved != true || !mounted) return; + + try { + await BackendAPI.instance.registerDevice( + device.macAddress, + owner, + deviceType, + ); + if (!mounted) return; + messenger.showSnackBar( + const SnackBar( + content: Text('Device registered'), + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + ), + ); + _loadDevices(); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text('Failed to register device: $e'), + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + ), + ); + } + } + IconData _deviceIcon(String deviceType) { switch (deviceType.toLowerCase()) { case 'phone': @@ -82,7 +233,7 @@ class _DeviceListState extends State { String _emptyMessage() { switch (_filter) { case _DeviceFilter.newDevices: - return 'No new devices'; + return 'No unregistered devices'; case _DeviceFilter.registered: return 'No registered devices'; case _DeviceFilter.all: @@ -106,7 +257,7 @@ class _DeviceListState extends State { spacing: 8.0, children: [ ChoiceChip( - label: const Text('New'), + label: const Text('Not registered'), selected: _filter == _DeviceFilter.newDevices, onSelected: (bool selected) { setState(() => _filter = _DeviceFilter.newDevices); @@ -146,35 +297,68 @@ class _DeviceListState extends State { itemCount: _devices.length, itemBuilder: (context, index) { final device = _devices[index]; - return Stack( - children: [ - Card( - color: device.isRegistered - ? null - : theme.colorScheme.secondaryContainer, - child: ListTile( - leading: Icon(_deviceIcon(device.deviceType)), - title: Text(device.ipv4Address), - subtitle: Text( - '${device.vendor} · ${device.macAddress}\n' - 'Last seen: ${formatter.format(device.lastSeen)}', - ), - isThreeLine: true, - trailing: device.isRegistered - ? Text(device.owner) - : null, - ), + return Card( + color: device.isRegistered + ? null + : theme.colorScheme.secondaryContainer, + child: ListTile( + leading: Tooltip( + message: + device.deviceType.isEmpty || + device.deviceType == 'unknown' + ? 'Device type unknown' + : device.deviceType, + child: Icon(_deviceIcon(device.deviceType)), ), - if (!device.isRegistered) - Positioned( - top: 16, - right: 16, - child: const StatusBadge( - label: 'New', - color: BadgeColor.secondary, + title: Row( + children: [ + Text(device.ipv4Address), + if (!device.isRegistered) ...[ + const SizedBox(width: 8), + const StatusBadge( + label: 'Not registered', + color: BadgeColor.secondary, + ), + ], + ], + ), + subtitle: Text( + '${device.vendor} · ${device.macAddress}\n' + 'Last seen: ${formatter.format(device.lastSeen)}', + ), + isThreeLine: true, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (device.isRegistered) Text(device.owner), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) async { + if (value == 'forget') { + await _confirmForget(context, device); + } else if (value == 'register') { + await _showRegisterDialog( + context, + device, + ); + } + }, + itemBuilder: (context) => [ + if (device.isRegistered) + const PopupMenuItem( + value: 'forget', + child: Text('Forget'), + ), + if (!device.isRegistered) + const PopupMenuItem( + value: 'register', + child: Text('Register'), + ), + ], ), - ), - ], + ], + ), + ), ); }, ), diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index af3733c..dbafede 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -98,6 +98,24 @@ class BackendAPI { .toList(); } + Future registerDevice( + String macAddress, + String owner, + String deviceType, + ) async { + debugPrint('About to call PUT /devices'); + await _dio.put('/devices', data: { + 'mac_address': macAddress, + 'owner': owner, + 'device_type': deviceType, + }); + } + + Future forgetDevice(String macAddress) async { + debugPrint('About to call DELETE /devices/$macAddress'); + await _dio.delete('/devices/$macAddress'); + } + Future> listNotifications(bool? isNew, int offset) async { debugPrint('About to call /notifications');