From cfa416e6126c6b02c1297e2c759aea36637185db Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 5 Nov 2026 20:47:25 +0600 Subject: [PATCH] feat(mobile): list passkeys (#32783) * feat(mobile): list passkeys * fix(mobile): show the passkey list as the server's passkeys * fix(mobile): reword and align the passkey list, refresh it from the store --- i18n/en.json | 2 + mobile/lib/data/server/passkey.dart | 10 +++++ mobile/lib/data/store/passkey.dart | 16 +++++++ mobile/lib/data/store/util/cache.dart | 4 ++ mobile/lib/domain/models/passkey.model.dart | 8 ++++ .../widgets/settings/passkey_settings.dart | 45 +++++++++++++++++-- mobile/test/data/server/passkey_test.dart | 33 ++++++++++++++ 7 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 mobile/lib/domain/models/passkey.model.dart diff --git a/i18n/en.json b/i18n/en.json index a85f2d635..51bc6f99f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1712,6 +1712,8 @@ "passkey_added": "Passkey added", "passkeys": "{count, plural, one {Passkey} other {Passkeys}}", "passkeys_not_supported": "Passkeys are not supported by this browser", + "passkeys_on_server": "Passkeys registered with the Immich server", + "passkeys_on_server_description": "Note: Removing a passkey from the Immich server does not remove it from your device", "password": "Password", "password_does_not_match": "Password does not match", "password_required": "Password Required", diff --git a/mobile/lib/data/server/passkey.dart b/mobile/lib/data/server/passkey.dart index 53a894db2..9013f2903 100644 --- a/mobile/lib/data/server/passkey.dart +++ b/mobile/lib/data/server/passkey.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/data/server/api_repository.dart'; +import 'package:immich_mobile/domain/models/passkey.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:openapi/api.dart'; @@ -37,4 +38,13 @@ class PasskeyApiRepository extends ApiRepository { final dto = PasskeyAuthenticationFinishDto(response: credential); return LoginResponse.fromDto(await checkNull(_api.finishAuthentication(dto))); } + + /// Every passkey of the current user + Future> getAll() async { + final response = await checkNull(_api.searchPasskeys()); + return response.map(_toPasskey).toList(); + } + + 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 99d1877e9..ce4b0a9c7 100644 --- a/mobile/lib/data/store/passkey.dart +++ b/mobile/lib/data/store/passkey.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/data/server/passkey.dart'; import 'package:immich_mobile/data/store/util/cache.dart'; +import 'package:immich_mobile/domain/models/passkey.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -18,6 +19,11 @@ extension type const PasskeyStore._(Provider _provider) implem /// Whether the server and this device support passkeys, and the server has them turned on AutoDisposeFutureProvider enabled() => _enabledProvider; + + /// Every passkey of the current user + /// + /// **NOTE:** This is not reactive to changes, and only hits the HTTP API + AutoDisposeFutureProvider> all() => _allProvider; } final _enabledProvider = FutureProvider.autoDispose((ref) async { @@ -34,6 +40,15 @@ final _enabledProvider = FutureProvider.autoDispose((ref) async { } }); +final _allProvider = FutureProvider.autoDispose>((ref) async { + try { + return await ref.watch(passkeyApiRepositoryProvider).getAll(); + } catch (error, stack) { + _log.severe("Failed to get all passkeys", error, stack); + return const []; + } +}); + class PasskeyMutations extends StoreMutations { const PasskeyMutations._(super.ref); @@ -49,6 +64,7 @@ class PasskeyMutations extends StoreMutations { } await apiRepository.finishRegistration(jsonDecode(credential)); + invalidate(_allProvider); return true; } catch (error, stack) { _log.severe("Failed to create a passkey", error, stack); diff --git a/mobile/lib/data/store/util/cache.dart b/mobile/lib/data/store/util/cache.dart index 44ba7c50f..a5b525274 100644 --- a/mobile/lib/data/store/util/cache.dart +++ b/mobile/lib/data/store/util/cache.dart @@ -117,6 +117,10 @@ abstract class StoreMutations { /// Read the state associated with a [provider] @protected T read(ProviderListenable provider) => _ref.read(provider); + + /// Invalidate the state associated with a [provider] + @protected + void invalidate(ProviderOrFamily provider) => _ref.invalidate(provider); } /// Mutations for a store entry whose reads are backed by a [StoreCache] diff --git a/mobile/lib/domain/models/passkey.model.dart b/mobile/lib/domain/models/passkey.model.dart new file mode 100644 index 000000000..aa1de2735 --- /dev/null +++ b/mobile/lib/domain/models/passkey.model.dart @@ -0,0 +1,8 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'passkey.model.freezed.dart'; + +@freezed +abstract class Passkey with _$Passkey { + const factory Passkey({required String id, required DateTime createdAt, String? name, DateTime? usedAt}) = _Passkey; +} diff --git a/mobile/lib/widgets/settings/passkey_settings.dart b/mobile/lib/widgets/settings/passkey_settings.dart index 48a4b8146..c18742330 100644 --- a/mobile/lib/widgets/settings/passkey_settings.dart +++ b/mobile/lib/widgets/settings/passkey_settings.dart @@ -1,19 +1,56 @@ 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/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/datetime_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/passkey.action.dart'; +import 'package:immich_ui/immich_ui.dart'; -class PasskeySettings extends StatelessWidget { +class PasskeySettings extends ConsumerWidget { const PasskeySettings({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final passkeys = ref.watch(Store.passkeys.all()).valueOrNull; + return ListView( - children: const [ - Padding( + children: [ + const Padding( padding: .symmetric(horizontal: 20, vertical: 16), child: ActionButton(action: PasskeyCreateAction(), variant: .filled), ), + if (passkeys == null) + const Center(child: CircularProgressIndicator()) + else if (passkeys.isNotEmpty) ...[ + SettingsSubTitle(title: context.t.passkeys_on_server), + Padding( + padding: const .fromLTRB(20, 4, 20, 0), + child: Text(context.t.passkeys_on_server_description, style: context.textTheme.bodySmall), + ), + for (final passkey in passkeys) _PasskeyTile(key: ValueKey(passkey.id), passkey: passkey), + ], ], ); } } + +class _PasskeyTile extends StatelessWidget { + const _PasskeyTile({super.key, required this.passkey}); + + final Passkey passkey; + + @override + Widget build(BuildContext context) { + final created = passkey.createdAt.toLocal().formatDate(); + final lastUsed = passkey.usedAt?.toLocal().formatDate() ?? context.t.never; + + return ListTile( + 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'), + ); + } +} diff --git a/mobile/test/data/server/passkey_test.dart b/mobile/test/data/server/passkey_test.dart index 471719ba9..adc75ee38 100644 --- a/mobile/test/data/server/passkey_test.dart +++ b/mobile/test/data/server/passkey_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:immich_mobile/data/server/passkey.dart'; +import 'package:immich_mobile/domain/models/passkey.model.dart'; import 'package:openapi/api.dart'; void main() { @@ -49,4 +50,36 @@ void main() { expect((await sut.finishAuthentication(credential)).accessToken, 'token'); expect(body, {'response': credential}); }); + + test('getAll maps passkeys with and without a name and a last use', () async { + const passkeys = [ + { + 'id': 'used', + 'name': 'Google Password Manager', + 'createdAt': '2026-10-27T06:34:31.000Z', + 'updatedAt': '2026-10-27T06:34:31.000Z', + 'usedAt': '2026-10-27T06:34:45.000Z', + }, + { + 'id': 'new', + 'name': null, + 'createdAt': '2026-10-27T06:40:00.000Z', + 'updatedAt': '2026-10-27T06:40:00.000Z', + 'usedAt': null, + }, + ]; + final apiClient = ApiClient(basePath: 'https://demo.immich.app/api') + ..client = MockClient((_) async => http.Response(jsonEncode(passkeys), 200)); + final sut = PasskeyApiRepository(PasskeysApi(apiClient), ConfigPublicApi(apiClient)); + + expect(await sut.getAll(), [ + Passkey( + id: 'used', + createdAt: DateTime.utc(2026, 10, 27, 6, 34, 31), + name: 'Google Password Manager', + usedAt: DateTime.utc(2026, 10, 27, 6, 34, 45), + ), + Passkey(id: 'new', createdAt: DateTime.utc(2026, 10, 27, 6, 40)), + ]); + }); }