Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions lib/core/sdui/sdui_form_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class SduiFormBuilder extends material.StatefulWidget {
this.initialValues = const {},
this.onChanged,
this.filePicker,
this.keepExistingSecrets = false,
});

final SduiFormSchema schema;
Expand All @@ -24,6 +25,10 @@ class SduiFormBuilder extends material.StatefulWidget {
/// Injectable file picker for tests. Defaults to `openFile`.
final Future<String?> 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<SduiFormBuilder> createState() => SduiFormBuilderState();
}
Expand Down Expand Up @@ -290,7 +295,7 @@ class SduiFormBuilderState extends material.State<SduiFormBuilder> {
? material.TextInputType.number
: material.TextInputType.text,
decoration: material.InputDecoration(
hintText: field.placeholder,
hintText: _hintFor(field),
),
validator: _validatorFor(field),
),
Expand All @@ -299,10 +304,20 @@ class SduiFormBuilderState extends material.State<SduiFormBuilder> {
}
}

String? _hintFor(SduiFormField field) {
if (widget.keepExistingSecrets &&
field.type == SduiFieldType.password) {
return 'Leave blank to keep existing';
}
return field.placeholder;
}

material.FormFieldValidator<String>? _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) {
Expand Down
64 changes: 2 additions & 62 deletions lib/features/connections/connection_creation_flow.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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<ConnectionRow> 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();
}
63 changes: 63 additions & 0 deletions lib/features/connections/connection_edit_secrets.dart
Original file line number Diff line number Diff line change
@@ -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<ConnectionRow> 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();
}
15 changes: 0 additions & 15 deletions lib/features/connections/connections_panel_extension.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
),
),
],
),
),
Expand Down
7 changes: 6 additions & 1 deletion lib/features/connections/extension_connection_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -247,6 +251,7 @@ class _ExtensionConnectionFormContentState
key: _formKey,
schema: _schema!,
initialValues: _initialValues,
keepExistingSecrets: _isEditing,
),
if (_testMessage != null) ...[
const material.SizedBox(height: 12),
Expand Down
70 changes: 70 additions & 0 deletions test/core/sdui/sdui_builders_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<SduiFormBuilderState>();

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<SduiFormBuilderState>();

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', () {
Expand Down
Loading