3cb980b629
Security — enforce write authorization server-side (was UI/RPC-only): - it_service_requests RLS: block cross-office read/edit + self-approve (QA-015) - pass_slips RLS: owner can complete but not self-approve (QA-046) - swap_requests RLS: scope select/update to participants + admin (QA-047) - storage: tighten it_service_attachments + task_attachments write/delete (QA-027) - admin_user_management edge function: allow programmers to manage users (QA-016) Fixes: - workforce generator "uncovered shifts" false alarms (QA-043/044) - network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048) - de-flake time-of-day-dependent dashboard metrics test (QA-045) Toolchain: - upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0, flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002) analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/. Note: also carries the in-progress Brick model cleanup already present in the working tree. QA-001 (AI keys public in the build) is deferred by owner decision. Co-Authored-By: claude-flow <ruv@ruv.net>
188 lines
6.1 KiB
Dart
188 lines
6.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../models/network/network_link.dart';
|
|
import '../../../models/network/network_port.dart';
|
|
import '../../../providers/network_map/network_devices_provider.dart';
|
|
import '../../../utils/snackbar.dart';
|
|
|
|
/// Opens an M3 dialog to connect [portId] to a port on another device.
|
|
///
|
|
/// Returns true if a link was successfully created.
|
|
Future<bool> showLinkEditDialog({
|
|
required BuildContext context,
|
|
required WidgetRef ref,
|
|
required String portId,
|
|
required String currentDeviceId,
|
|
}) async {
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => _LinkEditDialog(
|
|
portId: portId,
|
|
currentDeviceId: currentDeviceId,
|
|
),
|
|
);
|
|
return result == true;
|
|
}
|
|
|
|
class _LinkEditDialog extends ConsumerStatefulWidget {
|
|
const _LinkEditDialog({
|
|
required this.portId,
|
|
required this.currentDeviceId,
|
|
});
|
|
|
|
final String portId;
|
|
final String currentDeviceId;
|
|
|
|
@override
|
|
ConsumerState<_LinkEditDialog> createState() => _LinkEditDialogState();
|
|
}
|
|
|
|
class _LinkEditDialogState extends ConsumerState<_LinkEditDialog> {
|
|
String? _selectedDeviceId;
|
|
String? _selectedPortId;
|
|
NetworkLinkKind? _linkKind;
|
|
final _cableLabelCtrl = TextEditingController();
|
|
bool _saving = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_cableLabelCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final portA = widget.portId;
|
|
final portB = _selectedPortId;
|
|
if (portB == null) return;
|
|
setState(() => _saving = true);
|
|
try {
|
|
ref.invalidate(networkLinksProvider);
|
|
final controller = ref.read(networkDevicesControllerProvider);
|
|
await controller.createLink(
|
|
portA: portA,
|
|
portB: portB,
|
|
linkKind: _linkKind,
|
|
cableLabel: _cableLabelCtrl.text.trim().isEmpty
|
|
? null
|
|
: _cableLabelCtrl.text.trim(),
|
|
);
|
|
if (mounted) Navigator.of(context).pop(true);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
showErrorSnackBar(context, 'Failed to create link: $e');
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final devicesAsync = ref.watch(networkDevicesProvider);
|
|
final portsAsync = _selectedDeviceId != null
|
|
? ref.watch(networkPortsByDeviceProvider(_selectedDeviceId!))
|
|
: const AsyncValue<List<NetworkPort>>.data([]);
|
|
final allLinksAsync = ref.watch(networkLinksProvider);
|
|
|
|
final otherDevices = (devicesAsync.valueOrNull ?? const [])
|
|
.where((d) => d.id != widget.currentDeviceId)
|
|
.toList()
|
|
..sort((a, b) => a.name.compareTo(b.name));
|
|
|
|
final allLinks = allLinksAsync.valueOrNull ?? const [];
|
|
final usedPortIds = <String>{
|
|
for (final link in allLinks) ...[link.portA, link.portB],
|
|
};
|
|
|
|
final devicePorts = portsAsync.valueOrNull ?? const [];
|
|
final availablePorts =
|
|
devicePorts.where((p) => !usedPortIds.contains(p.id)).toList();
|
|
|
|
final canSave = _selectedPortId != null && !_saving;
|
|
|
|
return AlertDialog(
|
|
title: const Text('Connect port'),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _selectedDeviceId,
|
|
decoration:
|
|
const InputDecoration(labelText: 'Remote device *'),
|
|
items: [
|
|
for (final d in otherDevices)
|
|
DropdownMenuItem(value: d.id, child: Text(d.name)),
|
|
],
|
|
onChanged: (v) => setState(() {
|
|
_selectedDeviceId = v;
|
|
_selectedPortId = null;
|
|
}),
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
key: ValueKey(_selectedDeviceId),
|
|
initialValue: _selectedPortId,
|
|
decoration: InputDecoration(
|
|
labelText: 'Remote port *',
|
|
helperText: _selectedDeviceId == null
|
|
? 'Select a device first'
|
|
: availablePorts.isEmpty
|
|
? 'No available ports on this device'
|
|
: null,
|
|
),
|
|
items: [
|
|
for (final p in availablePorts)
|
|
DropdownMenuItem(
|
|
value: p.id,
|
|
child: Text(p.portNumber),
|
|
),
|
|
],
|
|
onChanged: _selectedDeviceId == null
|
|
? null
|
|
: (v) => setState(() => _selectedPortId = v),
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<NetworkLinkKind?>(
|
|
initialValue: _linkKind,
|
|
decoration: const InputDecoration(labelText: 'Link type'),
|
|
items: [
|
|
const DropdownMenuItem<NetworkLinkKind?>(
|
|
value: null,
|
|
child: Text('(unknown)'),
|
|
),
|
|
for (final k in NetworkLinkKind.values)
|
|
DropdownMenuItem(value: k, child: Text(k.label)),
|
|
],
|
|
onChanged: (v) => setState(() => _linkKind = v),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _cableLabelCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Cable label',
|
|
helperText: 'e.g. "CAT6-01", "SMF-A3" (optional)',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _saving ? null : () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: canSave ? _save : null,
|
|
child: Text(_saving ? 'Saving…' : 'Connect'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|