Keep stored home API keys hidden on edit
This commit is contained in:
@@ -25,6 +25,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
final _lonCtrl = TextEditingController();
|
final _lonCtrl = TextEditingController();
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _loadingApiKey = false;
|
bool _loadingApiKey = false;
|
||||||
|
bool _hasStoredApiKey = false;
|
||||||
String _originalApiKey = '';
|
String _originalApiKey = '';
|
||||||
|
|
||||||
bool get _isEdit => widget.home != null;
|
bool get _isEdit => widget.home != null;
|
||||||
@@ -59,11 +60,11 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
final apiKey = await ref
|
final apiKey = await ref
|
||||||
.read(settingsServiceProvider)
|
.read(settingsServiceProvider)
|
||||||
.getHomeApiKey(widget.home!.id);
|
.getHomeApiKey(widget.home!.id);
|
||||||
_originalApiKey = apiKey ?? '';
|
_originalApiKey = apiKey?.trim() ?? '';
|
||||||
|
_hasStoredApiKey = _originalApiKey.isNotEmpty;
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_keyCtrl.text = _originalApiKey;
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _loadingApiKey = false);
|
setState(() => _loadingApiKey = false);
|
||||||
@@ -144,14 +145,24 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
controller: _keyCtrl,
|
controller: _keyCtrl,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'API Key',
|
labelText: 'API Key',
|
||||||
|
hintText: _hasStoredApiKey
|
||||||
|
? 'Оставьте пустым, чтобы сохранить текущий ключ'
|
||||||
|
: null,
|
||||||
helperText: _loadingApiKey
|
helperText: _loadingApiKey
|
||||||
? 'Загружаем сохранённый ключ...'
|
? 'Загружаем сохранённый ключ...'
|
||||||
: 'Ключ проверяется только при изменении подключения',
|
: _hasStoredApiKey
|
||||||
|
? 'Сохранённый ключ хранится отдельно и не показывается в поле'
|
||||||
|
: 'Ключ хранится отдельно в защищённом хранилище',
|
||||||
prefixIcon: const Icon(Icons.key),
|
prefixIcon: const Icon(Icons.key),
|
||||||
),
|
),
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
validator: (value) =>
|
validator: (value) {
|
||||||
(value?.trim().isEmpty ?? true) ? 'Укажите API key' : null,
|
final enteredKey = value?.trim() ?? '';
|
||||||
|
if (enteredKey.isNotEmpty || _hasStoredApiKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return 'Укажите API key';
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -285,7 +296,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
|
|
||||||
final name = _nameCtrl.text.trim();
|
final name = _nameCtrl.text.trim();
|
||||||
final rawUrl = _urlCtrl.text.trim();
|
final rawUrl = _urlCtrl.text.trim();
|
||||||
final key = _keyCtrl.text.trim();
|
final enteredKey = _keyCtrl.text.trim();
|
||||||
|
final key = enteredKey.isNotEmpty ? enteredKey : _originalApiKey;
|
||||||
final latText = _latCtrl.text.trim();
|
final latText = _latCtrl.text.trim();
|
||||||
final lonText = _lonCtrl.text.trim();
|
final lonText = _lonCtrl.text.trim();
|
||||||
|
|
||||||
@@ -370,7 +382,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
|||||||
if (_isEdit) {
|
if (_isEdit) {
|
||||||
await ref
|
await ref
|
||||||
.read(homesProvider.notifier)
|
.read(homesProvider.notifier)
|
||||||
.update(home, apiKey: credentialsChanged ? key : null);
|
.update(home, apiKey: enteredKey.isNotEmpty ? key : null);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(homesProvider.notifier).add(home, apiKey: key);
|
await ref.read(homesProvider.notifier).add(home, apiKey: key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,25 +82,25 @@ void main() {
|
|||||||
expect(api.createdGroupMacs, ['AA:BB']);
|
expect(api.createdGroupMacs, ['AA:BB']);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('group edit screen shows backend rescan summary', (
|
testWidgets('group edit screen shows backend rescan summary', (tester) async {
|
||||||
tester,
|
final api =
|
||||||
) async {
|
FakeIgnisApi(
|
||||||
final api = FakeIgnisApi(
|
devicesData: {
|
||||||
devicesData: {
|
'devices': [
|
||||||
'devices': [
|
{'mac': 'AA:BB', 'name': 'Лампа 1'},
|
||||||
{'mac': 'AA:BB', 'name': 'Лампа 1'},
|
],
|
||||||
],
|
},
|
||||||
},
|
groupsData: <Object>[],
|
||||||
groupsData: <Object>[],
|
)
|
||||||
)..rescanNetworkData = {
|
..rescanNetworkData = {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'found': 3,
|
'found': 3,
|
||||||
'added': 1,
|
'added': 1,
|
||||||
'updated': 2,
|
'updated': 2,
|
||||||
'removed_offline': 1,
|
'removed_offline': 1,
|
||||||
'pending_removal': 0,
|
'pending_removal': 0,
|
||||||
'online': 3,
|
'online': 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
await pumpTestApp(tester, child: const GroupEditScreen(), api: api);
|
await pumpTestApp(tester, child: const GroupEditScreen(), api: api);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -109,7 +109,9 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.text('Сканирование завершено: найдено 3, новых 1, обновлено 2, убрано 1'),
|
find.text(
|
||||||
|
'Сканирование завершено: найдено 3, новых 1, обновлено 2, убрано 1',
|
||||||
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(api.rescanCalls, 1);
|
expect(api.rescanCalls, 1);
|
||||||
@@ -228,4 +230,57 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(savedApiKey, 'secret-key');
|
expect(savedApiKey, 'secret-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('home edit screen keeps stored api key hidden on edit', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final settingsService = SettingsService(
|
||||||
|
credentialsStorage: InMemoryCredentialsStorage(),
|
||||||
|
);
|
||||||
|
final existingHome = HomeConfig(
|
||||||
|
id: 'home-1',
|
||||||
|
name: 'Квартира',
|
||||||
|
url: 'https://ignis.akokos.ru',
|
||||||
|
);
|
||||||
|
await settingsService.upsertHome(existingHome, apiKey: 'stored-secret');
|
||||||
|
|
||||||
|
final api = FakeIgnisApi(authData: {'is_admin': true, 'name': 'owner'});
|
||||||
|
|
||||||
|
await pumpTestApp(
|
||||||
|
tester,
|
||||||
|
api: api,
|
||||||
|
settingsService: settingsService,
|
||||||
|
child: HomeEditScreen(home: existingHome),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final keyField = tester.widget<EditableText>(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(widget) => widget is EditableText && widget.obscureText,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(keyField.controller.text, isEmpty);
|
||||||
|
expect(
|
||||||
|
find.text('Сохранённый ключ хранится отдельно и не показывается в поле'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Адрес сервера'),
|
||||||
|
'ignis.example.com',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ElevatedButton, 'СОХРАНИТЬ'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(api.validateCredentialsCalls, 1);
|
||||||
|
expect(api.validatedBaseUrl, 'https://ignis.example.com');
|
||||||
|
expect(api.validatedApiKey, 'stored-secret');
|
||||||
|
expect(
|
||||||
|
await settingsService.getHomeApiKey(existingHome.id),
|
||||||
|
'stored-secret',
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
String? deletedGroupId;
|
String? deletedGroupId;
|
||||||
Map<String, dynamic>? scheduledOnceParams;
|
Map<String, dynamic>? scheduledOnceParams;
|
||||||
Map<String, dynamic>? scheduledCronParams;
|
Map<String, dynamic>? scheduledCronParams;
|
||||||
|
String? validatedBaseUrl;
|
||||||
|
String? validatedApiKey;
|
||||||
|
int validateCredentialsCalls = 0;
|
||||||
int rescanCalls = 0;
|
int rescanCalls = 0;
|
||||||
|
|
||||||
FakeIgnisApi({
|
FakeIgnisApi({
|
||||||
@@ -99,6 +102,9 @@ class FakeIgnisApi extends IgnisApi {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> validateCredentials(String baseUrl, String apiKey) async {
|
Future<void> validateCredentials(String baseUrl, String apiKey) async {
|
||||||
|
validateCredentialsCalls += 1;
|
||||||
|
validatedBaseUrl = baseUrl;
|
||||||
|
validatedApiKey = apiKey;
|
||||||
final error = authError;
|
final error = authError;
|
||||||
if (error != null) throw error;
|
if (error != null) throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user