diff --git a/mobile/lib/data/server/passkey.dart b/mobile/lib/data/server/passkey.dart index 9013f2903..42ce7569c 100644 --- a/mobile/lib/data/server/passkey.dart +++ b/mobile/lib/data/server/passkey.dart @@ -45,6 +45,10 @@ class PasskeyApiRepository extends ApiRepository { return response.map(_toPasskey).toList(); } + Future 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); } diff --git a/mobile/lib/data/store/passkey.dart b/mobile/lib/data/store/passkey.dart index ce4b0a9c7..5c0b36131 100644 --- a/mobile/lib/data/store/passkey.dart +++ b/mobile/lib/data/store/passkey.dart @@ -89,4 +89,15 @@ class PasskeyMutations extends StoreMutations { rethrow; } } + + /// Rename the passkey [id], or clear its name with `null` + Future 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; + } + } } diff --git a/mobile/lib/presentation/actions/passkey.action.dart b/mobile/lib/presentation/actions/passkey.action.dart index 786d16ca6..bac6f05f7 100644 --- a/mobile/lib/presentation/actions/passkey.action.dart +++ b/mobile/lib/presentation/actions/passkey.action.dart @@ -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 _rename(BuildContext context, WidgetRef ref) async { + final passkeys = ref.read(Store.passkeys); + final name = await showDialog( + 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)), + ], + ); + } +} diff --git a/mobile/lib/widgets/settings/passkey_settings.dart b/mobile/lib/widgets/settings/passkey_settings.dart index c18742330..288c67c63 100644 --- a/mobile/lib/widgets/settings/passkey_settings.dart +++ b/mobile/lib/widgets/settings/passkey_settings.dart @@ -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)), ); } } diff --git a/mobile/test/data/server/passkey_test.dart b/mobile/test/data/server/passkey_test.dart index adc75ee38..fad2266c7 100644 --- a/mobile/test/data/server/passkey_test.dart +++ b/mobile/test/data/server/passkey_test.dart @@ -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 = []; + 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}, + ]); + }); }