From 54895e990b32dfb14aa286f7668033ae2d3e3082 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 22:34:21 +0300 Subject: [PATCH] fix(extensions): edit-connection password keep and UI parity Allow blank required SDUI passwords when editing, merge secrets on Test, and drop the duplicate inline Remove control on extension tiles. --- lib/core/sdui/sdui_form_builder.dart | 19 ++++- .../connections/connection_creation_flow.dart | 64 +---------------- .../connections/connection_edit_secrets.dart | 63 +++++++++++++++++ .../connections_panel_extension.dart | 15 ---- .../extension_connection_form.dart | 7 +- test/core/sdui/sdui_builders_test.dart | 70 +++++++++++++++++++ 6 files changed, 158 insertions(+), 80 deletions(-) create mode 100644 lib/features/connections/connection_edit_secrets.dart diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart index d218b95..0eeb1d8 100644 --- a/lib/core/sdui/sdui_form_builder.dart +++ b/lib/core/sdui/sdui_form_builder.dart @@ -15,6 +15,7 @@ class SduiFormBuilder extends material.StatefulWidget { this.initialValues = const {}, this.onChanged, this.filePicker, + this.keepExistingSecrets = false, }); final SduiFormSchema schema; @@ -24,6 +25,10 @@ class SduiFormBuilder extends material.StatefulWidget { /// Injectable file picker for tests. Defaults to `openFile`. final Future Function(SduiFormField field)? filePicker; + /// When true (edit connection), blank password fields are valid and show + /// "Leave blank to keep existing" — host merges stored secrets on save. + final bool keepExistingSecrets; + @override material.State createState() => SduiFormBuilderState(); } @@ -290,7 +295,7 @@ class SduiFormBuilderState extends material.State { ? material.TextInputType.number : material.TextInputType.text, decoration: material.InputDecoration( - hintText: field.placeholder, + hintText: _hintFor(field), ), validator: _validatorFor(field), ), @@ -299,10 +304,20 @@ class SduiFormBuilderState extends material.State { } } + String? _hintFor(SduiFormField field) { + if (widget.keepExistingSecrets && + field.type == SduiFieldType.password) { + return 'Leave blank to keep existing'; + } + return field.placeholder; + } + material.FormFieldValidator? _validatorFor(SduiFormField field) { return (value) { final text = value?.trim() ?? ''; - if (field.required && text.isEmpty) { + final allowBlankSecret = widget.keepExistingSecrets && + field.type == SduiFieldType.password; + if (field.required && text.isEmpty && !allowBlankSecret) { return '${field.label} is required'; } if (field.type == SduiFieldType.number && text.isNotEmpty) { diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index a2d7479..6e2b647 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -1,8 +1,6 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; -import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/extension_connection_form.dart'; @@ -13,6 +11,8 @@ import 'package:querya_desktop/features/mysql/mysql_connection_form.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:querya_desktop/features/redis/redis_connection_form.dart'; +export 'package:querya_desktop/features/connections/connection_edit_secrets.dart'; + /// Context that stays mounted after menu overlays close (multi-step dialog flow). material.BuildContext _dialogAnchorContext(material.BuildContext context) { final navigator = material.Navigator.maybeOf(context, rootNavigator: true); @@ -129,63 +129,3 @@ DriverContribution? _driverForConnection( } return first; } - -/// Keeps previous secure-store secrets when edit form fields are left blank. -/// -/// [ConnectionSecretsStore.writeForConnection] deletes empty values — callers -/// must merge before [LocalDb.updateConnection]. -Future mergeSecretsForConnectionUpdate( - ConnectionRow edited, -) async { - final id = edited.id; - if (id == null) { - throw ArgumentError('edited.id is required for secret merge'); - } - final prev = await ConnectionSecretsStore.readForConnection(id); - - final passwordEmpty = - edited.password == null || edited.password!.trim().isEmpty; - final password = passwordEmpty ? prev.password : edited.password; - - var connectionString = edited.connectionString; - if (connectionString == null || connectionString.trim().isEmpty) { - // Host-mode edit: do not resurrect a previous URI. - connectionString = null; - } else { - connectionString = injectUriPasswordIfMissing(connectionString, password); - } - - return edited.copyWith( - password: password, - connectionString: connectionString, - clearPassword: password == null, - clearConnectionString: connectionString == null, - ); -} - -/// Strips userinfo password so edit forms never show stored secrets. -String? redactUriPassword(String? uri) { - if (uri == null || uri.trim().isEmpty) return uri; - final parsed = Uri.tryParse(uri.trim()); - if (parsed == null) return uri; - final info = parsed.userInfo; - if (info.isEmpty || !info.contains(':')) return uri; - final user = info.split(':').first; - return parsed.replace(userInfo: user).toString(); -} - -/// Puts [password] into URI userinfo when the URI has a user but no password. -@visibleForTesting -String injectUriPasswordIfMissing(String uri, String? password) { - if (password == null || password.isEmpty) return uri; - final parsed = Uri.tryParse(uri.trim()); - if (parsed == null) return uri; - final info = parsed.userInfo; - if (info.isEmpty) return uri; - final parts = info.split(':'); - if (parts.length >= 2 && parts.sublist(1).join(':').isNotEmpty) { - return uri; - } - final user = parts.first; - return parsed.replace(userInfo: '$user:$password').toString(); -} diff --git a/lib/features/connections/connection_edit_secrets.dart b/lib/features/connections/connection_edit_secrets.dart new file mode 100644 index 0000000..700f473 --- /dev/null +++ b/lib/features/connections/connection_edit_secrets.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Keeps previous secure-store secrets when edit form fields are left blank. +/// +/// [ConnectionSecretsStore.writeForConnection] deletes empty values — callers +/// must merge before [LocalDb.updateConnection]. +Future mergeSecretsForConnectionUpdate( + ConnectionRow edited, +) async { + final id = edited.id; + if (id == null) { + throw ArgumentError('edited.id is required for secret merge'); + } + final prev = await ConnectionSecretsStore.readForConnection(id); + + final passwordEmpty = + edited.password == null || edited.password!.trim().isEmpty; + final password = passwordEmpty ? prev.password : edited.password; + + var connectionString = edited.connectionString; + if (connectionString == null || connectionString.trim().isEmpty) { + // Host-mode edit: do not resurrect a previous URI. + connectionString = null; + } else { + connectionString = injectUriPasswordIfMissing(connectionString, password); + } + + return edited.copyWith( + password: password, + connectionString: connectionString, + clearPassword: password == null, + clearConnectionString: connectionString == null, + ); +} + +/// Strips userinfo password so edit forms never show stored secrets. +String? redactUriPassword(String? uri) { + if (uri == null || uri.trim().isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty || !info.contains(':')) return uri; + final user = info.split(':').first; + return parsed.replace(userInfo: user).toString(); +} + +/// Puts [password] into URI userinfo when the URI has a user but no password. +@visibleForTesting +String injectUriPasswordIfMissing(String uri, String? password) { + if (password == null || password.isEmpty) return uri; + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null) return uri; + final info = parsed.userInfo; + if (info.isEmpty) return uri; + final parts = info.split(':'); + if (parts.length >= 2 && parts.sublist(1).join(':').isNotEmpty) { + return uri; + } + final user = parts.first; + return parsed.replace(userInfo: '$user:$password').toString(); +} diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index e93bfa3..f70afaa 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -260,21 +260,6 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { ], ), ), - material.Tooltip( - message: 'Remove', - child: material.InkWell( - onTap: widget.onRemove, - borderRadius: material.BorderRadius.circular(6), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon( - material.Icons.close_rounded, - size: 14, - color: theme.colorScheme.mutedForeground, - ), - ), - ), - ), ], ), ), diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index 08ee32c..587dfad 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_edit_secrets.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows an SDUI connection form for an installed extension driver. @@ -154,13 +155,16 @@ class _ExtensionConnectionFormContentState }); try { - final row = connectionRowFromExtensionForm( + var row = connectionRowFromExtensionForm( manifest: widget.manifest, driver: widget.driver, name: 'connection-test', values: values, initial: widget.initial, ); + if (widget.initial?.id != null) { + row = await mergeSecretsForConnectionUpdate(row); + } final version = await ExtensionDriverSession.instance.testConnection( manifest: widget.manifest, row: row, @@ -247,6 +251,7 @@ class _ExtensionConnectionFormContentState key: _formKey, schema: _schema!, initialValues: _initialValues, + keepExistingSecrets: _isEditing, ), if (_testMessage != null) ...[ const material.SizedBox(height: 12), diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart index 9bf1f38..1b85f76 100644 --- a/test/core/sdui/sdui_builders_test.dart +++ b/test/core/sdui/sdui_builders_test.dart @@ -151,6 +151,76 @@ void main() { expect(key.currentState!.snapshotValues()['db'], '/tmp/test.db'); }); + + testWidgets( + 'keepExistingSecrets allows blank required password with hint', + (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'fields': [ + {'id': 'host', 'type': 'text', 'label': 'Host', 'required': true}, + { + 'id': 'password', + 'type': 'password', + 'label': 'Password', + 'required': true, + 'placeholder': 'Secret', + }, + ], + }); + final key = material.GlobalKey(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder( + key: key, + schema: schema, + initialValues: const {'host': 'db.local'}, + keepExistingSecrets: true, + ), + ), + ), + ); + + expect(find.text('Leave blank to keep existing'), findsOneWidget); + expect(find.text('Secret'), findsNothing); + + final values = key.currentState!.collectValues(); + expect(values, isNotNull); + expect(values!['host'], 'db.local'); + expect(values['password'], ''); + expect(find.text('Password is required'), findsNothing); + }, + ); + + testWidgets( + 'required password still blocks create when keepExistingSecrets is false', + (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'fields': [ + { + 'id': 'password', + 'type': 'password', + 'label': 'Password', + 'required': true, + }, + ], + }); + final key = material.GlobalKey(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder(key: key, schema: schema), + ), + ), + ); + + expect(key.currentState!.collectValues(), isNull); + await tester.pump(); + expect(find.text('Password is required'), findsOneWidget); + }, + ); }); group('SduiTreeSchema', () {