From 45f54ef0925c024a3c2382dad7c9d1ae27bc68aa Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 12 Feb 2026 10:25:44 +0100 Subject: [PATCH] move auth feature from container to profile --- app/lib/core/providers/router.dart | 28 +- app/lib/core/providers/router.g.dart | 2 +- app/lib/core/routing/routes.dart | 21 +- app/lib/core/routing/routes.g.dart | 27 ++ app/lib/domain/entities/profile.dart | 21 +- app/lib/domain/entities/profile.g.dart | 27 +- .../geckoview/domain/repositories/tab.dart | 28 +- .../geckoview/domain/repositories/tab.g.dart | 2 +- .../dialogs/open_shared_content.dart | 13 +- .../widgets/browser_modules/browser_view.dart | 5 + .../search/presentation/screens/search.dart | 15 +- .../widgets/search_modules/tab_search.dart | 13 +- .../tabs/data/models/container_data.dart | 41 --- .../tabs/data/models/container_data.g.dart | 120 ------- .../domain/providers/selected_container.dart | 42 +-- .../providers/selected_container.g.dart | 2 +- .../presentation/screens/container_edit.dart | 71 ---- .../presentation/widgets/sections.dart | 2 +- .../user/data/models/auth_settings.dart | 70 ++++ .../user/data/models/auth_settings.g.dart | 108 ++++++ .../presentation/screens/profile_edit.dart | 338 +++++++++++++----- .../presentation/widgets/auth_gate.dart | 81 +++++ .../user/domain/providers/profile_auth.dart | 114 ++++++ .../user/domain/providers/profile_auth.g.dart | 110 ++++++ .../user/domain/repositories/profile.dart | 9 +- .../user/domain/repositories/profile.g.dart | 2 +- .../domain/services/local_authentication.dart | 44 +-- .../services/local_authentication.g.dart | 2 +- 28 files changed, 903 insertions(+), 455 deletions(-) create mode 100644 app/lib/features/user/data/models/auth_settings.dart create mode 100644 app/lib/features/user/data/models/auth_settings.g.dart create mode 100644 app/lib/features/user/domain/presentation/widgets/auth_gate.dart create mode 100644 app/lib/features/user/domain/providers/profile_auth.dart create mode 100644 app/lib/features/user/domain/providers/profile_auth.g.dart diff --git a/app/lib/core/providers/router.dart b/app/lib/core/providers/router.dart index 4835d0db..15d1eb5a 100644 --- a/app/lib/core/providers/router.dart +++ b/app/lib/core/providers/router.dart @@ -17,10 +17,13 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:async'; + import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/providers/app_state.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/user/domain/providers/profile_auth.dart'; import 'package:weblibre/features/user/domain/repositories/onboarding.dart'; part 'router.g.dart'; @@ -30,6 +33,7 @@ Future router(Ref ref) async { ref.watch(appStateKeyProvider); //Rebuild router on key changes final onboardingRepository = ref.read(onboardingRepositoryProvider.notifier); + unawaited(ref.read(profileAuthStateProvider.notifier).bootstrapFromProfile()); String? initialLocation; @@ -46,10 +50,32 @@ Future router(Ref ref) async { initialLocation = route.location; } + final profileAuthRefreshListenable = ref.watch(profileAuthProvider); + return GoRouter( debugLogDiagnostics: true, routes: $appRoutes, - initialLocation: initialLocation ?? const BrowserRoute().location, + initialLocation: initialLocation ?? const LockRoute().location, + refreshListenable: profileAuthRefreshListenable, + redirect: (context, state) { + final authenticated = ref.read(profileAuthStateProvider); + final currentTopRouteName = state.topRoute?.name; + final isOnLockRoute = currentTopRouteName == LockRoute.name; + final isOnOnboarding = currentTopRouteName == OnboardingRoute.name; + + // Don't redirect during onboarding + if (isOnOnboarding) return null; + + if (!authenticated && !isOnLockRoute) { + return const LockRoute().location; + } + + if (authenticated && isOnLockRoute) { + return const BrowserRoute().location; + } + + return null; + }, ); } diff --git a/app/lib/core/providers/router.g.dart b/app/lib/core/providers/router.g.dart index be3a5bca..efaaf06d 100644 --- a/app/lib/core/providers/router.g.dart +++ b/app/lib/core/providers/router.g.dart @@ -41,7 +41,7 @@ final class RouterProvider } } -String _$routerHash() => r'cbaa7e982114942303574f9573f4a75b79955583'; +String _$routerHash() => r'4402ca2d7061945c395f3963d6bde29be8999f96'; @ProviderFor(CurrentTopRoute) final currentTopRouteProvider = CurrentTopRouteProvider._(); diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 18f9a702..624c415e 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -77,6 +77,7 @@ import 'package:weblibre/features/user/domain/presentation/screens/profile_backu import 'package:weblibre/features/user/domain/presentation/screens/profile_edit.dart'; import 'package:weblibre/features/user/domain/presentation/screens/profile_list.dart'; import 'package:weblibre/features/user/domain/presentation/screens/profile_restore.dart'; +import 'package:weblibre/features/user/domain/presentation/widgets/auth_gate.dart'; import 'package:weblibre/features/web_feed/presentation/add_feed_dialog.dart'; import 'package:weblibre/features/web_feed/presentation/screens/feed_article.dart'; import 'package:weblibre/features/web_feed/presentation/screens/feed_article_list.dart'; @@ -103,10 +104,13 @@ class AboutRoute extends GoRouteData with $AboutRoute { } @TypedGoRoute( - name: 'OnboardingRoute', - path: '/onboarding/:currentRevision/:targetRevision', + name: OnboardingRoute.name, + path: '${OnboardingRoute.pathPrefix}/:currentRevision/:targetRevision', ) class OnboardingRoute extends GoRouteData with $OnboardingRoute { + static const name = 'OnboardingRoute'; + static const pathPrefix = '/onboarding'; + final int currentRevision; final int targetRevision; @@ -123,3 +127,16 @@ class OnboardingRoute extends GoRouteData with $OnboardingRoute { ); } } + +@TypedGoRoute(name: LockRoute.name, path: LockRoute.path) +class LockRoute extends GoRouteData with $LockRoute { + static const name = 'LockRoute'; + static const path = '/lock'; + + const LockRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return const LockScreen(); + } +} diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 54037de7..4e425d79 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -9,6 +9,7 @@ part of 'routes.dart'; List get $appRoutes => [ $aboutRoute, $onboardingRoute, + $lockRoute, $bangMenuRoute, $bookmarksRoute, $browserRoute, @@ -78,6 +79,32 @@ mixin $OnboardingRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +RouteBase get $lockRoute => GoRouteData.$route( + path: '/lock', + name: 'LockRoute', + factory: $LockRoute._fromState, +); + +mixin $LockRoute on GoRouteData { + static LockRoute _fromState(GoRouterState state) => const LockRoute(); + + @override + String get location => GoRouteData.$location('/lock'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + RouteBase get $bangMenuRoute => GoRouteData.$route( path: '/bangs', name: 'BangRoute', diff --git a/app/lib/domain/entities/profile.dart b/app/lib/domain/entities/profile.dart index 37014b32..8f2fdaac 100644 --- a/app/lib/domain/entities/profile.dart +++ b/app/lib/domain/entities/profile.dart @@ -22,6 +22,7 @@ import 'package:fast_equatable/fast_equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:uuid/uuid_value.dart'; import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/features/user/data/models/auth_settings.dart'; part 'profile.g.dart'; @@ -31,19 +32,31 @@ class Profile with FastEquatable { @CopyWithField(immutable: true) final String id; final String name; + final AuthSettings authSettings; late final uuidValue = UuidValue.fromString(id); static String getNewProfileId() => uuid.v7(); - Profile({required this.id, required this.name}); + Profile({ + required this.id, + required this.name, + AuthSettings? authSettings, + }) : authSettings = authSettings ?? AuthSettings.withDefaults(); - factory Profile.create({required String name}) { - return Profile(id: getNewProfileId(), name: name); + factory Profile.create({ + required String name, + AuthSettings? authSettings, + }) { + return Profile( + id: getNewProfileId(), + name: name, + authSettings: authSettings, + ); } @override - List get hashParameters => [id, name]; + List get hashParameters => [id, name, authSettings]; factory Profile.fromJson(Map json) => _$ProfileFromJson(json); diff --git a/app/lib/domain/entities/profile.g.dart b/app/lib/domain/entities/profile.g.dart index 646459ca..c616f41a 100644 --- a/app/lib/domain/entities/profile.g.dart +++ b/app/lib/domain/entities/profile.g.dart @@ -9,6 +9,8 @@ part of 'profile.dart'; abstract class _$ProfileCWProxy { Profile name(String name); + Profile authSettings(AuthSettings? authSettings); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `Profile(...).copyWith.fieldName(value)`. /// @@ -16,7 +18,7 @@ abstract class _$ProfileCWProxy { /// ```dart /// Profile(...).copyWith(id: 12, name: "My name") /// ``` - Profile call({String name}); + Profile call({String name, AuthSettings? authSettings}); } /// Callable proxy for `copyWith` functionality. @@ -29,6 +31,10 @@ class _$ProfileCWProxyImpl implements _$ProfileCWProxy { @override Profile name(String name) => call(name: name); + @override + Profile authSettings(AuthSettings? authSettings) => + call(authSettings: authSettings); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `Profile(...).copyWith.fieldName(value)`. @@ -37,13 +43,20 @@ class _$ProfileCWProxyImpl implements _$ProfileCWProxy { /// ```dart /// Profile(...).copyWith(id: 12, name: "My name") /// ``` - Profile call({Object? name = const $CopyWithPlaceholder()}) { + Profile call({ + Object? name = const $CopyWithPlaceholder(), + Object? authSettings = const $CopyWithPlaceholder(), + }) { return Profile( id: _value.id, name: name == const $CopyWithPlaceholder() || name == null ? _value.name // ignore: cast_nullable_to_non_nullable : name as String, + authSettings: authSettings == const $CopyWithPlaceholder() + ? _value.authSettings + // ignore: cast_nullable_to_non_nullable + : authSettings as AuthSettings?, ); } } @@ -59,10 +72,16 @@ extension $ProfileCopyWith on Profile { // JsonSerializableGenerator // ************************************************************************** -Profile _$ProfileFromJson(Map json) => - Profile(id: json['id'] as String, name: json['name'] as String); +Profile _$ProfileFromJson(Map json) => Profile( + id: json['id'] as String, + name: json['name'] as String, + authSettings: json['authSettings'] == null + ? null + : AuthSettings.fromJson(json['authSettings'] as Map), +); Map _$ProfileToJson(Profile instance) => { 'id': instance.id, 'name': instance.name, + 'authSettings': instance.authSettings.toJson(), }; diff --git a/app/lib/features/geckoview/domain/repositories/tab.dart b/app/lib/features/geckoview/domain/repositories/tab.dart index 41cfe525..dc9c37d0 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.dart @@ -273,16 +273,6 @@ class TabRepository extends _$TabRepository { if (!ref.mounted) return false; if (containerData != null) { - if (containerData.metadata.authSettings.authenticationRequired) { - // ignore: only_use_keep_alive_inside_keep_alive - if (containerData.id != ref.read(selectedContainerProvider)) { - logger.w( - 'Tried to open authenticated tab $tabId but container not selected', - ); - return false; - } - } - if (containerData.metadata.useProxy) { final proxyPluginHealthy = await GeckoContainerProxyService() .healthcheck(); @@ -380,20 +370,15 @@ class TabRepository extends _$TabRepository { if (!ref.mounted) return; - //We only take containers without authentication! final availableContainers = await ref .read(containerRepositoryProvider.notifier) .getAllContainersWithCount(); - final nextAvailableContainerUnauthenticated = availableContainers - .firstWhereOrNull( - (container) => - container.metadata.authSettings.authenticationRequired == false, - ); + final nextAvailableContainer = availableContainers.firstOrNull; if (!ref.mounted) return; - final nextContainerTabs = await nextAvailableContainerUnauthenticated + final nextContainerTabs = await nextAvailableContainer .mapNotNull( (container) => ref .read(containerRepositoryProvider.notifier) @@ -404,15 +389,6 @@ class TabRepository extends _$TabRepository { if (nextContainerTabs.isNotEmpty) { return _tabsService.selectTab(tabId: nextContainerTabs!.first); } - - if (ref.mounted && - availableContainers.any( - (container) => container.metadata.authSettings.authenticationRequired, - )) { - //Last resort push new tab to avoid any authenticated tab is selected - // ignore: avoid_redundant_argument_values - await addTab(selectTab: true, private: false); - } } Future closeTab(String tabId) async { diff --git a/app/lib/features/geckoview/domain/repositories/tab.g.dart b/app/lib/features/geckoview/domain/repositories/tab.g.dart index 9d51e619..a9ceed42 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'db35cef59703f15f1b099b6adb42f7623bec612f'; +String _$tabRepositoryHash() => r'f0ff983df4115bfed7ff3dbc8994592bc46a1daa'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/open_shared_content.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/open_shared_content.dart index f76e59fc..3fdaaa00 100644 --- a/app/lib/features/geckoview/features/browser/presentation/dialogs/open_shared_content.dart +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/open_shared_content.dart @@ -25,7 +25,6 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; import 'package:weblibre/presentation/icons/weblibre_icons.dart'; import 'package:weblibre/utils/form_validators.dart'; @@ -72,16 +71,8 @@ class OpenSharedContent extends HookConsumerWidget { child: ContainerChips( displayMenu: false, selectedContainer: selectedContainer.value, - onSelected: (container) async { - if (container != null) { - if (await ref - .read(selectedContainerProvider.notifier) - .authenticateContainer(container)) { - selectedContainer.value = container; - } - } else { - selectedContainer.value = container; - } + onSelected: (container) { + selectedContainer.value = container; }, onDeleted: (container) { selectedContainer.value = null; diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 3b605e38..60463544 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -53,6 +53,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/providers/profile_auth.dart'; import 'package:weblibre/features/user/domain/repositories/cache.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/services/local_authentication.dart'; @@ -553,6 +554,10 @@ class _BrowserViewState extends ConsumerState _timerPaused = false; } + unawaited( + ref.read(profileAuthStateProvider.notifier).revalidateAfterResume(), + ); + if (_suggestionCountTime != null && DateTime.now().difference(_suggestionCountTime!) > widget.suggestionTimeout) { diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart index b976b2c4..77a86d2b 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -338,19 +338,8 @@ class SearchScreen extends HookConsumerWidget { height: 48, child: ContainerChips( selectedContainer: selectedContainer.value, - onSelected: (container) async { - if (container != null) { - if (await ref - .read( - selectedContainerProvider - .notifier, - ) - .authenticateContainer(container)) { - selectedContainer.value = container; - } - } else { - selectedContainer.value = container; - } + onSelected: (container) { + selectedContainer.value = container; }, onDeleted: (container) { selectedContainer.value = null; diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index a537fc2c..dcc25311 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -101,7 +101,6 @@ class TabSearch extends HookConsumerWidget { .then((containers) { return containers.firstWhereOrNull( (container) => - !container.metadata.authSettings.authenticationRequired && containerIdsWithResults.value.containsKey(container.id), ); }); @@ -145,16 +144,8 @@ class TabSearch extends HookConsumerWidget { showUnassignedChip: containerIdsWithResults.value.containsKey( null, ), - onSelected: (container) async { - if (container != null) { - if (await ref - .read(selectedContainerProvider.notifier) - .authenticateContainer(container)) { - selectedContainer.value = container; - } - } else { - selectedContainer.value = container; - } + onSelected: (container) { + selectedContainer.value = container; }, onDeleted: (container) { selectedContainer.value = null; diff --git a/app/lib/features/geckoview/features/tabs/data/models/container_data.dart b/app/lib/features/geckoview/features/tabs/data/models/container_data.dart index 67ca69b4..84d8a252 100644 --- a/app/lib/features/geckoview/features/tabs/data/models/container_data.dart +++ b/app/lib/features/geckoview/features/tabs/data/models/container_data.dart @@ -26,49 +26,12 @@ import 'package:weblibre/data/database/converters/icon_data.dart'; part 'container_data.g.dart'; -@CopyWith() -@JsonSerializable() -class ContainerAuthSettings with FastEquatable { - final bool authenticationRequired; - final bool lockOnAppBackground; - final Duration? lockTimeout; - - ContainerAuthSettings({ - required this.authenticationRequired, - required this.lockOnAppBackground, - required this.lockTimeout, - }); - - ContainerAuthSettings.withDefaults({ - bool? authenticationRequired, - bool? lockOnAppBackground, - Duration? lockTimeout, - }) : this( - authenticationRequired: authenticationRequired ?? false, - lockOnAppBackground: lockOnAppBackground ?? false, - lockTimeout: lockTimeout, - ); - - factory ContainerAuthSettings.fromJson(Map json) => - _$ContainerAuthSettingsFromJson(json); - - Map toJson() => _$ContainerAuthSettingsToJson(this); - - @override - List get hashParameters => [ - authenticationRequired, - lockOnAppBackground, - lockTimeout, - ]; -} - @CopyWith() @JsonSerializable(constructor: 'withDefaults') class ContainerMetadata with FastEquatable { @IconDataJsonConverter() final IconData? iconData; final String? contextualIdentity; - final ContainerAuthSettings authSettings; @JsonKey(defaultValue: false) final bool useProxy; @@ -81,7 +44,6 @@ class ContainerMetadata with FastEquatable { ContainerMetadata({ required this.iconData, required this.contextualIdentity, - required this.authSettings, required this.useProxy, required this.clearDataOnExit, required this.assignedSites, @@ -90,14 +52,12 @@ class ContainerMetadata with FastEquatable { ContainerMetadata.withDefaults({ IconData? iconData, String? contextualIdentity, - ContainerAuthSettings? authSettings, bool? useProxy, bool? clearDataOnExit, List? assignedSites, }) : this( iconData: iconData, contextualIdentity: contextualIdentity, - authSettings: authSettings ?? ContainerAuthSettings.withDefaults(), useProxy: useProxy ?? false, clearDataOnExit: clearDataOnExit ?? false, assignedSites: assignedSites, @@ -112,7 +72,6 @@ class ContainerMetadata with FastEquatable { List get hashParameters => [ iconData, contextualIdentity, - authSettings, useProxy, clearDataOnExit, assignedSites, diff --git a/app/lib/features/geckoview/features/tabs/data/models/container_data.g.dart b/app/lib/features/geckoview/features/tabs/data/models/container_data.g.dart index 001bb4b4..d88b53fb 100644 --- a/app/lib/features/geckoview/features/tabs/data/models/container_data.g.dart +++ b/app/lib/features/geckoview/features/tabs/data/models/container_data.g.dart @@ -6,96 +6,11 @@ part of 'container_data.dart'; // CopyWithGenerator // ************************************************************************** -abstract class _$ContainerAuthSettingsCWProxy { - ContainerAuthSettings authenticationRequired(bool authenticationRequired); - - ContainerAuthSettings lockOnAppBackground(bool lockOnAppBackground); - - ContainerAuthSettings lockTimeout(Duration? lockTimeout); - - /// Creates a new instance with the provided field values. - /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerAuthSettings(...).copyWith.fieldName(value)`. - /// - /// Example: - /// ```dart - /// ContainerAuthSettings(...).copyWith(id: 12, name: "My name") - /// ``` - ContainerAuthSettings call({ - bool authenticationRequired, - bool lockOnAppBackground, - Duration? lockTimeout, - }); -} - -/// Callable proxy for `copyWith` functionality. -/// Use as `instanceOfContainerAuthSettings.copyWith(...)` or call `instanceOfContainerAuthSettings.copyWith.fieldName(value)` for a single field. -class _$ContainerAuthSettingsCWProxyImpl - implements _$ContainerAuthSettingsCWProxy { - const _$ContainerAuthSettingsCWProxyImpl(this._value); - - final ContainerAuthSettings _value; - - @override - ContainerAuthSettings authenticationRequired(bool authenticationRequired) => - call(authenticationRequired: authenticationRequired); - - @override - ContainerAuthSettings lockOnAppBackground(bool lockOnAppBackground) => - call(lockOnAppBackground: lockOnAppBackground); - - @override - ContainerAuthSettings lockTimeout(Duration? lockTimeout) => - call(lockTimeout: lockTimeout); - - @override - /// Creates a new instance with the provided field values. - /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerAuthSettings(...).copyWith.fieldName(value)`. - /// - /// Example: - /// ```dart - /// ContainerAuthSettings(...).copyWith(id: 12, name: "My name") - /// ``` - ContainerAuthSettings call({ - Object? authenticationRequired = const $CopyWithPlaceholder(), - Object? lockOnAppBackground = const $CopyWithPlaceholder(), - Object? lockTimeout = const $CopyWithPlaceholder(), - }) { - return ContainerAuthSettings( - authenticationRequired: - authenticationRequired == const $CopyWithPlaceholder() || - authenticationRequired == null - ? _value.authenticationRequired - // ignore: cast_nullable_to_non_nullable - : authenticationRequired as bool, - lockOnAppBackground: - lockOnAppBackground == const $CopyWithPlaceholder() || - lockOnAppBackground == null - ? _value.lockOnAppBackground - // ignore: cast_nullable_to_non_nullable - : lockOnAppBackground as bool, - lockTimeout: lockTimeout == const $CopyWithPlaceholder() - ? _value.lockTimeout - // ignore: cast_nullable_to_non_nullable - : lockTimeout as Duration?, - ); - } -} - -extension $ContainerAuthSettingsCopyWith on ContainerAuthSettings { - /// Returns a callable class used to build a new instance with modified fields. - /// Example: `instanceOfContainerAuthSettings.copyWith(...)` or `instanceOfContainerAuthSettings.copyWith.fieldName(...)`. - // ignore: library_private_types_in_public_api - _$ContainerAuthSettingsCWProxy get copyWith => - _$ContainerAuthSettingsCWProxyImpl(this); -} - abstract class _$ContainerMetadataCWProxy { ContainerMetadata iconData(IconData? iconData); ContainerMetadata contextualIdentity(String? contextualIdentity); - ContainerMetadata authSettings(ContainerAuthSettings authSettings); - ContainerMetadata useProxy(bool useProxy); ContainerMetadata clearDataOnExit(bool clearDataOnExit); @@ -112,7 +27,6 @@ abstract class _$ContainerMetadataCWProxy { ContainerMetadata call({ IconData? iconData, String? contextualIdentity, - ContainerAuthSettings authSettings, bool useProxy, bool clearDataOnExit, List? assignedSites, @@ -133,10 +47,6 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ContainerMetadata contextualIdentity(String? contextualIdentity) => call(contextualIdentity: contextualIdentity); - @override - ContainerMetadata authSettings(ContainerAuthSettings authSettings) => - call(authSettings: authSettings); - @override ContainerMetadata useProxy(bool useProxy) => call(useProxy: useProxy); @@ -159,7 +69,6 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ContainerMetadata call({ Object? iconData = const $CopyWithPlaceholder(), Object? contextualIdentity = const $CopyWithPlaceholder(), - Object? authSettings = const $CopyWithPlaceholder(), Object? useProxy = const $CopyWithPlaceholder(), Object? clearDataOnExit = const $CopyWithPlaceholder(), Object? assignedSites = const $CopyWithPlaceholder(), @@ -173,11 +82,6 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ? _value.contextualIdentity // ignore: cast_nullable_to_non_nullable : contextualIdentity as String?, - authSettings: - authSettings == const $CopyWithPlaceholder() || authSettings == null - ? _value.authSettings - // ignore: cast_nullable_to_non_nullable - : authSettings as ContainerAuthSettings, useProxy: useProxy == const $CopyWithPlaceholder() || useProxy == null ? _value.useProxy // ignore: cast_nullable_to_non_nullable @@ -294,24 +198,6 @@ extension $ContainerDataCopyWith on ContainerData { // JsonSerializableGenerator // ************************************************************************** -ContainerAuthSettings _$ContainerAuthSettingsFromJson( - Map json, -) => ContainerAuthSettings( - authenticationRequired: json['authenticationRequired'] as bool, - lockOnAppBackground: json['lockOnAppBackground'] as bool, - lockTimeout: json['lockTimeout'] == null - ? null - : Duration(microseconds: (json['lockTimeout'] as num).toInt()), -); - -Map _$ContainerAuthSettingsToJson( - ContainerAuthSettings instance, -) => { - 'authenticationRequired': instance.authenticationRequired, - 'lockOnAppBackground': instance.lockOnAppBackground, - 'lockTimeout': instance.lockTimeout?.inMicroseconds, -}; - ContainerMetadata _$ContainerMetadataFromJson(Map json) => ContainerMetadata.withDefaults( iconData: _$JsonConverterFromJson, IconData>( @@ -319,11 +205,6 @@ ContainerMetadata _$ContainerMetadataFromJson(Map json) => const IconDataJsonConverter().fromJson, ), contextualIdentity: json['contextualIdentity'] as String?, - authSettings: json['authSettings'] == null - ? null - : ContainerAuthSettings.fromJson( - json['authSettings'] as Map, - ), useProxy: json['useProxy'] as bool? ?? false, clearDataOnExit: json['clearDataOnExit'] as bool? ?? false, assignedSites: (json['assignedSites'] as List?) @@ -339,7 +220,6 @@ Map _$ContainerMetadataToJson( const IconDataJsonConverter().toJson, ), 'contextualIdentity': instance.contextualIdentity, - 'authSettings': instance.authSettings.toJson(), 'useProxy': instance.useProxy, 'clearDataOnExit': instance.clearDataOnExit, 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart index f75818b7..ac9b0f72 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart @@ -27,7 +27,6 @@ import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; -import 'package:weblibre/features/user/domain/services/local_authentication.dart'; part 'selected_container.g.dart'; @@ -45,48 +44,23 @@ class SelectedContainer extends _$SelectedContainer { return null; } - Future authenticateContainer(ContainerData container) async { - var passAuth = false; - if (container.metadata.authSettings.authenticationRequired) { - final authResult = await ref - .read(localAuthenticationServiceProvider.notifier) - .authenticate( - authKey: 'container_access::${container.id}', - localizedReason: 'Require authentication for container', - settings: container.metadata.authSettings, - useAuthCache: true, - ); - - if (authResult) { - passAuth = true; - } - } else { - passAuth = true; - } - - return passAuth; - } - Future setContainerId(String id) async { final container = await ref .read(containerRepositoryProvider.notifier) .getContainerData(id); if (ref.mounted && container != null) { - final passAuth = await authenticateContainer(container); - if (passAuth) { - if (container.metadata.useProxy) { - final proxyPluginHealthy = await GeckoContainerProxyService() - .healthcheck(); + if (container.metadata.useProxy) { + final proxyPluginHealthy = await GeckoContainerProxyService() + .healthcheck(); - if (proxyPluginHealthy) { - state = id; - return SetContainerResult.successHasProxy; - } - } else { + if (proxyPluginHealthy) { state = id; - return SetContainerResult.success; + return SetContainerResult.successHasProxy; } + } else { + state = id; + return SetContainerResult.success; } } diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart index 36f46a88..e7848b64 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart @@ -41,7 +41,7 @@ final class SelectedContainerProvider } } -String _$selectedContainerHash() => r'e1d20f0c2e7764e82937ba486de52c92184c0e4a'; +String _$selectedContainerHash() => r'0ffa17823ec95c94b9cf9b2d005de19d6ff992f8'; abstract class _$SelectedContainer extends $Notifier { String? build(); diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index c374c316..d01bde9b 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -32,19 +32,10 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/d import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; -import 'package:weblibre/features/user/domain/services/local_authentication.dart'; import 'package:weblibre/presentation/icons/tor_icons.dart'; enum _DialogMode { create, edit } -const _timeoutOptions = >[ - DropdownMenuItem(child: Text('Immediately')), - DropdownMenuItem(value: Duration(minutes: 1), child: Text('1 minute')), - DropdownMenuItem(value: Duration(minutes: 5), child: Text('5 minutes')), - DropdownMenuItem(value: Duration(minutes: 15), child: Text('15 minutes')), - DropdownMenuItem(value: Duration(hours: 1), child: Text('1 hour')), -]; - class ContainerEditScreen extends HookConsumerWidget { final _DialogMode _mode; @@ -77,7 +68,6 @@ class ContainerEditScreen extends HookConsumerWidget { final contextualIdentity = useState( initialContainer.metadata.contextualIdentity, ); - final authSettings = useState(initialContainer.metadata.authSettings); final useProxy = useState(initialContainer.metadata.useProxy); final clearDataOnExit = useState(initialContainer.metadata.clearDataOnExit); final assignedSites = useState(initialContainer.metadata.assignedSites); @@ -106,7 +96,6 @@ class ContainerEditScreen extends HookConsumerWidget { color: selectedColor.value, metadata: initialContainer.metadata.copyWith( contextualIdentity: contextualIdentity.value, - authSettings: authSettings.value, useProxy: useProxy.value && contextualIdentity.value != null, clearDataOnExit: clearDataOnExit.value && contextualIdentity.value != null, @@ -114,24 +103,6 @@ class ContainerEditScreen extends HookConsumerWidget { ), ); - //Check for permissions, when auth is set or getting set - if (initialContainer - .metadata - .authSettings - .authenticationRequired || - container.metadata.authSettings.authenticationRequired) { - final authResult = await ref - .read(localAuthenticationServiceProvider.notifier) - .authenticate( - authKey: 'container_access::${container.id}', - localizedReason: 'Require authentication for container', - ); - - if (!authResult) { - return; - } - } - switch (_mode) { case _DialogMode.create: await ref @@ -283,48 +254,6 @@ class ContainerEditScreen extends HookConsumerWidget { } : null, ), - SwitchListTile.adaptive( - value: authSettings.value.authenticationRequired, - title: const Text('Require Authentication'), - secondary: const Icon(MdiIcons.fingerprint), - contentPadding: EdgeInsets.zero, - onChanged: (value) { - authSettings.value = authSettings.value.copyWith - .authenticationRequired(value); - }, - ), - if (authSettings.value.authenticationRequired) - CheckboxListTile.adaptive( - value: authSettings.value.lockOnAppBackground, - title: const Text('Auto-lock on background'), - controlAffinity: ListTileControlAffinity.leading, - onChanged: (value) { - authSettings.value = authSettings.value.copyWith - .lockOnAppBackground(value!); - }, - ), - if (authSettings.value.authenticationRequired) - CheckboxListTile.adaptive( - value: authSettings.value.lockTimeout != null, - title: const Text('Timeout'), - controlAffinity: ListTileControlAffinity.leading, - onChanged: (value) { - final newValue = value! - ? _timeoutOptions[1].value - : null; - - authSettings.value = authSettings.value.copyWith - .lockTimeout(newValue); - }, - secondary: DropdownButton( - value: authSettings.value.lockTimeout, - items: _timeoutOptions, - onChanged: (value) { - authSettings.value = authSettings.value.copyWith - .lockTimeout(value); - }, - ), - ), ListTile( leading: const Icon(Icons.web), title: const Text('Assigned Sites'), diff --git a/app/lib/features/settings/presentation/widgets/sections.dart b/app/lib/features/settings/presentation/widgets/sections.dart index 55f7487c..db74d4a6 100644 --- a/app/lib/features/settings/presentation/widgets/sections.dart +++ b/app/lib/features/settings/presentation/widgets/sections.dart @@ -27,7 +27,7 @@ class SettingSection extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0), + padding: const EdgeInsets.only(top: 16.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/app/lib/features/user/data/models/auth_settings.dart b/app/lib/features/user/data/models/auth_settings.dart new file mode 100644 index 00000000..8c2ff54b --- /dev/null +++ b/app/lib/features/user/data/models/auth_settings.dart @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'auth_settings.g.dart'; + +enum AutoLockMode { background, timeout } + +@CopyWith() +@JsonSerializable() +class AuthSettings with FastEquatable { + final bool authenticationRequired; + final AutoLockMode autoLockMode; + final Duration timeout; + + AuthSettings({ + required this.authenticationRequired, + required this.autoLockMode, + required this.timeout, + }); + + AuthSettings.withDefaults({ + bool? authenticationRequired, + AutoLockMode? autoLockMode, + Duration? timeout, + }) : this( + authenticationRequired: authenticationRequired ?? false, + autoLockMode: autoLockMode ?? AutoLockMode.background, + timeout: timeout ?? const Duration(minutes: 5), + ); + + AuthSettings withBackgroundLock() { + return copyWith(autoLockMode: AutoLockMode.background); + } + + AuthSettings withTimeoutLock(Duration value) { + return copyWith(autoLockMode: AutoLockMode.timeout, timeout: value); + } + + factory AuthSettings.fromJson(Map json) => + _$AuthSettingsFromJson(json); + + Map toJson() => _$AuthSettingsToJson(this); + + @override + List get hashParameters => [ + authenticationRequired, + autoLockMode, + timeout, + ]; +} diff --git a/app/lib/features/user/data/models/auth_settings.g.dart b/app/lib/features/user/data/models/auth_settings.g.dart new file mode 100644 index 00000000..4a0a3135 --- /dev/null +++ b/app/lib/features/user/data/models/auth_settings.g.dart @@ -0,0 +1,108 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_settings.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$AuthSettingsCWProxy { + AuthSettings authenticationRequired(bool authenticationRequired); + + AuthSettings autoLockMode(AutoLockMode autoLockMode); + + AuthSettings timeout(Duration timeout); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AuthSettings(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// AuthSettings(...).copyWith(id: 12, name: "My name") + /// ``` + AuthSettings call({ + bool authenticationRequired, + AutoLockMode autoLockMode, + Duration timeout, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfAuthSettings.copyWith(...)` or call `instanceOfAuthSettings.copyWith.fieldName(value)` for a single field. +class _$AuthSettingsCWProxyImpl implements _$AuthSettingsCWProxy { + const _$AuthSettingsCWProxyImpl(this._value); + + final AuthSettings _value; + + @override + AuthSettings authenticationRequired(bool authenticationRequired) => + call(authenticationRequired: authenticationRequired); + + @override + AuthSettings autoLockMode(AutoLockMode autoLockMode) => + call(autoLockMode: autoLockMode); + + @override + AuthSettings timeout(Duration timeout) => call(timeout: timeout); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AuthSettings(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// AuthSettings(...).copyWith(id: 12, name: "My name") + /// ``` + AuthSettings call({ + Object? authenticationRequired = const $CopyWithPlaceholder(), + Object? autoLockMode = const $CopyWithPlaceholder(), + Object? timeout = const $CopyWithPlaceholder(), + }) { + return AuthSettings( + authenticationRequired: + authenticationRequired == const $CopyWithPlaceholder() || + authenticationRequired == null + ? _value.authenticationRequired + // ignore: cast_nullable_to_non_nullable + : authenticationRequired as bool, + autoLockMode: + autoLockMode == const $CopyWithPlaceholder() || autoLockMode == null + ? _value.autoLockMode + // ignore: cast_nullable_to_non_nullable + : autoLockMode as AutoLockMode, + timeout: timeout == const $CopyWithPlaceholder() || timeout == null + ? _value.timeout + // ignore: cast_nullable_to_non_nullable + : timeout as Duration, + ); + } +} + +extension $AuthSettingsCopyWith on AuthSettings { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfAuthSettings.copyWith(...)` or `instanceOfAuthSettings.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$AuthSettingsCWProxy get copyWith => _$AuthSettingsCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AuthSettings _$AuthSettingsFromJson(Map json) => AuthSettings( + authenticationRequired: json['authenticationRequired'] as bool, + autoLockMode: $enumDecode(_$AutoLockModeEnumMap, json['autoLockMode']), + timeout: Duration(microseconds: (json['timeout'] as num).toInt()), +); + +Map _$AuthSettingsToJson(AuthSettings instance) => + { + 'authenticationRequired': instance.authenticationRequired, + 'autoLockMode': _$AutoLockModeEnumMap[instance.autoLockMode]!, + 'timeout': instance.timeout.inMicroseconds, + }; + +const _$AutoLockModeEnumMap = { + AutoLockMode.background: 'background', + AutoLockMode.timeout: 'timeout', +}; diff --git a/app/lib/features/user/domain/presentation/screens/profile_edit.dart b/app/lib/features/user/domain/presentation/screens/profile_edit.dart index ae3648db..bf816cb3 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_edit.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_edit.dart @@ -27,20 +27,82 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/filesystem.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/domain/entities/profile.dart'; +import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/user/data/models/auth_settings.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/delete_profile_dialog.dart'; import 'package:weblibre/features/user/domain/presentation/utils/profile_switch_handler.dart'; +import 'package:weblibre/features/user/domain/providers/profile_auth.dart'; import 'package:weblibre/features/user/domain/repositories/profile.dart'; +import 'package:weblibre/features/user/domain/services/local_authentication.dart'; import 'package:weblibre/utils/form_validators.dart'; +const _timeoutOptions = >[ + DropdownMenuItem(value: Duration(minutes: 1), child: Text('1 minute')), + DropdownMenuItem(value: Duration(minutes: 5), child: Text('5 minutes')), + DropdownMenuItem(value: Duration(minutes: 15), child: Text('15 minutes')), + DropdownMenuItem(value: Duration(hours: 1), child: Text('1 hour')), +]; + class ProfileEditScreen extends HookConsumerWidget { final Profile? profile; const ProfileEditScreen({required this.profile}); + Future _handleSave( + BuildContext context, + WidgetRef ref, + GlobalKey formKey, + String name, + AuthSettings authSettings, + ) async { + if (!(formKey.currentState?.validate() ?? false)) { + return; + } + + // Require biometric confirmation when enabling/changing auth + if (profile != null && + (profile!.authSettings.authenticationRequired || + authSettings.authenticationRequired)) { + final authResult = await ref + .read(localAuthenticationServiceProvider.notifier) + .authenticate( + authKey: profileAccessAuthKey(profile!.id), + localizedReason: 'Require authentication for profile', + ); + + if (!authResult) { + return; + } + } + + if (profile != null) { + await ref + .read(profileRepositoryProvider.notifier) + .updateProfileMetadata( + profile!.copyWith(name: name, authSettings: authSettings), + ); + + if (context.mounted) { + context.pop(); + } + } else { + await ref + .read(profileRepositoryProvider.notifier) + .createProfile(name: name, authSettings: authSettings); + + if (context.mounted) { + context.pop(); + } + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final formKey = useMemoized(() => GlobalKey()); final nameTextController = useTextEditingController(text: profile?.name); + final authSettings = useState( + profile?.authSettings ?? AuthSettings.withDefaults(), + ); return Scaffold( appBar: AppBar( @@ -50,27 +112,13 @@ class ProfileEditScreen extends HookConsumerWidget { actions: [ IconButton( onPressed: () async { - if (formKey.currentState?.validate() ?? false) { - if (profile != null) { - await ref - .read(profileRepositoryProvider.notifier) - .updateProfileMetadata( - profile!.copyWith.name(nameTextController.text), - ); - - if (context.mounted) { - context.pop(); - } - } else { - await ref - .read(profileRepositoryProvider.notifier) - .createProfile(name: nameTextController.text); - - if (context.mounted) { - context.pop(); - } - } - } + await _handleSave( + context, + ref, + formKey, + nameTextController.text, + authSettings.value, + ); }, icon: const Icon(Icons.check), ), @@ -78,77 +126,191 @@ class ProfileEditScreen extends HookConsumerWidget { ), body: Form( key: formKey, - child: Padding( + child: ListView( padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: ListView( - children: [ - TextFormField( - controller: nameTextController, - decoration: const InputDecoration( - label: Text('Name'), - floatingLabelBehavior: FloatingLabelBehavior.always, - ), - validator: validateProfileName, + children: [ + TextFormField( + controller: nameTextController, + decoration: const InputDecoration( + label: Text('Name'), + floatingLabelBehavior: FloatingLabelBehavior.always, ), - const SizedBox(height: 16), - if (profile != null) ...[ - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - label: const Text('Backup'), - icon: const Icon(MdiIcons.safe), - onPressed: () async { - await BackupProfileRoute( - profile: jsonEncode(profile!.toJson()), - ).push(context); - }, - ), - ), - const SizedBox(height: 16), - if (filesystem.selectedProfile != profile!.uuidValue) - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - label: const Text('Switch to this Profile'), - icon: const Icon(MdiIcons.accountSwitch), - onPressed: () async { - await handleSwitchProfile(context, ref, profile!); - }, - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - side: BorderSide( - color: Theme.of(context).colorScheme.error, - ), - foregroundColor: Theme.of(context).colorScheme.error, - iconColor: Theme.of(context).colorScheme.error, - ), - label: const Text('Delete'), - icon: const Icon(Icons.delete), - onPressed: () async { - final result = await showDeleteProfileDialog(context); - - if (result == true) { - await ref - .read(profileRepositoryProvider.notifier) - .deleteProfile(profile!.uuidValue.uuid); - - if (context.mounted) { - context.pop(); - } - } - }, - ), - ), - ], - ], - ), + validator: validateProfileName, + ), + const SizedBox(height: 24), + _AuthSection( + authSettings: authSettings.value, + onAuthSettingsChanged: (newSettings) { + authSettings.value = newSettings; + }, + ), + const SizedBox(height: 24), + if (profile != null) ...[_ProfileActionsSection(profile: profile!)], + ], ), ), ); } } + +class _AuthSection extends StatelessWidget { + final AuthSettings authSettings; + final ValueChanged onAuthSettingsChanged; + + const _AuthSection({ + required this.authSettings, + required this.onAuthSettingsChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingSection(name: 'Authentication'), + SwitchListTile.adaptive( + value: authSettings.authenticationRequired, + title: const Text('Require Authentication'), + subtitle: const Text( + 'Lock this profile when switching away from the app', + ), + secondary: const Icon(MdiIcons.fingerprint), + contentPadding: EdgeInsets.zero, + onChanged: (value) { + onAuthSettingsChanged( + authSettings.copyWith.authenticationRequired(value), + ); + }, + ), + if (authSettings.authenticationRequired) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTile( + title: Text('Auto-lock Behavior'), + subtitle: Text('Choose when to lock the profile'), + contentPadding: EdgeInsets.zero, + leading: Icon(MdiIcons.lockClock), + ), + RadioGroup( + groupValue: authSettings.autoLockMode, + onChanged: (value) { + if (value != null) { + onAuthSettingsChanged( + authSettings.copyWith.autoLockMode(value), + ); + } + }, + child: const Column( + children: [ + RadioListTile.adaptive( + value: AutoLockMode.background, + title: Text('Lock on Background'), + subtitle: Text( + 'Lock immediately when app goes to background', + ), + ), + RadioListTile.adaptive( + value: AutoLockMode.timeout, + title: Text('Lock After Timeout'), + subtitle: Text('Lock after a period of inactivity'), + ), + ], + ), + ), + ], + ), + ), + if (authSettings.autoLockMode == AutoLockMode.timeout) + ListTile( + title: const Text('Timeout Duration'), + subtitle: const Text('How long to wait before locking'), + leading: const Icon(MdiIcons.timerOutline), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + trailing: DropdownButton( + value: authSettings.timeout, + items: _timeoutOptions, + underline: const SizedBox.shrink(), + onChanged: (Duration? value) { + if (value != null) { + onAuthSettingsChanged(authSettings.copyWith.timeout(value)); + } + }, + ), + ), + ], + ], + ); + } +} + +class _ProfileActionsSection extends ConsumerWidget { + final Profile profile; + + const _ProfileActionsSection({required this.profile}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingSection(name: 'Profile Actions'), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + label: const Text('Backup'), + icon: const Icon(MdiIcons.safe), + onPressed: () async { + await BackupProfileRoute( + profile: jsonEncode(profile.toJson()), + ).push(context); + }, + ), + ), + const SizedBox(height: 12), + if (filesystem.selectedProfile != profile.uuidValue) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + label: const Text('Switch to this Profile'), + icon: const Icon(MdiIcons.accountSwitch), + onPressed: () async { + await handleSwitchProfile(context, ref, profile); + }, + ), + ), + if (filesystem.selectedProfile != profile.uuidValue) + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide(color: Theme.of(context).colorScheme.error), + foregroundColor: Theme.of(context).colorScheme.error, + iconColor: Theme.of(context).colorScheme.error, + ), + label: const Text('Delete'), + icon: const Icon(Icons.delete), + onPressed: () async { + final result = await showDeleteProfileDialog(context); + + if (result == true) { + await ref + .read(profileRepositoryProvider.notifier) + .deleteProfile(profile.uuidValue.uuid); + + if (context.mounted) { + context.pop(); + } + } + }, + ), + ), + ], + ); + } +} diff --git a/app/lib/features/user/domain/presentation/widgets/auth_gate.dart b/app/lib/features/user/domain/presentation/widgets/auth_gate.dart new file mode 100644 index 00000000..da6ddbad --- /dev/null +++ b/app/lib/features/user/domain/presentation/widgets/auth_gate.dart @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/user/domain/providers/profile_auth.dart'; +import 'package:weblibre/presentation/hooks/on_initialization.dart'; + +class LockScreen extends HookConsumerWidget { + const LockScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isAuthenticating = useState(false); + final didAutoAuthenticate = useRef(false); + + Future authenticate() async { + if (isAuthenticating.value) return; + + isAuthenticating.value = true; + + try { + await ref.read(profileAuthStateProvider.notifier).authenticate(); + } finally { + if (context.mounted) { + isAuthenticating.value = false; + } + } + } + + useOnInitialization(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!didAutoAuthenticate.value) { + didAutoAuthenticate.value = true; + unawaited(authenticate()); + } + }); + return null; + }); + + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(MdiIcons.lock, size: 64), + const SizedBox(height: 16), + const Text('Profile is locked'), + const SizedBox(height: 16), + FilledButton.icon( + style: FilledButton.styleFrom(minimumSize: const Size(160, 40)), + icon: const Icon(MdiIcons.fingerprint), + label: Text(isAuthenticating.value ? 'Unlocking...' : 'Unlock'), + onPressed: isAuthenticating.value ? null : authenticate, + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/user/domain/providers/profile_auth.dart b/app/lib/features/user/domain/providers/profile_auth.dart new file mode 100644 index 00000000..59fb79a9 --- /dev/null +++ b/app/lib/features/user/domain/providers/profile_auth.dart @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/domain/providers.dart'; +import 'package:weblibre/features/user/domain/services/local_authentication.dart'; + +part 'profile_auth.g.dart'; + +String profileAccessAuthKey(String profileId) => 'profile_access::$profileId'; + +@Riverpod(keepAlive: true) +class ProfileAuthState extends _$ProfileAuthState { + bool _bootstrapped = false; + + Future bootstrapFromProfile() async { + if (_bootstrapped) return; + + final profile = await ref.read(selectedProfileProvider.future); + if (!ref.mounted) return; + + _bootstrapped = true; + + if (!profile.authSettings.authenticationRequired) { + _unlock(); + } + } + + Future authenticate() async { + final profile = await ref.read(selectedProfileProvider.future); + if (!ref.mounted) return false; + + if (!profile.authSettings.authenticationRequired) { + _unlock(); + return true; + } + + final result = await ref + .read(localAuthenticationServiceProvider.notifier) + .authenticate( + authKey: profileAccessAuthKey(profile.id), + localizedReason: 'Unlock profile', + settings: profile.authSettings, + useAuthCache: true, + ); + + if (!ref.mounted) return false; + + state = result; + return result; + } + + Future revalidateAfterResume() async { + if (!state) return; + + final profile = await ref.read(selectedProfileProvider.future); + if (!ref.mounted || !profile.authSettings.authenticationRequired) return; + + final cached = ref + .read(localAuthenticationServiceProvider.notifier) + .isCached(profileAccessAuthKey(profile.id)); + + if (!cached && ref.mounted) { + _lock(); + } + } + + void _lock() { + state = false; + } + + void _unlock() { + state = true; + } + + @override + bool build() { + return false; + } +} + +@Riverpod(keepAlive: true) +Raw profileAuthNotifier(Ref ref) { + final notifier = ProfileAuthNotifier(); + + ref.listen(profileAuthStateProvider, (_, _) { + notifier.notify(); + }); + + ref.onDispose(notifier.dispose); + + return notifier; +} + +class ProfileAuthNotifier extends ChangeNotifier { + void notify() => notifyListeners(); +} diff --git a/app/lib/features/user/domain/providers/profile_auth.g.dart b/app/lib/features/user/domain/providers/profile_auth.g.dart new file mode 100644 index 00000000..678b78f9 --- /dev/null +++ b/app/lib/features/user/domain/providers/profile_auth.g.dart @@ -0,0 +1,110 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'profile_auth.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ProfileAuthState) +final profileAuthStateProvider = ProfileAuthStateProvider._(); + +final class ProfileAuthStateProvider + extends $NotifierProvider { + ProfileAuthStateProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'profileAuthStateProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$profileAuthStateHash(); + + @$internal + @override + ProfileAuthState create() => ProfileAuthState(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$profileAuthStateHash() => r'9eb65fdb76baa0b088fc12a8063ea4ee63d54ac4'; + +abstract class _$ProfileAuthState extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +@ProviderFor(profileAuthNotifier) +final profileAuthProvider = ProfileAuthNotifierProvider._(); + +final class ProfileAuthNotifierProvider + extends + $FunctionalProvider< + Raw, + Raw, + Raw + > + with $Provider> { + ProfileAuthNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'profileAuthProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$profileAuthNotifierHash(); + + @$internal + @override + $ProviderElement> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + Raw create(Ref ref) { + return profileAuthNotifier(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Raw value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$profileAuthNotifierHash() => + r'795f47b1494e4a9cdd74f5ff22d431b2bc59ffbd'; diff --git a/app/lib/features/user/domain/repositories/profile.dart b/app/lib/features/user/domain/repositories/profile.dart index 8457e4a0..e046da2c 100644 --- a/app/lib/features/user/domain/repositories/profile.dart +++ b/app/lib/features/user/domain/repositories/profile.dart @@ -20,8 +20,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:uuid/uuid.dart'; import 'package:weblibre/core/filesystem.dart'; - import 'package:weblibre/domain/entities/profile.dart'; +import 'package:weblibre/features/user/data/models/auth_settings.dart'; part 'profile.g.dart'; @@ -40,8 +40,11 @@ class ProfileRepository extends _$ProfileRepository { await filesystem.setStartupProfile(UuidValue.withValidation(id)); } - Future createProfile({required String name}) async { - final profile = Profile.create(name: name); + Future createProfile({ + required String name, + AuthSettings? authSettings, + }) async { + final profile = Profile.create(name: name, authSettings: authSettings); if (!await filesystem.createNewProfile(profile)) { throw Exception('Could not create profile'); } diff --git a/app/lib/features/user/domain/repositories/profile.g.dart b/app/lib/features/user/domain/repositories/profile.g.dart index 0f7ea275..6c8c1ce0 100644 --- a/app/lib/features/user/domain/repositories/profile.g.dart +++ b/app/lib/features/user/domain/repositories/profile.g.dart @@ -33,7 +33,7 @@ final class ProfileRepositoryProvider ProfileRepository create() => ProfileRepository(); } -String _$profileRepositoryHash() => r'e925dba74b0f15244fea8fff8391b5b67509be09'; +String _$profileRepositoryHash() => r'c17702af1e59727ab3fec26e9ca659048e92c8bb'; abstract class _$ProfileRepository extends $AsyncNotifier> { FutureOr> build(); diff --git a/app/lib/features/user/domain/services/local_authentication.dart b/app/lib/features/user/domain/services/local_authentication.dart index 652910bf..7f78d7ba 100644 --- a/app/lib/features/user/domain/services/local_authentication.dart +++ b/app/lib/features/user/domain/services/local_authentication.dart @@ -22,46 +22,50 @@ import 'dart:async'; import 'package:local_auth/local_auth.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; -import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/user/data/models/auth_settings.dart'; part 'local_authentication.g.dart'; @Riverpod(keepAlive: true) class LocalAuthenticationService extends _$LocalAuthenticationService { final _auth = LocalAuthentication(); - final _cache = {}; - - bool _cacheAuth(String authKey) { - final auth = _cache[authKey]; - if (auth != null && auth.$2.lockTimeout != null) { - return DateTime.now().difference(auth.$1) < auth.$2.lockTimeout!; - } - - return false; - } + final _cache = {}; void evictCacheOnBackground() { - _cache.removeWhere((key, value) => value.$2.lockOnAppBackground); + _cache.removeWhere( + (key, value) => value.$2.autoLockMode == AutoLockMode.background, + ); + } + + bool isCached(String authKey) { + final auth = _cache[authKey]; + + if (auth == null) return false; + if (auth.$2.autoLockMode == AutoLockMode.timeout) { + return DateTime.now().difference(auth.$1) < auth.$2.timeout; + } + + // Background mode cache stays valid until app background eviction. + return true; } Future authenticate({ required String authKey, required String localizedReason, - ContainerAuthSettings? settings, + AuthSettings? settings, bool useAuthCache = false, }) async { try { - var result = useAuthCache && _cacheAuth(authKey); + final useCache = useAuthCache && isCached(authKey); + final success = + useCache || + await _auth.authenticate(localizedReason: localizedReason); - if (!result) { - result = await _auth.authenticate(localizedReason: localizedReason); - } - - if (result && settings != null) { + if (success && settings != null) { _cache[authKey] = (DateTime.now(), settings); } - return result; + return success; } on LocalAuthException catch (e, s) { logger.e('Could not authenticate', error: e, stackTrace: s); return false; diff --git a/app/lib/features/user/domain/services/local_authentication.g.dart b/app/lib/features/user/domain/services/local_authentication.g.dart index 8cee406f..5b151bb6 100644 --- a/app/lib/features/user/domain/services/local_authentication.g.dart +++ b/app/lib/features/user/domain/services/local_authentication.g.dart @@ -35,7 +35,7 @@ final class LocalAuthenticationServiceProvider } String _$localAuthenticationServiceHash() => - r'1aab9214af5487dc770658c7be6f66083f5d6931'; + r'0f4b2b47e94b2426a2219eca4eb2258bf683ab7c'; abstract class _$LocalAuthenticationService extends $AsyncNotifier { FutureOr build();