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:
Santo Shakil authored and Codex committed 2026-11-05 20:35:16 +03:00
1 parent cfa416e612
commit f727e8edb4
5 files changed
+112

No files matched your search

+4
View File
@@ -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);
}
+11
View File
@@ -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)),
);
}
}
+27
View File
@@ -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},
]);
});
}