mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
d0b9855bad
commit
3094753587
@@ -10,8 +10,11 @@
|
|||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- [ ] List recorded devices
|
- [x] List recorded devices
|
||||||
- [ ] Forget a device
|
- [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)
|
- [ ] View detailed log of device activity (based on event log in the backend)
|
||||||
- [ ] Scan process monitor and summary page
|
- [ ] Scan process monitor and summary page
|
||||||
- [ ] Is the scan process running
|
- [ ] Is the scan process running
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
|
|||||||
// Allow all origins and headers for API
|
// Allow all origins and headers for API
|
||||||
let cors_layer = CorsLayer::new()
|
let cors_layer = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.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]);
|
.allow_headers([http::header::AUTHORIZATION, http::header::CONTENT_TYPE]);
|
||||||
|
|
||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
|
|||||||
|
|
||||||
device.is_registered = false;
|
device.is_registered = false;
|
||||||
device.owner = "".to_string();
|
device.owner = "".to_string();
|
||||||
device.device_type = "".to_string();
|
|
||||||
|
|
||||||
match db::devices::update(device) {
|
match db::devices::update(device) {
|
||||||
Ok(_) => (axum::http::StatusCode::OK, "Device un-registered"),
|
Ok(_) => (axum::http::StatusCode::OK, "Device un-registered"),
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ import '../utils/friendly_date_formatter.dart';
|
|||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
import '../widgets/status_badge.dart';
|
import '../widgets/status_badge.dart';
|
||||||
|
|
||||||
|
const _deviceTypes = [
|
||||||
|
'phone',
|
||||||
|
'laptop',
|
||||||
|
'tablet',
|
||||||
|
'server',
|
||||||
|
'router',
|
||||||
|
'tv',
|
||||||
|
'printer',
|
||||||
|
'unknown',
|
||||||
|
];
|
||||||
|
|
||||||
enum _DeviceFilter { newDevices, registered, all }
|
enum _DeviceFilter { newDevices, registered, all }
|
||||||
|
|
||||||
class DeviceList extends StatefulWidget {
|
class DeviceList extends StatefulWidget {
|
||||||
@@ -58,6 +69,146 @@ class _DeviceListState extends State<DeviceList> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmForget(BuildContext context, Device device) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<void> _showRegisterDialog(BuildContext context, Device device) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
String owner = '';
|
||||||
|
String deviceType = _deviceTypes.contains(device.deviceType)
|
||||||
|
? device.deviceType
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
|
final saved = await showDialog<bool>(
|
||||||
|
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<String>(
|
||||||
|
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) {
|
IconData _deviceIcon(String deviceType) {
|
||||||
switch (deviceType.toLowerCase()) {
|
switch (deviceType.toLowerCase()) {
|
||||||
case 'phone':
|
case 'phone':
|
||||||
@@ -82,7 +233,7 @@ class _DeviceListState extends State<DeviceList> {
|
|||||||
String _emptyMessage() {
|
String _emptyMessage() {
|
||||||
switch (_filter) {
|
switch (_filter) {
|
||||||
case _DeviceFilter.newDevices:
|
case _DeviceFilter.newDevices:
|
||||||
return 'No new devices';
|
return 'No unregistered devices';
|
||||||
case _DeviceFilter.registered:
|
case _DeviceFilter.registered:
|
||||||
return 'No registered devices';
|
return 'No registered devices';
|
||||||
case _DeviceFilter.all:
|
case _DeviceFilter.all:
|
||||||
@@ -106,7 +257,7 @@ class _DeviceListState extends State<DeviceList> {
|
|||||||
spacing: 8.0,
|
spacing: 8.0,
|
||||||
children: [
|
children: [
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
label: const Text('New'),
|
label: const Text('Not registered'),
|
||||||
selected: _filter == _DeviceFilter.newDevices,
|
selected: _filter == _DeviceFilter.newDevices,
|
||||||
onSelected: (bool selected) {
|
onSelected: (bool selected) {
|
||||||
setState(() => _filter = _DeviceFilter.newDevices);
|
setState(() => _filter = _DeviceFilter.newDevices);
|
||||||
@@ -146,35 +297,68 @@ class _DeviceListState extends State<DeviceList> {
|
|||||||
itemCount: _devices.length,
|
itemCount: _devices.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final device = _devices[index];
|
final device = _devices[index];
|
||||||
return Stack(
|
return Card(
|
||||||
children: [
|
color: device.isRegistered
|
||||||
Card(
|
? null
|
||||||
color: device.isRegistered
|
: theme.colorScheme.secondaryContainer,
|
||||||
? null
|
child: ListTile(
|
||||||
: theme.colorScheme.secondaryContainer,
|
leading: Tooltip(
|
||||||
child: ListTile(
|
message:
|
||||||
leading: Icon(_deviceIcon(device.deviceType)),
|
device.deviceType.isEmpty ||
|
||||||
title: Text(device.ipv4Address),
|
device.deviceType == 'unknown'
|
||||||
subtitle: Text(
|
? 'Device type unknown'
|
||||||
'${device.vendor} · ${device.macAddress}\n'
|
: device.deviceType,
|
||||||
'Last seen: ${formatter.format(device.lastSeen)}',
|
child: Icon(_deviceIcon(device.deviceType)),
|
||||||
),
|
|
||||||
isThreeLine: true,
|
|
||||||
trailing: device.isRegistered
|
|
||||||
? Text(device.owner)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (!device.isRegistered)
|
title: Row(
|
||||||
Positioned(
|
children: [
|
||||||
top: 16,
|
Text(device.ipv4Address),
|
||||||
right: 16,
|
if (!device.isRegistered) ...[
|
||||||
child: const StatusBadge(
|
const SizedBox(width: 8),
|
||||||
label: 'New',
|
const StatusBadge(
|
||||||
color: BadgeColor.secondary,
|
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<String>(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -98,6 +98,24 @@ class BackendAPI {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> forgetDevice(String macAddress) async {
|
||||||
|
debugPrint('About to call DELETE /devices/$macAddress');
|
||||||
|
await _dio.delete('/devices/$macAddress');
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Notification>> listNotifications(bool? isNew, int offset) async {
|
Future<List<Notification>> listNotifications(bool? isNew, int offset) async {
|
||||||
debugPrint('About to call /notifications');
|
debugPrint('About to call /notifications');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user