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
This commit is contained in:
Santo Shakil authored and Codex committed 2026-11-05 20:35:16 +03:00
1 parent 22e7b20d3e
commit cfa416e612
7 files changed
+114 -4

No files matched your search

+2
View File
@@ -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",
+10
View File
@@ -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<List<Passkey>> 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);
}
+16
View File
@@ -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<PasskeyMutations> _provider) implem
/// Whether the server and this device support passkeys, and the server has them turned on
AutoDisposeFutureProvider<bool> enabled() => _enabledProvider;
/// Every passkey of the current user
///
/// **NOTE:** This is not reactive to changes, and only hits the HTTP API
AutoDisposeFutureProvider<List<Passkey>> all() => _allProvider;
}
final _enabledProvider = FutureProvider.autoDispose<bool>((ref) async {
@@ -34,6 +40,15 @@ final _enabledProvider = FutureProvider.autoDispose<bool>((ref) async {
}
});
final _allProvider = FutureProvider.autoDispose<List<Passkey>>((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);
+4
View File
@@ -117,6 +117,10 @@ abstract class StoreMutations {
/// Read the state associated with a [provider]
@protected
T read<T>(ProviderListenable<T> 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]
@@ -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;
}
@@ -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'),
);
}
}
+33
View File
@@ -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)),
]);
});
}