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:
1 parent
22e7b20d3e
commit
cfa416e612
7 files changed
+114
-4
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
]);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user