feat(mobile): rename passkeys (#32784)
* feat(mobile): rename passkeys * fix(mobile): refresh the passkey list from the store after a rename * fix(mobile): skip the name dialog after creating a passkey
This commit is contained in:
1 parent
cfa416e612
commit
f727e8edb4
5 files changed
+112
No files matched your search
@@ -45,6 +45,10 @@ class PasskeyApiRepository extends ApiRepository {
|
||||
return response.map(_toPasskey).toList();
|
||||
}
|
||||
|
||||
Future<void> update(String id, String? name) async {
|
||||
await _api.updatePasskey(id, PasskeyUpdateDto(name: name));
|
||||
}
|
||||
|
||||
static Passkey _toPasskey(PasskeyResponseDto dto) =>
|
||||
.new(id: dto.id, createdAt: dto.createdAt, name: dto.name, usedAt: dto.usedAt);
|
||||
}
|
||||
@@ -89,4 +89,15 @@ class PasskeyMutations extends StoreMutations {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the passkey [id], or clear its name with `null`
|
||||
Future<void> rename(String id, String? name) async {
|
||||
try {
|
||||
await read(passkeyApiRepositoryProvider).update(id, name);
|
||||
invalidate(_allProvider);
|
||||
} catch (error, stack) {
|
||||
_log.severe("Failed to rename passkey $id", error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/data/store.dart';
|
||||
import 'package:immich_mobile/domain/models/passkey.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/error_handler.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class PasskeyCreateAction extends ActionBuilder {
|
||||
const PasskeyCreateAction();
|
||||
@@ -27,3 +29,70 @@ class PasskeyCreateAction extends ActionBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PasskeyRenameAction extends ActionBuilder {
|
||||
const PasskeyRenameAction({required this.passkey});
|
||||
|
||||
final Passkey passkey;
|
||||
|
||||
@override
|
||||
ActionItem create(BuildContext context, WidgetRef ref) =>
|
||||
ActionItem(icon: Icons.edit_outlined, label: context.t.edit_passkey, onAction: () => _rename(context, ref));
|
||||
|
||||
Future<void> _rename(BuildContext context, WidgetRef ref) async {
|
||||
final passkeys = ref.read(Store.passkeys);
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => _PasskeyNameDialog(name: passkey.name),
|
||||
);
|
||||
if (name == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await passkeys.rename(passkey.id, name.isEmpty ? null : name);
|
||||
} catch (error, stack) {
|
||||
handleError(error, stack: stack, description: "Failed to rename a passkey");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PasskeyNameDialog extends StatefulWidget {
|
||||
const _PasskeyNameDialog({required this.name});
|
||||
|
||||
final String? name;
|
||||
|
||||
@override
|
||||
State<_PasskeyNameDialog> createState() => _PasskeyNameDialogState();
|
||||
}
|
||||
|
||||
class _PasskeyNameDialogState extends State<_PasskeyNameDialog> {
|
||||
late final _controller = TextEditingController(text: widget.name);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() => Navigator.pop(context, _controller.text.trim());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(context.t.passkeys(count: 1)),
|
||||
content: ImmichTextInput(
|
||||
controller: _controller,
|
||||
label: context.t.name,
|
||||
autofocus: true,
|
||||
keyboardAction: .done,
|
||||
onSubmit: (_) => _save(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(context.t.cancel)),
|
||||
TextButton(onPressed: _save, child: Text(context.t.save)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ class _PasskeyTile extends StatelessWidget {
|
||||
contentPadding: const .symmetric(horizontal: 20),
|
||||
title: Text(passkey.name ?? context.t.passkeys(count: 1)),
|
||||
subtitle: Text('${context.t.created} $created • ${context.t.last_used} $lastUsed'),
|
||||
trailing: ActionIconButton(action: PasskeyRenameAction(passkey: passkey)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,4 +82,31 @@ void main() {
|
||||
Passkey(id: 'new', createdAt: DateTime.utc(2026, 10, 27, 6, 40)),
|
||||
]);
|
||||
});
|
||||
|
||||
test('update sends the new name, and null to clear it', () async {
|
||||
final bodies = <Object?>[];
|
||||
final apiClient = ApiClient(basePath: 'https://demo.immich.app/api')
|
||||
..client = MockClient((request) async {
|
||||
bodies.add(jsonDecode(request.body));
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'id': 'passkey-id',
|
||||
'name': null,
|
||||
'createdAt': '2026-10-27T06:34:31.000Z',
|
||||
'updatedAt': '2026-10-27T06:34:31.000Z',
|
||||
'usedAt': null,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
final sut = PasskeyApiRepository(PasskeysApi(apiClient), ConfigPublicApi(apiClient));
|
||||
|
||||
await sut.update('passkey-id', 'Laptop');
|
||||
await sut.update('passkey-id', null);
|
||||
|
||||
expect(bodies, [
|
||||
{'name': 'Laptop'},
|
||||
{'name': null},
|
||||
]);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user