Merge branch 'main' into adamve/android_fido_bio

This commit is contained in:
Adam Velebil 2024-03-22 11:41:46 +01:00
commit f5d1b21f19
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
42 changed files with 2016 additions and 445 deletions

View File

@ -271,6 +271,8 @@ class MainActivity : FlutterFragmentActivity() {
private fun processYubiKey(device: YubiKeyDevice) { private fun processYubiKey(device: YubiKeyDevice) {
lifecycleScope.launch { lifecycleScope.launch {
if (device is NfcYubiKeyDevice) {
// verify that current context supports connection provided by the YubiKey // verify that current context supports connection provided by the YubiKey
// if not, switch to a context which supports the connection // if not, switch to a context which supports the connection
val supportedApps = DeviceManager.getSupportedContexts(device) val supportedApps = DeviceManager.getSupportedContexts(device)
@ -288,6 +290,7 @@ class MainActivity : FlutterFragmentActivity() {
if (contextManager == null) { if (contextManager == null) {
switchContext(DeviceManager.getPreferredContext(supportedApps)) switchContext(DeviceManager.getPreferredContext(supportedApps))
} }
}
contextManager?.let { contextManager?.let {
try { try {

View File

@ -295,7 +295,7 @@ class FidoManager(
fidoViewModel.setSessionState( fidoViewModel.setSessionState(
Session( Session(
fidoSession.cachedInfo, fidoSession.info,
pinStore.hasPin() pinStore.hasPin()
) )
) )

View File

@ -42,6 +42,7 @@ from yubikit.logging import LOG_LEVEL
from ykman.pcsc import list_devices, YK_READER_NAME from ykman.pcsc import list_devices, YK_READER_NAME
from smartcard.Exceptions import SmartcardException, NoCardException from smartcard.Exceptions import SmartcardException, NoCardException
from smartcard.pcsc.PCSCExceptions import EstablishContextException from smartcard.pcsc.PCSCExceptions import EstablishContextException
from smartcard.CardMonitoring import CardObserver, CardMonitor
from hashlib import sha256 from hashlib import sha256
from dataclasses import asdict from dataclasses import asdict
from typing import Mapping, Tuple from typing import Mapping, Tuple
@ -263,9 +264,6 @@ class AbstractDeviceNode(RpcNode):
class UsbDeviceNode(AbstractDeviceNode): class UsbDeviceNode(AbstractDeviceNode):
def __init__(self, device, info):
super().__init__(device, info)
def _supports_connection(self, conn_type): def _supports_connection(self, conn_type):
return self._device.pid.supports_connection(conn_type) return self._device.pid.supports_connection(conn_type)
@ -308,15 +306,53 @@ class UsbDeviceNode(AbstractDeviceNode):
raise ConnectionException("fido", e) raise ConnectionException("fido", e)
class _ReaderObserver(CardObserver):
def __init__(self, device):
self.device = device
self.card = None
self.data = None
def update(self, observable, actions):
added, removed = actions
for card in added:
if card.reader == self.device.reader.name:
if card != self.card:
self.card = card
break
else:
self.card = None
self.data = None
logger.debug(f"NFC card: {self.card}")
class ReaderDeviceNode(AbstractDeviceNode): class ReaderDeviceNode(AbstractDeviceNode):
def __init__(self, device, info):
super().__init__(device, info)
self._observer = _ReaderObserver(device)
self._monitor = CardMonitor()
self._monitor.addObserver(self._observer)
def close(self):
self._monitor.deleteObserver(self._observer)
super().close()
def get_data(self): def get_data(self):
if self._observer.data is None:
card = self._observer.card
if card is None:
return dict(present=False, status="no-card")
try: try:
with self._device.open_connection(SmartCardConnection) as conn: with self._device.open_connection(SmartCardConnection) as conn:
return dict(self._read_data(conn), present=True) self._observer.data = dict(self._read_data(conn), present=True)
except NoCardException: except NoCardException:
return dict(present=False, status="no-card") return dict(present=False, status="no-card")
except ValueError: except ValueError:
return dict(present=False, status="unknown-device") self._observer.data = dict(present=False, status="unknown-device")
return self._observer.data
@action(closes_child=False)
def get(self, params, event, signal):
return super().get(params, event, signal)
@child @child
def ccid(self): def ccid(self):

View File

@ -399,12 +399,41 @@ class SlotNode(RpcNode):
else None, else None,
) )
@action(condition=lambda self: self.certificate) @action(condition=lambda self: self.certificate or self.metadata)
def delete(self, params, event, signal): def delete(self, params, event, signal):
delete_cert = params.pop("delete_cert", False)
delete_key = params.pop("delete_key", False)
if not delete_cert and not delete_key:
raise ValueError("Missing delete option")
if delete_cert:
self.session.delete_certificate(self.slot) self.session.delete_certificate(self.slot)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid()) self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
self._refresh()
self.certificate = None self.certificate = None
if delete_key:
self.session.delete_key(self.slot)
self._refresh()
return dict()
@action(condition=lambda self: self.metadata)
def move_key(self, params, event, signal):
destination = params.pop("destination")
overwrite_key = params.pop("overwrite_key")
include_certificate = params.pop("include_certificate")
if include_certificate:
source_object = self.session.get_object(OBJECT_ID.from_slot(self.slot))
destination = SLOT(int(destination, base=16))
if overwrite_key:
self.session.delete_key(destination)
self.session.move_key(self.slot, destination)
if include_certificate:
self.session.put_object(OBJECT_ID.from_slot(destination), source_object)
self.session.delete_certificate(self.slot)
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
self.certificate = None
self._refresh()
return dict() return dict()
@action @action

View File

@ -24,7 +24,6 @@ import 'package:window_manager/window_manager.dart';
import '../about_page.dart'; import '../about_page.dart';
import '../core/state.dart'; import '../core/state.dart';
import '../desktop/state.dart'; import '../desktop/state.dart';
import '../oath/keys.dart';
import 'message.dart'; import 'message.dart';
import 'models.dart'; import 'models.dart';
import 'state.dart'; import 'state.dart';
@ -130,8 +129,8 @@ class GlobalShortcuts extends ConsumerWidget {
return null; return null;
}), }),
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (intent) { SearchIntent: CallbackAction<SearchIntent>(onInvoke: (intent) {
// If the OATH view doesn't have focus, but is shown, find and select the search bar. // If the view doesn't have focus, but is shown, find and select the search bar.
final searchContext = searchAccountsField.currentContext; final searchContext = searchField.currentContext;
if (searchContext != null) { if (searchContext != null) {
if (!Navigator.of(searchContext).canPop()) { if (!Navigator.of(searchContext).canPop()) {
return Actions.maybeInvoke(searchContext, intent); return Actions.maybeInvoke(searchContext, intent);

View File

@ -53,7 +53,7 @@ class ActionListItem extends StatelessWidget {
// }; // };
return ListTile( return ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)),
title: Text(title), title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null, subtitle: subtitle != null ? Text(subtitle!) : null,
leading: Opacity( leading: Opacity(

View File

@ -77,7 +77,7 @@ class _AppListItemState<T> extends ConsumerState<AppListItem> {
item: widget.item, item: widget.item,
child: InkWell( child: InkWell(
focusNode: _focusNode, focusNode: _focusNode,
borderRadius: BorderRadius.circular(30), borderRadius: BorderRadius.circular(48),
onSecondaryTapDown: buildPopupActions == null onSecondaryTapDown: buildPopupActions == null
? null ? null
: (details) { : (details) {

View File

@ -14,11 +14,15 @@
* limitations under the License. * limitations under the License.
*/ */
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:sliver_tools/sliver_tools.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../../management/models.dart'; import '../../management/models.dart';
@ -30,12 +34,30 @@ import 'fs_dialog.dart';
import 'keys.dart'; import 'keys.dart';
import 'navigation.dart'; import 'navigation.dart';
// We use global keys here to maintain the NavigatorContent between AppPages. final _navigationProvider = StateNotifierProvider<_NavigationProvider, bool>(
(ref) => _NavigationProvider());
class _NavigationProvider extends StateNotifier<bool> {
_NavigationProvider() : super(true);
void toggleExpanded() {
state = !state;
}
}
// We use global keys here to maintain the content between AppPages,
// and keep track of what has been scrolled under AppBar
final _navKey = GlobalKey(); final _navKey = GlobalKey();
final _navExpandedKey = GlobalKey(); final _navExpandedKey = GlobalKey();
final _sliverTitleGlobalKey = GlobalKey();
final _sliverTitleWrapperGlobalKey = GlobalKey();
final _headerSliverGlobalKey = GlobalKey();
final _detailsViewGlobalKey = GlobalKey();
final _mainContentGlobalKey = GlobalKey();
class AppPage extends StatelessWidget { class AppPage extends ConsumerStatefulWidget {
final String? title; final String? title;
final String? alternativeTitle;
final String? footnote; final String? footnote;
final Widget Function(BuildContext context, bool expanded) builder; final Widget Function(BuildContext context, bool expanded) builder;
final Widget Function(BuildContext context)? detailViewBuilder; final Widget Function(BuildContext context)? detailViewBuilder;
@ -49,9 +71,11 @@ class AppPage extends StatelessWidget {
final Widget? fileDropOverlay; final Widget? fileDropOverlay;
final Function(File file)? onFileDropped; final Function(File file)? onFileDropped;
final List<Capability>? capabilities; final List<Capability>? capabilities;
const AppPage({ final Widget? headerSliver;
super.key, const AppPage(
{super.key,
this.title, this.title,
this.alternativeTitle,
this.footnote, this.footnote,
required this.builder, required this.builder,
this.centered = false, this.centered = false,
@ -64,14 +88,45 @@ class AppPage extends StatelessWidget {
this.onFileDropped, this.onFileDropped,
this.delayedContent = false, this.delayedContent = false,
this.keyActionsBadge = false, this.keyActionsBadge = false,
}) : assert(!(onFileDropped != null && fileDropOverlay == null), this.headerSliver})
: assert(!(onFileDropped != null && fileDropOverlay == null),
'Declaring onFileDropped requires declaring a fileDropOverlay'); 'Declaring onFileDropped requires declaring a fileDropOverlay');
@override @override
Widget build(BuildContext context) => LayoutBuilder( ConsumerState<ConsumerStatefulWidget> createState() => _AppPageState();
}
class _AppPageState extends ConsumerState<AppPage> {
final _VisibilityController _sliverTitleController = _VisibilityController();
final _VisibilityController _headerSliverController = _VisibilityController();
final _VisibilityController _navController = _VisibilityController();
final _VisibilityController _detailsController = _VisibilityController();
late _VisibilitiesController _scrolledUnderController;
final ScrollController _sliverTitleScrollController = ScrollController();
@override
void initState() {
super.initState();
_scrolledUnderController = _VisibilitiesController(
[_sliverTitleController, _navController, _detailsController]);
}
@override
void dispose() {
_sliverTitleController.dispose();
_headerSliverController.dispose();
_navController.dispose();
_detailsController.dispose();
_scrolledUnderController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final width = constraints.maxWidth; final width = constraints.maxWidth;
if (width < 400 || if (width < 400 ||
(isAndroid && width < 600 && width < constraints.maxHeight)) { (isAndroid && width < 600 && width < constraints.maxHeight)) {
return _buildScaffold(context, true, false, false); return _buildScaffold(context, true, false, false);
@ -87,34 +142,11 @@ class AppPage extends StatelessWidget {
if (scaffoldState?.isDrawerOpen == true) { if (scaffoldState?.isDrawerOpen == true) {
scaffoldState?.openEndDrawer(); scaffoldState?.openEndDrawer();
} }
return Scaffold( return _buildScaffold(context, false, true, true);
body: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 280,
child: SingleChildScrollView(
child: Column(
children: [
_buildLogo(context),
NavigationContent(
key: _navExpandedKey,
shouldPop: false,
extended: true,
),
],
),
),
),
Expanded(
child: _buildScaffold(context, false, false, true),
),
],
),
);
} }
}, },
); );
}
Widget _buildLogo(BuildContext context) { Widget _buildLogo(BuildContext context) {
final color = final color =
@ -160,40 +192,159 @@ class AppPage extends StatelessWidget {
)); ));
} }
void _scrollElement(
BuildContext context,
ScrollController scrollController,
_ScrollDirection direction,
_VisibilityController controller,
GlobalKey targetKey,
GlobalKey? anchorKey) {
if (direction != _ScrollDirection.idle) {
final currentContext = targetKey.currentContext;
if (currentContext == null) return;
final RenderBox renderBox =
currentContext.findRenderObject() as RenderBox;
final RenderBox? anchorRenderBox = anchorKey != null
? anchorKey.currentContext?.findRenderObject() as RenderBox?
: null;
final anchorHeight = anchorRenderBox != null
? anchorRenderBox.size.height
: Scaffold.of(context).appBarMaxHeight!;
final targetHeight = renderBox.size.height;
final positionOffset = anchorRenderBox != null
? Offset(0, -anchorRenderBox.localToGlobal(Offset.zero).dy)
: Offset.zero;
final position = renderBox.localToGlobal(positionOffset);
if (direction == _ScrollDirection.up) {
var offset = scrollController.position.pixels +
(targetHeight - (anchorHeight - position.dy));
if (offset > scrollController.position.maxScrollExtent) {
offset = scrollController.position.maxScrollExtent;
}
Timer.run(() {
scrollController.animateTo(offset,
duration: const Duration(milliseconds: 100), curve: Curves.ease);
});
} else {
var offset =
scrollController.position.pixels - (anchorHeight - position.dy);
if (offset < scrollController.position.minScrollExtent) {
offset = scrollController.position.minScrollExtent;
}
if (controller.visibility != _Visibility.visible) {
Timer.run(() {
scrollController.animateTo(offset,
duration: const Duration(milliseconds: 100),
curve: Curves.ease);
});
}
}
}
}
Widget _buildTitle(BuildContext context) { Widget _buildTitle(BuildContext context) {
return ListenableBuilder(
listenable: _sliverTitleController,
builder: (context, child) {
_scrollElement(
context,
_sliverTitleScrollController,
_sliverTitleController.scrollDirection,
_sliverTitleController,
_sliverTitleWrapperGlobalKey,
null);
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(title!, Flexible(
child: Text(
key: _sliverTitleGlobalKey,
widget.alternativeTitle ?? widget.title!,
style: Theme.of(context).textTheme.displaySmall!.copyWith( style: Theme.of(context).textTheme.displaySmall!.copyWith(
color: Theme.of(context).colorScheme.primary.withOpacity(0.9))), color: widget.alternativeTitle != null
if (capabilities != null) ? Theme.of(context)
.colorScheme
.onSurfaceVariant
.withOpacity(0.4)
: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.9),
),
overflow: TextOverflow.ellipsis,
),
),
if (widget.capabilities != null && widget.alternativeTitle == null)
Wrap( Wrap(
spacing: 4.0, spacing: 4.0,
runSpacing: 8.0, runSpacing: 8.0,
children: [...capabilities!.map((c) => CapabilityBadge(c))], children: [
...widget.capabilities!.map((c) => CapabilityBadge(c))
],
) )
], ],
); );
},
);
}
Widget? _buildAppBarTitle(
BuildContext context, bool hasRail, bool hasManage, bool fullyExpanded) {
final showNavigation = ref.watch(_navigationProvider);
EdgeInsets padding;
if (fullyExpanded) {
padding = EdgeInsets.only(left: showNavigation ? 280 : 72, right: 320);
} else if (!hasRail && hasManage) {
padding = const EdgeInsets.only(right: 320);
} else if (hasRail && hasManage) {
padding = const EdgeInsets.only(left: 72, right: 320);
} else if (hasRail && !hasManage) {
padding = const EdgeInsets.only(left: 72);
} else {
padding = const EdgeInsets.all(0);
}
if (widget.title != null) {
return ListenableBuilder(
listenable: _sliverTitleController,
builder: (context, child) {
final visible =
_sliverTitleController.visibility == _Visibility.scrolledUnder;
return Padding(
padding: padding,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: visible ? 1 : 0,
child: Text(widget.alternativeTitle ?? widget.title!),
),
);
},
);
}
return null;
} }
Widget _buildMainContent(BuildContext context, bool expanded) { Widget _buildMainContent(BuildContext context, bool expanded) {
final actions = actionsBuilder?.call(context, expanded) ?? []; final actions = widget.actionsBuilder?.call(context, expanded) ?? [];
final content = Column( final content = Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAxisAlignment: widget.centered
centered ? CrossAxisAlignment.center : CrossAxisAlignment.start, ? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [ children: [
if (title != null && !centered) widget.builder(context, expanded),
Padding(
padding:
const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 24.0),
child: _buildTitle(context),
),
builder(context, expanded),
if (actions.isNotEmpty) if (actions.isNotEmpty)
Align( Align(
alignment: centered ? Alignment.center : Alignment.centerLeft, alignment:
widget.centered ? Alignment.center : Alignment.centerLeft,
child: Padding( child: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 16, bottom: 0, left: 16, right: 16), top: 16, bottom: 0, left: 16, right: 16),
@ -204,14 +355,14 @@ class AppPage extends StatelessWidget {
), ),
), ),
), ),
if (footnote != null) if (widget.footnote != null)
Padding( Padding(
padding: padding:
const EdgeInsets.only(bottom: 16, top: 33, left: 16, right: 16), const EdgeInsets.only(bottom: 16, top: 33, left: 16, right: 16),
child: Opacity( child: Opacity(
opacity: 0.6, opacity: 0.6,
child: Text( child: Text(
footnote!, widget.footnote!,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
@ -220,7 +371,7 @@ class AppPage extends StatelessWidget {
); );
final safeArea = SafeArea( final safeArea = SafeArea(
child: delayedContent child: widget.delayedContent
? DelayedVisibility( ? DelayedVisibility(
key: GlobalKey(), // Ensure we reset the delay on rebuild key: GlobalKey(), // Ensure we reset the delay on rebuild
delay: const Duration(milliseconds: 400), delay: const Duration(milliseconds: 400),
@ -229,10 +380,9 @@ class AppPage extends StatelessWidget {
: content, : content,
); );
if (centered) { if (widget.centered) {
return Stack( return Stack(children: [
children: [ if (widget.title != null)
if (title != null)
Positioned.fill( Positioned.fill(
child: Align( child: Align(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
@ -244,24 +394,85 @@ class AppPage extends StatelessWidget {
), ),
), ),
Positioned.fill( Positioned.fill(
top: title != null ? 68.0 : 0, top: widget.title != null ? 68.0 : 0,
child: Align( child: Align(
alignment: Alignment.center, alignment: Alignment.center,
child: ScrollConfiguration( child: ScrollConfiguration(
behavior: behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false), ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(), physics: isAndroid
? const ClampingScrollPhysics(
parent: AlwaysScrollableScrollPhysics())
: null,
child: safeArea, child: safeArea,
), ),
), ),
), ),
) )
]);
}
if (widget.title != null) {
return _VisibilityListener(
targetKey: _sliverTitleGlobalKey,
controller: _sliverTitleController,
subTargetKey:
widget.headerSliver != null ? _headerSliverGlobalKey : null,
subController:
widget.headerSliver != null ? _headerSliverController : null,
subAnchorKey:
widget.headerSliver != null ? _sliverTitleWrapperGlobalKey : null,
child: CustomScrollView(
physics: isAndroid
? const ClampingScrollPhysics(
parent: AlwaysScrollableScrollPhysics())
: null,
controller: _sliverTitleScrollController,
key: _mainContentGlobalKey,
slivers: [
SliverMainAxisGroup(
slivers: [
SliverPinnedHeader(
child: ColoredBox(
color: Theme.of(context).colorScheme.background,
child: Padding(
key: _sliverTitleWrapperGlobalKey,
padding: const EdgeInsets.only(
left: 16.0, right: 16.0, bottom: 12.0, top: 4.0),
child: _buildTitle(context),
),
),
),
if (widget.headerSliver != null)
SliverToBoxAdapter(
child: ListenableBuilder(
listenable: _headerSliverController,
builder: (context, child) {
_scrollElement(
context,
_sliverTitleScrollController,
_headerSliverController.scrollDirection,
_headerSliverController,
_headerSliverGlobalKey,
_sliverTitleWrapperGlobalKey);
return Container(
key: _headerSliverGlobalKey,
child: widget.headerSliver);
},
))
], ],
),
SliverToBoxAdapter(child: safeArea)
],
),
); );
} }
return SingleChildScrollView( return SingleChildScrollView(
physics: isAndroid
? const ClampingScrollPhysics(parent: AlwaysScrollableScrollPhysics())
: null,
primary: false, primary: false,
child: safeArea, child: safeArea,
); );
@ -269,12 +480,14 @@ class AppPage extends StatelessWidget {
Scaffold _buildScaffold( Scaffold _buildScaffold(
BuildContext context, bool hasDrawer, bool hasRail, bool hasManage) { BuildContext context, bool hasDrawer, bool hasRail, bool hasManage) {
final fullyExpanded = !hasDrawer && hasRail && hasManage;
final showNavigation = ref.watch(_navigationProvider);
var body = _buildMainContent(context, hasManage); var body = _buildMainContent(context, hasManage);
if (onFileDropped != null) { if (widget.onFileDropped != null) {
body = FileDropTarget( body = FileDropTarget(
onFileDropped: onFileDropped!, onFileDropped: widget.onFileDropped!,
overlay: fileDropOverlay!, overlay: widget.fileDropOverlay!,
child: body, child: body,
); );
} }
@ -282,9 +495,12 @@ class AppPage extends StatelessWidget {
body = Row( body = Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (hasRail) if (hasRail && (!fullyExpanded || !showNavigation))
SizedBox( SizedBox(
width: 72, width: 72,
child: _VisibilityListener(
targetKey: _navKey,
controller: _navController,
child: SingleChildScrollView( child: SingleChildScrollView(
child: NavigationContent( child: NavigationContent(
key: _navKey, key: _navKey,
@ -293,6 +509,24 @@ class AppPage extends StatelessWidget {
), ),
), ),
), ),
),
if (fullyExpanded && showNavigation)
SizedBox(
width: 280,
child: _VisibilityListener(
controller: _navController,
targetKey: _navExpandedKey,
child: SingleChildScrollView(
child: Material(
type: MaterialType.transparency,
child: NavigationContent(
key: _navExpandedKey,
shouldPop: false,
extended: true,
),
),
),
)),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: GestureDetector( child: GestureDetector(
@ -308,47 +542,85 @@ class AppPage extends StatelessWidget {
]), ]),
)), )),
if (hasManage && if (hasManage &&
(detailViewBuilder != null || keyActionsBuilder != null)) (widget.detailViewBuilder != null ||
SingleChildScrollView( widget.keyActionsBuilder != null))
_VisibilityListener(
controller: _detailsController,
targetKey: _detailsViewGlobalKey,
child: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox( child: SizedBox(
width: 320, width: 320,
child: Column( child: Column(
key: _detailsViewGlobalKey,
children: [ children: [
if (detailViewBuilder != null) if (widget.detailViewBuilder != null)
detailViewBuilder!(context), widget.detailViewBuilder!(context),
if (keyActionsBuilder != null) if (widget.keyActionsBuilder != null)
keyActionsBuilder!(context), widget.keyActionsBuilder!(context),
], ],
), ),
), ),
), ),
), ),
),
], ],
); );
} }
return Scaffold( return Scaffold(
key: scaffoldGlobalKey, key: scaffoldGlobalKey,
appBar: AppBar( appBar: AppBar(
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: ListenableBuilder(
listenable: _scrolledUnderController,
builder: (context, child) {
final visible = _scrolledUnderController.someIsScrolledUnder;
return AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: Container(
color: Theme.of(context).colorScheme.secondaryContainer,
height: 1.0,
),
);
},
),
),
scrolledUnderElevation: 0.0, scrolledUnderElevation: 0.0,
leadingWidth: hasRail ? 84 : null, leadingWidth: hasRail ? 84 : null,
backgroundColor: Theme.of(context).colorScheme.background,
title: _buildAppBarTitle(
context,
hasRail,
hasManage,
fullyExpanded,
),
leading: hasRail leading: hasRail
? const Row( ? Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
Expanded( Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: DrawerButton(), child: DrawerButton(
onPressed: fullyExpanded
? () {
ref
.read(_navigationProvider.notifier)
.toggleExpanded();
}
: null,
),
)), )),
SizedBox(width: 12), const SizedBox(width: 12),
], ],
) )
: null, : null,
actions: [ actions: [
if (actionButtonBuilder == null && if (widget.actionButtonBuilder == null &&
(keyActionsBuilder != null && !hasManage)) (widget.keyActionsBuilder != null && !hasManage))
Padding( Padding(
padding: const EdgeInsets.only(left: 4), padding: const EdgeInsets.only(left: 4),
child: IconButton( child: IconButton(
@ -360,12 +632,12 @@ class AppPage extends StatelessWidget {
builder: (context) => FsDialog( builder: (context) => FsDialog(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 32), padding: const EdgeInsets.only(top: 32),
child: keyActionsBuilder!(context), child: widget.keyActionsBuilder!(context),
), ),
), ),
); );
}, },
icon: keyActionsBadge icon: widget.keyActionsBadge
? const Badge( ? const Badge(
child: Icon(Symbols.more_vert), child: Icon(Symbols.more_vert),
) )
@ -375,10 +647,10 @@ class AppPage extends StatelessWidget {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
), ),
), ),
if (actionButtonBuilder != null) if (widget.actionButtonBuilder != null)
Padding( Padding(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: actionButtonBuilder!.call(context), child: widget.actionButtonBuilder!.call(context),
), ),
], ],
), ),
@ -408,3 +680,203 @@ class CapabilityBadge extends StatelessWidget {
); );
} }
} }
enum _Visibility { visible, topScrolledUnder, halfScrolledUnder, scrolledUnder }
enum _ScrollDirection { idle, up, down }
class _VisibilityController with ChangeNotifier {
_Visibility _visibility = _Visibility.visible;
_ScrollDirection _scrollDirection = _ScrollDirection.idle;
void setVisibility(_Visibility visibility) {
if (visibility != _visibility) {
_visibility = visibility;
if (_visibility != _Visibility.visible) {
_scrollDirection = _ScrollDirection.idle;
}
notifyListeners();
}
}
void notifyScroll(_ScrollDirection scrollDirection) {
if (visibility != _Visibility.scrolledUnder) {
_scrollDirection = scrollDirection;
notifyListeners();
}
}
_ScrollDirection get scrollDirection => _scrollDirection;
_Visibility get visibility => _visibility;
}
class _VisibilitiesController with ChangeNotifier {
final List<_VisibilityController> controllers;
bool someIsScrolledUnder = false;
_VisibilitiesController(this.controllers) {
for (var element in controllers) {
element.addListener(() {
_setScrolledUnder();
});
}
}
void _setScrolledUnder() {
final val =
controllers.any((element) => element.visibility != _Visibility.visible);
if (val != someIsScrolledUnder) {
someIsScrolledUnder = val;
notifyListeners();
}
}
}
class _VisibilityListener extends StatefulWidget {
final _VisibilityController controller;
final Widget child;
final GlobalKey targetKey;
final _VisibilityController? subController;
final GlobalKey? subTargetKey;
final GlobalKey? subAnchorKey;
const _VisibilityListener(
{required this.controller,
required this.child,
required this.targetKey,
this.subController,
this.subTargetKey,
this.subAnchorKey})
: assert(
(subController == null &&
subTargetKey == null &&
subAnchorKey == null) ||
(subController != null &&
subTargetKey != null &&
subAnchorKey != null),
'Declaring requires subTargetKey and subAnchorKey, and vice versa',
);
@override
State<_VisibilityListener> createState() => _VisibilityListenerState();
}
class _VisibilityListenerState extends State<_VisibilityListener> {
bool disableScroll = false;
@override
Widget build(BuildContext context) => Listener(
onPointerDown: (event) {
setState(() {
disableScroll = true;
});
},
onPointerUp: (event) {
setState(() {
disableScroll = false;
});
},
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
if (!disableScroll) {
setState(() {
disableScroll = true;
});
Timer(const Duration(seconds: 1), () {
if (mounted) {
setState(() {
disableScroll = false;
});
}
});
}
}
},
child: NotificationListener(
onNotification: (notification) {
if (notification is ScrollMetricsNotification ||
notification is ScrollUpdateNotification) {
_handleScrollUpdate(context);
}
if (notification is ScrollEndNotification &&
widget.child is CustomScrollView) {
// Disable auto scrolling for mouse wheel and scrollbar
_handleScrollEnd(context);
}
return false;
},
child: widget.child,
),
);
void _handleScrollUpdate(
BuildContext context,
) {
widget.controller
.setVisibility(_scrolledUnderState(context, widget.targetKey, null));
if (widget.subController != null) {
widget.subController!.setVisibility(_scrolledUnderState(
context, widget.subTargetKey!, widget.subAnchorKey));
}
}
void _handleScrollEnd(
BuildContext context,
) {
if (!disableScroll) {
widget.controller.notifyScroll(_getSrollDirection(
_scrolledUnderState(context, widget.targetKey, null)));
if (widget.subController != null) {
widget.subController!.notifyScroll(_getSrollDirection(
_scrolledUnderState(
context, widget.subTargetKey!, widget.subAnchorKey)));
}
}
}
_ScrollDirection _getSrollDirection(_Visibility visibility) {
if (visibility == _Visibility.halfScrolledUnder) {
return _ScrollDirection.up;
} else if (visibility == _Visibility.topScrolledUnder) {
return _ScrollDirection.down;
} else {
return _ScrollDirection.idle;
}
}
_Visibility _scrolledUnderState(
BuildContext context,
GlobalKey targetKey,
GlobalKey? anchorKey,
) {
final currentContext = targetKey.currentContext;
if (currentContext == null) return _Visibility.visible;
final RenderBox renderBox = currentContext.findRenderObject() as RenderBox;
final RenderBox? anchorRenderBox = anchorKey != null
? anchorKey.currentContext?.findRenderObject() as RenderBox?
: null;
final anchorHeight = anchorRenderBox != null
? anchorRenderBox.size.height
: Scaffold.of(context).appBarMaxHeight!;
final targetHeight = renderBox.size.height;
final positionOffset = anchorRenderBox != null
? Offset(0, -anchorRenderBox.localToGlobal(Offset.zero).dy)
: Offset.zero;
final position = renderBox.localToGlobal(positionOffset);
if (anchorHeight - position.dy > targetHeight - 10) {
return _Visibility.scrolledUnder;
} else if (anchorHeight - position.dy > targetHeight / 2) {
return _Visibility.halfScrolledUnder;
} else if (anchorHeight - position.dy > 0) {
return _Visibility.topScrolledUnder;
} else {
return _Visibility.visible;
}
}
}

View File

@ -131,7 +131,16 @@ class DevicePickerContent extends ConsumerWidget {
), ),
]; ];
return Column(children: children); return Padding(
padding: EdgeInsets.only(
bottom: !extended && children.length > 1
? 13
: !extended
? 6.5
: 0,
),
child: Column(children: children),
);
} }
} }
@ -311,7 +320,7 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> {
isDesktop && menuItems.isNotEmpty ? showMenuFn : null, isDesktop && menuItems.isNotEmpty ? showMenuFn : null,
onLongPressStart: isAndroid ? showMenuFn : null, onLongPressStart: isAndroid ? showMenuFn : null,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6.5), padding: const EdgeInsets.only(bottom: 6.5),
child: widget.selected child: widget.selected
? IconButton.filled( ? IconButton.filled(
tooltip: isDesktop ? tooltip : null, tooltip: isDesktop ? tooltip : null,

View File

@ -18,6 +18,8 @@ import 'package:flutter/material.dart';
// global keys // global keys
final scaffoldGlobalKey = GlobalKey<ScaffoldState>(); final scaffoldGlobalKey = GlobalKey<ScaffoldState>();
// This is global so we can access it from the global Ctrl+F shortcut.
final searchField = GlobalKey();
const _prefix = 'app.keys'; const _prefix = 'app.keys';
const deviceInfoListTile = Key('$_prefix.device_info_list_tile'); const deviceInfoListTile = Key('$_prefix.device_info_list_tile');

View File

@ -128,7 +128,8 @@ class NavigationContent extends ConsumerWidget {
final currentSection = ref.watch(currentSectionProvider); final currentSection = ref.watch(currentSectionProvider);
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding:
const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 8.0, top: 12),
child: Column( child: Column(
children: [ children: [
AnimatedSize( AnimatedSize(

View File

@ -31,9 +31,8 @@ import 'state.dart';
const _usbPollDelay = Duration(milliseconds: 500); const _usbPollDelay = Duration(milliseconds: 500);
const _nfcPollDelay = Duration(milliseconds: 2500); const _nfcPollReadersDelay = Duration(milliseconds: 2500);
const _nfcAttachPollDelay = Duration(seconds: 1); const _nfcPollCardDelay = Duration(seconds: 1);
const _nfcDetachPollDelay = Duration(seconds: 5);
final _log = Logger('desktop.devices'); final _log = Logger('desktop.devices');
@ -197,7 +196,7 @@ class NfcDeviceNotifier extends StateNotifier<List<NfcReaderNode>> {
} }
if (mounted) { if (mounted) {
_pollTimer = Timer(_nfcPollDelay, _pollReaders); _pollTimer = Timer(_nfcPollReadersDelay, _pollReaders);
} }
} }
} }
@ -260,7 +259,7 @@ class CurrentDeviceDataNotifier extends StateNotifier<AsyncValue<YubiKeyData>> {
void _notifyWindowState(WindowState windowState) { void _notifyWindowState(WindowState windowState) {
if (windowState.active) { if (windowState.active) {
_pollReader(); _pollCard();
} else { } else {
_pollTimer?.cancel(); _pollTimer?.cancel();
// TODO: Should we clear the key here? // TODO: Should we clear the key here?
@ -276,16 +275,23 @@ class CurrentDeviceDataNotifier extends StateNotifier<AsyncValue<YubiKeyData>> {
super.dispose(); super.dispose();
} }
void _pollReader() async { void _pollCard() async {
_pollTimer?.cancel(); _pollTimer?.cancel();
final node = _deviceNode!; final node = _deviceNode!;
try { try {
_log.debug('Polling for USB device changes...'); _log.debug('Polling for NFC device changes...');
var result = await _rpc?.command('get', node.path.segments); var result = await _rpc?.command('get', node.path.segments);
if (mounted && result != null) { if (mounted && result != null) {
if (result['data']['present']) { if (result['data']['present']) {
state = AsyncValue.data(YubiKeyData(node, result['data']['name'], final oldState = state.valueOrNull;
DeviceInfo.fromJson(result['data']['info']))); final newState = YubiKeyData(node, result['data']['name'],
DeviceInfo.fromJson(result['data']['info']));
if (oldState != null && oldState != newState) {
// Ensure state is cleared
state = const AsyncValue.loading();
} else {
state = AsyncValue.data(newState);
}
} else { } else {
final status = result['data']['status']; final status = result['data']['status'];
// Only update if status is not changed // Only update if status is not changed
@ -298,9 +304,7 @@ class CurrentDeviceDataNotifier extends StateNotifier<AsyncValue<YubiKeyData>> {
_log.error('Error polling NFC', jsonEncode(e)); _log.error('Error polling NFC', jsonEncode(e));
} }
if (mounted) { if (mounted) {
_pollTimer = Timer( _pollTimer = Timer(_nfcPollCardDelay, _pollCard);
state is AsyncData ? _nfcDetachPollDelay : _nfcAttachPollDelay,
_pollReader);
} }
} }
} }

View File

@ -295,7 +295,7 @@ class DesktopCredentialListNotifier extends OathCredentialListNotifier {
code = OathCode.fromJson(result); code = OathCode.fromJson(result);
} }
_log.debug('Calculate', jsonEncode(code)); _log.debug('Calculate', jsonEncode(code));
if (update && mounted) { if (update && mounted && state != null) {
final creds = state!.toList(); final creds = state!.toList();
final i = creds.indexWhere((e) => e.credential.id == credential.id); final i = creds.indexWhere((e) => e.credential.id == credential.id);
state = creds..[i] = creds[i].copyWith(code: code); state = creds..[i] = creds[i].copyWith(code: code);

View File

@ -72,8 +72,25 @@ class _DesktopPivStateNotifier extends PivStateNotifier {
ref.invalidate(_sessionProvider(devicePath)); ref.invalidate(_sessionProvider(devicePath));
}) })
..setErrorHandler('auth-required', (e) async { ..setErrorHandler('auth-required', (e) async {
try {
if (state.valueOrNull?.protectedKey == true) {
final String? pin;
if (state.valueOrNull?.metadata?.pinMetadata.defaultValue == true) {
pin = defaultPin;
} else {
pin = ref.read(_pinProvider(devicePath));
}
if (pin != null) {
if (await verifyPin(pin) is PinSuccess) {
return;
} else {
ref.read(_pinProvider(devicePath).notifier).state = null;
}
}
} else {
final String? mgmtKey; final String? mgmtKey;
if (state.valueOrNull?.metadata?.managementKeyMetadata.defaultValue == if (state.valueOrNull?.metadata?.managementKeyMetadata
.defaultValue ==
true) { true) {
mgmtKey = defaultManagementKey; mgmtKey = defaultManagementKey;
} else { } else {
@ -81,15 +98,16 @@ class _DesktopPivStateNotifier extends PivStateNotifier {
} }
if (mgmtKey != null) { if (mgmtKey != null) {
if (await authenticate(mgmtKey)) { if (await authenticate(mgmtKey)) {
ref.invalidateSelf(); return;
} else { } else {
ref.read(_managementKeyProvider(devicePath).notifier).state = null; ref.read(_managementKeyProvider(devicePath).notifier).state =
ref.invalidateSelf(); null;
throw e; }
}
} }
} else {
ref.invalidateSelf();
throw e; throw e;
} finally {
ref.invalidateSelf();
} }
}); });
ref.onDispose(() { ref.onDispose(() {
@ -299,8 +317,24 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier {
} }
@override @override
Future<void> delete(SlotId slot) async { Future<void> delete(SlotId slot, bool deleteCert, bool deleteKey) async {
await _session.command('delete', target: ['slots', slot.hexId]); await _session.command('delete',
target: ['slots', slot.hexId],
params: {'delete_cert': deleteCert, 'delete_key': deleteKey});
ref.invalidateSelf();
}
@override
Future<void> moveKey(SlotId source, SlotId destination, bool overwriteKey,
bool includeCertificate) async {
await _session.command('move_key', target: [
'slots',
source.hexId
], params: {
'destination': destination.hexId,
'overwrite_key': overwriteKey,
'include_certificate': includeCertificate
});
ref.invalidateSelf(); ref.invalidateSelf();
} }

View File

@ -40,6 +40,8 @@ class FidoState with _$FidoState {
info['options']['credMgmt'] == true || info['options']['credMgmt'] == true ||
info['options']['credentialMgmtPreview'] == true; info['options']['credentialMgmtPreview'] == true;
int? get remainingCreds => info['remaining_disc_creds'];
bool? get bioEnroll => info['options']['bioEnroll']; bool? get bioEnroll => info['options']['bioEnroll'];
bool get alwaysUv => info['options']['alwaysUv'] == true; bool get alwaysUv => info['options']['alwaysUv'] == true;

View File

@ -20,6 +20,18 @@ import '../app/models.dart';
import '../core/state.dart'; import '../core/state.dart';
import 'models.dart'; import 'models.dart';
final passkeysSearchProvider =
StateNotifierProvider<PasskeysSearchNotifier, String>(
(ref) => PasskeysSearchNotifier());
class PasskeysSearchNotifier extends StateNotifier<String> {
PasskeysSearchNotifier() : super('');
void setFilter(String value) {
state = value;
}
}
final fidoStateProvider = AsyncNotifierProvider.autoDispose final fidoStateProvider = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState, DevicePath>( .family<FidoStateNotifier, FidoState, DevicePath>(
() => throw UnimplementedError(), () => throw UnimplementedError(),
@ -52,3 +64,23 @@ abstract class FidoCredentialsNotifier
extends AutoDisposeFamilyAsyncNotifier<List<FidoCredential>, DevicePath> { extends AutoDisposeFamilyAsyncNotifier<List<FidoCredential>, DevicePath> {
Future<void> deleteCredential(FidoCredential credential); Future<void> deleteCredential(FidoCredential credential);
} }
final filteredFidoCredentialsProvider = StateNotifierProvider.autoDispose
.family<FilteredFidoCredentialsNotifier, List<FidoCredential>,
List<FidoCredential>>(
(ref, full) {
return FilteredFidoCredentialsNotifier(
full, ref.watch(passkeysSearchProvider));
},
);
class FilteredFidoCredentialsNotifier
extends StateNotifier<List<FidoCredential>> {
final String query;
FilteredFidoCredentialsNotifier(List<FidoCredential> full, this.query)
: super(full
.where((credential) =>
credential.rpId.toLowerCase().contains(query.toLowerCase()) ||
credential.userName.toLowerCase().contains(query.toLowerCase()))
.toList());
}

View File

@ -75,7 +75,7 @@ class CredentialDialog extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Icon(Symbols.person, size: 72), const Icon(Symbols.passkey, size: 72),
], ],
), ),
), ),

View File

@ -17,6 +17,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@ -29,11 +30,14 @@ import '../../app/views/action_list.dart';
import '../../app/views/app_failure_page.dart'; import '../../app/views/app_failure_page.dart';
import '../../app/views/app_list_item.dart'; import '../../app/views/app_list_item.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/keys.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../app/views/message_page_not_initialized.dart'; import '../../app/views/message_page_not_initialized.dart';
import '../../core/state.dart'; import '../../core/state.dart';
import '../../exception/no_data_exception.dart'; import '../../exception/no_data_exception.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/list_title.dart'; import '../../widgets/list_title.dart';
import '../features.dart' as features; import '../features.dart' as features;
import '../models.dart'; import '../models.dart';
@ -137,7 +141,7 @@ class _FidoLockedPage extends ConsumerWidget {
: alwaysUv : alwaysUv
? l10n.l_pin_change_required_desc ? l10n.l_pin_change_required_desc
: l10n.l_register_sk_on_websites, : l10n.l_register_sk_on_websites,
footnote: isBio ? null : l10n.l_non_passkeys_note, footnote: isBio ? null : l10n.p_non_passkeys_note,
keyActionsBuilder: hasActions ? _buildActions : null, keyActionsBuilder: hasActions ? _buildActions : null,
keyActionsBadge: passkeysShowActionsNotifier(state), keyActionsBadge: passkeysShowActionsNotifier(state),
); );
@ -149,7 +153,7 @@ class _FidoLockedPage extends ConsumerWidget {
capabilities: const [Capability.fido2], capabilities: const [Capability.fido2],
header: l10n.l_ready_to_use, header: l10n.l_ready_to_use,
message: l10n.l_register_sk_on_websites, message: l10n.l_register_sk_on_websites,
footnote: l10n.l_non_passkeys_note, footnote: l10n.p_non_passkeys_note,
keyActionsBuilder: hasActions ? _buildActions : null, keyActionsBuilder: hasActions ? _buildActions : null,
keyActionsBadge: passkeysShowActionsNotifier(state), keyActionsBadge: passkeysShowActionsNotifier(state),
); );
@ -206,8 +210,30 @@ class _FidoUnlockedPage extends ConsumerStatefulWidget {
} }
class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
late FocusNode searchFocus;
late TextEditingController searchController;
FidoCredential? _selected; FidoCredential? _selected;
@override
void initState() {
super.initState();
searchFocus = FocusNode();
searchController =
TextEditingController(text: ref.read(passkeysSearchProvider));
searchFocus.addListener(_onFocusChange);
}
@override
void dispose() {
searchFocus.dispose();
searchController.dispose();
super.dispose();
}
void _onFocusChange() {
setState(() {});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@ -222,7 +248,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
capabilities: const [Capability.fido2], capabilities: const [Capability.fido2],
header: l10n.l_no_discoverable_accounts, header: l10n.l_no_discoverable_accounts,
message: l10n.l_register_sk_on_websites, message: l10n.l_register_sk_on_websites,
footnote: l10n.l_non_passkeys_note, footnote: l10n.p_non_passkeys_note,
keyActionsBuilder: hasActions keyActionsBuilder: hasActions
? (context) => ? (context) =>
passkeysBuildActions(context, widget.node, widget.state) passkeysBuildActions(context, widget.node, widget.state)
@ -236,6 +262,12 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
return _buildLoadingPage(context); return _buildLoadingPage(context);
} }
final credentials = data.value; final credentials = data.value;
final filteredCredentials =
ref.watch(filteredFidoCredentialsProvider(credentials.toList()));
final remainingCreds = widget.state.remainingCreds;
final maxCreds =
remainingCreds != null ? remainingCreds + credentials.length : 25;
if (credentials.isEmpty) { if (credentials.isEmpty) {
return MessagePage( return MessagePage(
@ -265,14 +297,22 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
passkeysBuildActions(context, widget.node, widget.state) passkeysBuildActions(context, widget.node, widget.state)
: null, : null,
keyActionsBadge: passkeysShowActionsNotifier(widget.state), keyActionsBadge: passkeysShowActionsNotifier(widget.state),
footnote: l10n.l_non_passkeys_note, footnote: l10n.p_non_passkeys_note,
); );
} }
final credential = _selected; final credential = _selected;
final searchText = searchController.text;
return FidoActions( return FidoActions(
devicePath: widget.node.path, devicePath: widget.node.path,
actions: (context) => { actions: (context) => {
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
searchController.selection = TextSelection(
baseOffset: 0, extentOffset: searchController.text.length);
searchFocus.unfocus();
Timer.run(() => searchFocus.requestFocus());
return null;
}),
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) { EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
if (_selected != null) { if (_selected != null) {
setState(() { setState(() {
@ -307,8 +347,84 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
}, },
builder: (context) => AppPage( builder: (context) => AppPage(
title: l10n.s_passkeys, title: l10n.s_passkeys,
alternativeTitle:
searchText != '' ? l10n.l_results_for(searchText) : null,
capabilities: const [Capability.fido2], capabilities: const [Capability.fido2],
footnote: l10n.l_non_passkeys_note, footnote:
'${l10n.p_passkeys_used(credentials.length, maxCreds)} ${l10n.p_non_passkeys_note}',
headerSliver: Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
node.focusInDirection(TraversalDirection.down);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.escape) {
searchController.clear();
ref.read(passkeysSearchProvider.notifier).setFilter('');
node.unfocus();
setState(() {});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: AppTextFormField(
key: searchField,
controller: searchController,
focusNode: searchFocus,
// Use the default style, but with a smaller font size:
style: textTheme.titleMedium
?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
decoration: AppInputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(48),
borderSide: BorderSide(
width: 0,
style: searchFocus.hasFocus
? BorderStyle.solid
: BorderStyle.none,
),
),
contentPadding: const EdgeInsets.all(16),
fillColor: Theme.of(context).hoverColor,
filled: true,
hintText: l10n.s_search_passkeys,
isDense: true,
prefixIcon: const Padding(
padding: EdgeInsetsDirectional.only(start: 8.0),
child: Icon(Icons.search_outlined),
),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
iconSize: 16,
onPressed: () {
searchController.clear();
ref
.read(passkeysSearchProvider.notifier)
.setFilter('');
setState(() {});
},
)
: null,
),
onChanged: (value) {
ref.read(passkeysSearchProvider.notifier).setFilter(value);
setState(() {});
},
textInputAction: TextInputAction.next,
onFieldSubmitted: (value) {
Focus.of(context).focusInDirection(TraversalDirection.down);
},
),
);
}),
),
detailViewBuilder: credential != null detailViewBuilder: credential != null
? (context) => Column( ? (context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -347,7 +463,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Icon(Symbols.person, size: 72), const Icon(Symbols.passkey, size: 72),
], ],
), ),
), ),
@ -390,15 +506,19 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
}, },
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: credentials children: [
.map( if (filteredCredentials.isEmpty)
Center(
child: Text(l10n.s_no_passkeys),
),
...filteredCredentials.map(
(cred) => _CredentialListItem( (cred) => _CredentialListItem(
cred, cred,
expanded: expanded, expanded: expanded,
selected: _selected == cred, selected: _selected == cred,
), ),
) ),
.toList(), ],
), ),
); );
}, },
@ -423,13 +543,14 @@ class _CredentialListItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AppListItem( return AppListItem(
credential, credential,
selected: selected, selected: selected,
leading: CircleAvatar( leading: CircleAvatar(
foregroundColor: Theme.of(context).colorScheme.onPrimary, foregroundColor: colorScheme.onSecondary,
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: colorScheme.secondary,
child: const Icon(Symbols.person), child: const Icon(Symbols.passkey),
), ),
title: credential.userName, title: credential.userName,
subtitle: credential.rpId, subtitle: credential.rpId,

View File

@ -14,21 +14,39 @@
* limitations under the License. * limitations under the License.
*/ */
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../management/models.dart'; import '../../management/models.dart';
class WebAuthnScreen extends StatelessWidget { class WebAuthnScreen extends StatefulWidget {
const WebAuthnScreen({super.key}); const WebAuthnScreen({super.key});
@override
State<WebAuthnScreen> createState() => _WebAuthnScreenState();
}
class _WebAuthnScreenState extends State<WebAuthnScreen> {
bool hide = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
// We need this to avoid unwanted app switch animation
if (hide) {
Timer.run(() {
setState(() {
hide = false;
});
});
}
return MessagePage( return MessagePage(
title: l10n.s_security_key, title: hide ? null : l10n.s_security_key,
capabilities: const [Capability.u2f], capabilities: const [Capability.u2f],
delayedContent: hide,
header: l10n.l_ready_to_use, header: l10n.l_ready_to_use,
message: l10n.l_register_sk_on_websites, message: l10n.l_register_sk_on_websites,
); );

View File

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -32,32 +34,49 @@ import '../../widgets/product_image.dart';
import 'key_actions.dart'; import 'key_actions.dart';
import 'manage_label_dialog.dart'; import 'manage_label_dialog.dart';
class HomeScreen extends ConsumerWidget { class HomeScreen extends ConsumerStatefulWidget {
final YubiKeyData deviceData; final YubiKeyData deviceData;
const HomeScreen(this.deviceData, {super.key}); const HomeScreen(this.deviceData, {super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<ConsumerStatefulWidget> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
bool hide = true;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final serial = deviceData.info.serial; final serial = widget.deviceData.info.serial;
final keyCustomization = ref.watch(keyCustomizationManagerProvider)[serial]; final keyCustomization = ref.watch(keyCustomizationManagerProvider)[serial];
final enabledCapabilities = final enabledCapabilities = widget.deviceData.info.config
deviceData.info.config.enabledCapabilities[deviceData.node.transport] ?? .enabledCapabilities[widget.deviceData.node.transport] ??
0; 0;
final primaryColor = ref.watch(defaultColorProvider); final primaryColor = ref.watch(defaultColorProvider);
// We need this to avoid unwanted app switch animation
if (hide) {
Timer.run(() {
setState(() {
hide = false;
});
});
}
return AppPage( return AppPage(
title: l10n.s_home, title: hide ? null : l10n.s_home,
delayedContent: hide,
keyActionsBuilder: (context) => keyActionsBuilder: (context) =>
homeBuildActions(context, deviceData, ref), homeBuildActions(context, widget.deviceData, ref),
builder: (context, expanded) { builder: (context, expanded) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_DeviceContent(deviceData, keyCustomization), _DeviceContent(widget.deviceData, keyCustomization),
const SizedBox(height: 16.0), const SizedBox(height: 16.0),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -79,7 +98,7 @@ class HomeScreen extends ConsumerWidget {
if (serial != null) ...[ if (serial != null) ...[
const SizedBox(height: 32.0), const SizedBox(height: 32.0),
_DeviceColor( _DeviceColor(
deviceData: deviceData, deviceData: widget.deviceData,
initialCustomization: keyCustomization ?? initialCustomization: keyCustomization ??
KeyCustomization(serial: serial)) KeyCustomization(serial: serial))
] ]
@ -93,9 +112,9 @@ class HomeScreen extends ConsumerWidget {
child: _HeroAvatar( child: _HeroAvatar(
color: keyCustomization?.color ?? primaryColor, color: keyCustomization?.color ?? primaryColor,
child: ProductImage( child: ProductImage(
name: deviceData.name, name: widget.deviceData.name,
formFactor: deviceData.info.formFactor, formFactor: widget.deviceData.info.formFactor,
isNfc: deviceData.info.supportedCapabilities isNfc: widget.deviceData.info.supportedCapabilities
.containsKey(Transport.nfc), .containsKey(Transport.nfc),
), ),
), ),

View File

@ -28,6 +28,7 @@
"s_cancel": "Abbrechen", "s_cancel": "Abbrechen",
"s_close": "Schließen", "s_close": "Schließen",
"s_delete": "Löschen", "s_delete": "Löschen",
"s_move": null,
"s_quit": "Beenden", "s_quit": "Beenden",
"s_status": null, "s_status": null,
"s_unlock": "Entsperren", "s_unlock": "Entsperren",
@ -352,6 +353,12 @@
}, },
"s_accounts": "Konten", "s_accounts": "Konten",
"s_no_accounts": "Keine Konten", "s_no_accounts": "Keine Konten",
"l_results_for": null,
"@l_results_for": {
"placeholders": {
"query": {}
}
},
"l_authenticator_get_started": null, "l_authenticator_get_started": null,
"l_no_accounts_desc": null, "l_no_accounts_desc": null,
"s_add_account": "Konto hinzufügen", "s_add_account": "Konto hinzufügen",
@ -419,15 +426,23 @@
} }
}, },
"s_passkeys": null, "s_passkeys": null,
"s_no_passkeys": null,
"l_ready_to_use": "Bereit zur Verwendung", "l_ready_to_use": "Bereit zur Verwendung",
"l_register_sk_on_websites": "Als Sicherheitsschlüssel auf Webseiten registrieren", "l_register_sk_on_websites": "Als Sicherheitsschlüssel auf Webseiten registrieren",
"l_no_discoverable_accounts": "Keine erkennbaren Konten", "l_no_discoverable_accounts": "Keine erkennbaren Konten",
"l_non_passkeys_note": null, "p_non_passkeys_note": null,
"s_delete_passkey": null, "s_delete_passkey": null,
"l_delete_passkey_desc": null, "l_delete_passkey_desc": null,
"s_passkey_deleted": null, "s_passkey_deleted": null,
"p_warning_delete_passkey": null, "p_warning_delete_passkey": null,
"s_search_passkeys": null,
"p_passkeys_used": null,
"@p_passkeys_used": {
"placeholders": {
"used": {},
"max": {}
}
},
"@_fingerprints": {}, "@_fingerprints": {},
"l_fingerprint": "Fingerabdruck: {label}", "l_fingerprint": "Fingerabdruck: {label}",
"@l_fingerprint": { "@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": null, "l_unsupported_key_type": null,
"l_delete_certificate": null, "l_delete_certificate": null,
"l_delete_certificate_desc": null, "l_delete_certificate_desc": null,
"l_delete_key": null,
"l_delete_key_desc": null,
"l_delete_certificate_or_key": null,
"l_delete_certificate_or_key_desc": null,
"l_move_key": null,
"l_move_key_desc": null,
"s_issuer": null, "s_issuer": null,
"s_serial": null, "s_serial": null,
"s_certificate_fingerprint": null, "s_certificate_fingerprint": null,
@ -522,14 +543,53 @@
}, },
"l_generating_private_key": null, "l_generating_private_key": null,
"s_private_key_generated": null, "s_private_key_generated": null,
"p_select_what_to_delete": null,
"p_warning_delete_certificate": null, "p_warning_delete_certificate": null,
"p_warning_delete_key": null,
"p_warning_delete_certificate_and_key": null,
"q_delete_certificate_confirm": null, "q_delete_certificate_confirm": null,
"@q_delete_certificate_confirm": { "@q_delete_certificate_confirm": {
"placeholders": { "placeholders": {
"slot": {} "slot": {}
} }
}, },
"q_delete_key_confirm": null,
"@q_delete_key_confirm": {
"placeholders": {
"slot": {}
}
},
"q_delete_certificate_and_key_confirm": null,
"@q_delete_certificate_and_key_confirm": {
"placeholders": {
"slot": {}
}
},
"l_certificate_deleted": null, "l_certificate_deleted": null,
"l_key_deleted": null,
"l_certificate_and_key_deleted": null,
"l_include_certificate": null,
"l_select_destination_slot": null,
"q_move_key_confirm": null,
"@q_move_key_confirm": {
"placeholders": {
"from_slot": {}
}
},
"q_move_key_to_slot_confirm": null,
"@q_move_key_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"q_move_key_and_certificate_to_slot_confirm": null,
"@q_move_key_and_certificate_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"p_password_protected_file": null, "p_password_protected_file": null,
"p_import_items_desc": null, "p_import_items_desc": null,
"@p_import_items_desc": { "@p_import_items_desc": {
@ -537,6 +597,8 @@
"slot": {} "slot": {}
} }
}, },
"l_key_moved": null,
"l_key_and_certificate_moved": null,
"p_subject_desc": null, "p_subject_desc": null,
"l_rfc4514_invalid": null, "l_rfc4514_invalid": null,
"rfc4514_examples": null, "rfc4514_examples": null,
@ -560,6 +622,12 @@
"hexid": {} "hexid": {}
} }
}, },
"s_retired_slot_display_name": null,
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": null, "s_slot_9a": null,
"s_slot_9c": null, "s_slot_9c": null,
"s_slot_9d": null, "s_slot_9d": null,

View File

@ -28,6 +28,7 @@
"s_cancel": "Cancel", "s_cancel": "Cancel",
"s_close": "Close", "s_close": "Close",
"s_delete": "Delete", "s_delete": "Delete",
"s_move": "Move",
"s_quit": "Quit", "s_quit": "Quit",
"s_status": "Status", "s_status": "Status",
"s_unlock": "Unlock", "s_unlock": "Unlock",
@ -352,6 +353,12 @@
}, },
"s_accounts": "Accounts", "s_accounts": "Accounts",
"s_no_accounts": "No accounts", "s_no_accounts": "No accounts",
"l_results_for": "Results for \"{query}\"",
"@l_results_for": {
"placeholders": {
"query": {}
}
},
"l_authenticator_get_started": "Get started with OTP accounts", "l_authenticator_get_started": "Get started with OTP accounts",
"l_no_accounts_desc": "Add accounts to your YubiKey from any service provider supporting OATH TOTP/HOTP", "l_no_accounts_desc": "Add accounts to your YubiKey from any service provider supporting OATH TOTP/HOTP",
"s_add_account": "Add account", "s_add_account": "Add account",
@ -419,15 +426,23 @@
} }
}, },
"s_passkeys": "Passkeys", "s_passkeys": "Passkeys",
"s_no_passkeys": "No passkeys",
"l_ready_to_use": "Ready to use", "l_ready_to_use": "Ready to use",
"l_register_sk_on_websites": "Register as a Security Key on websites", "l_register_sk_on_websites": "Register as a Security Key on websites",
"l_no_discoverable_accounts": "No passkeys stored", "l_no_discoverable_accounts": "No passkeys stored",
"l_non_passkeys_note": "Non-passkey credentials may exist, but can not be listed", "p_non_passkeys_note": "Non-passkey credentials may exist, but can not be listed.",
"s_delete_passkey": "Delete passkey", "s_delete_passkey": "Delete passkey",
"l_delete_passkey_desc": "Remove the passkey from the YubiKey", "l_delete_passkey_desc": "Remove the passkey from the YubiKey",
"s_passkey_deleted": "Passkey deleted", "s_passkey_deleted": "Passkey deleted",
"p_warning_delete_passkey": "This will delete the passkey from your YubiKey.", "p_warning_delete_passkey": "This will delete the passkey from your YubiKey.",
"s_search_passkeys": "Search passkeys",
"p_passkeys_used": "{used} of {max} passkeys used.",
"@p_passkeys_used": {
"placeholders": {
"used": {},
"max": {}
}
},
"@_fingerprints": {}, "@_fingerprints": {},
"l_fingerprint": "Fingerprint: {label}", "l_fingerprint": "Fingerprint: {label}",
"@l_fingerprint": { "@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": "Unsupported key type", "l_unsupported_key_type": "Unsupported key type",
"l_delete_certificate": "Delete certificate", "l_delete_certificate": "Delete certificate",
"l_delete_certificate_desc": "Remove the certificate from your YubiKey", "l_delete_certificate_desc": "Remove the certificate from your YubiKey",
"l_delete_key": "Delete key",
"l_delete_key_desc": "Remove the key from your YubiKey",
"l_delete_certificate_or_key": "Delete certificate/key",
"l_delete_certificate_or_key_desc": "Remove the certificate or key from your YubiKey",
"l_move_key": "Move key",
"l_move_key_desc": "Move a key from one PIV slot into another",
"s_issuer": "Issuer", "s_issuer": "Issuer",
"s_serial": "Serial", "s_serial": "Serial",
"s_certificate_fingerprint": "Fingerprint", "s_certificate_fingerprint": "Fingerprint",
@ -522,14 +543,53 @@
}, },
"l_generating_private_key": "Generating private key\u2026", "l_generating_private_key": "Generating private key\u2026",
"s_private_key_generated": "Private key generated", "s_private_key_generated": "Private key generated",
"p_select_what_to_delete": "Select what to delete from the slot.",
"p_warning_delete_certificate": "Warning! This action will delete the certificate from your YubiKey.", "p_warning_delete_certificate": "Warning! This action will delete the certificate from your YubiKey.",
"p_warning_delete_key": "Warning! This action will delete the private key from your YubiKey.",
"p_warning_delete_certificate_and_key": "Warning! This action will delete the certificate and private key from your YubiKey.",
"q_delete_certificate_confirm": "Delete the certificate in PIV slot {slot}?", "q_delete_certificate_confirm": "Delete the certificate in PIV slot {slot}?",
"@q_delete_certificate_confirm": { "@q_delete_certificate_confirm": {
"placeholders": { "placeholders": {
"slot": {} "slot": {}
} }
}, },
"q_delete_key_confirm": "Delete the private key in PIV slot {slot}?",
"@q_delete_key_confirm": {
"placeholders": {
"slot": {}
}
},
"q_delete_certificate_and_key_confirm": "Delete the certificate and private key in PIV slot {slot}?",
"@q_delete_certificate_and_key_confirm": {
"placeholders": {
"slot": {}
}
},
"l_certificate_deleted": "Certificate deleted", "l_certificate_deleted": "Certificate deleted",
"l_key_deleted": "Key deleted",
"l_certificate_and_key_deleted": "Certificate and key deleted",
"l_include_certificate": "Include certificate",
"l_select_destination_slot": "Select destination slot",
"q_move_key_confirm": "Move the private key in PIV slot {from_slot}?",
"@q_move_key_confirm": {
"placeholders": {
"from_slot": {}
}
},
"q_move_key_to_slot_confirm": "Move the private key in PIV slot {from_slot} to slot {to_slot}?",
"@q_move_key_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"q_move_key_and_certificate_to_slot_confirm": "Move the private key and certificate in PIV slot {from_slot} to slot {to_slot}?",
"@q_move_key_and_certificate_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"p_password_protected_file": "The selected file is password protected. Enter the password to proceed.", "p_password_protected_file": "The selected file is password protected. Enter the password to proceed.",
"p_import_items_desc": "The following item(s) will be imported into PIV slot {slot}.", "p_import_items_desc": "The following item(s) will be imported into PIV slot {slot}.",
"@p_import_items_desc": { "@p_import_items_desc": {
@ -537,6 +597,8 @@
"slot": {} "slot": {}
} }
}, },
"l_key_moved": "Key moved",
"l_key_and_certificate_moved": "Key and certificate moved",
"p_subject_desc": "A distinguished name (DN) formatted in accordance to the RFC 4514 specification.", "p_subject_desc": "A distinguished name (DN) formatted in accordance to the RFC 4514 specification.",
"l_rfc4514_invalid": "Invalid RFC 4514 format", "l_rfc4514_invalid": "Invalid RFC 4514 format",
"rfc4514_examples": "Examples:\nCN=Example Name\nCN=jsmith,DC=example,DC=net", "rfc4514_examples": "Examples:\nCN=Example Name\nCN=jsmith,DC=example,DC=net",
@ -560,6 +622,12 @@
"hexid": {} "hexid": {}
} }
}, },
"s_retired_slot_display_name": "Retired Key Management ({hexid})",
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": "Authentication", "s_slot_9a": "Authentication",
"s_slot_9c": "Digital Signature", "s_slot_9c": "Digital Signature",
"s_slot_9d": "Key Management", "s_slot_9d": "Key Management",

View File

@ -28,6 +28,7 @@
"s_cancel": "Annuler", "s_cancel": "Annuler",
"s_close": "Fermer", "s_close": "Fermer",
"s_delete": "Supprimer", "s_delete": "Supprimer",
"s_move": null,
"s_quit": "Quitter", "s_quit": "Quitter",
"s_status": null, "s_status": null,
"s_unlock": "Déverrouiller", "s_unlock": "Déverrouiller",
@ -352,6 +353,12 @@
}, },
"s_accounts": "Comptes", "s_accounts": "Comptes",
"s_no_accounts": "Aucun compte", "s_no_accounts": "Aucun compte",
"l_results_for": null,
"@l_results_for": {
"placeholders": {
"query": {}
}
},
"l_authenticator_get_started": null, "l_authenticator_get_started": null,
"l_no_accounts_desc": null, "l_no_accounts_desc": null,
"s_add_account": "Ajouter un compte", "s_add_account": "Ajouter un compte",
@ -419,15 +426,23 @@
} }
}, },
"s_passkeys": "Passkeys", "s_passkeys": "Passkeys",
"s_no_passkeys": null,
"l_ready_to_use": "Prêt à l'emploi", "l_ready_to_use": "Prêt à l'emploi",
"l_register_sk_on_websites": "Enregistrer comme clé de sécurité sur les sites internet", "l_register_sk_on_websites": "Enregistrer comme clé de sécurité sur les sites internet",
"l_no_discoverable_accounts": "Aucune Passkey détectée", "l_no_discoverable_accounts": "Aucune Passkey détectée",
"l_non_passkeys_note": null, "p_non_passkeys_note": null,
"s_delete_passkey": "Supprimer une Passkey", "s_delete_passkey": "Supprimer une Passkey",
"l_delete_passkey_desc": "Supprimer la Passkey de votre YubiKey", "l_delete_passkey_desc": "Supprimer la Passkey de votre YubiKey",
"s_passkey_deleted": "Passkey supprimée", "s_passkey_deleted": "Passkey supprimée",
"p_warning_delete_passkey": "Cette action supprimera cette Passkey de votre YubiKey.", "p_warning_delete_passkey": "Cette action supprimera cette Passkey de votre YubiKey.",
"s_search_passkeys": null,
"p_passkeys_used": null,
"@p_passkeys_used": {
"placeholders": {
"used": {},
"max": {}
}
},
"@_fingerprints": {}, "@_fingerprints": {},
"l_fingerprint": "Empreinte: {label}", "l_fingerprint": "Empreinte: {label}",
"@l_fingerprint": { "@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": null, "l_unsupported_key_type": null,
"l_delete_certificate": "Supprimer un certificat", "l_delete_certificate": "Supprimer un certificat",
"l_delete_certificate_desc": "Supprimer un certificat de votre YubiKey", "l_delete_certificate_desc": "Supprimer un certificat de votre YubiKey",
"l_delete_key": null,
"l_delete_key_desc": null,
"l_delete_certificate_or_key": null,
"l_delete_certificate_or_key_desc": null,
"l_move_key": null,
"l_move_key_desc": null,
"s_issuer": "Émetteur", "s_issuer": "Émetteur",
"s_serial": "Série", "s_serial": "Série",
"s_certificate_fingerprint": "Empreinte digitale", "s_certificate_fingerprint": "Empreinte digitale",
@ -522,14 +543,53 @@
}, },
"l_generating_private_key": "Génération d'une clé privée\u2026", "l_generating_private_key": "Génération d'une clé privée\u2026",
"s_private_key_generated": "Clé privée générée", "s_private_key_generated": "Clé privée générée",
"p_select_what_to_delete": null,
"p_warning_delete_certificate": "Attention! Cette action supprimera le certificat de votre YubiKey.", "p_warning_delete_certificate": "Attention! Cette action supprimera le certificat de votre YubiKey.",
"p_warning_delete_key": null,
"p_warning_delete_certificate_and_key": null,
"q_delete_certificate_confirm": "Supprimer le certficat du slot PIV {slot}?", "q_delete_certificate_confirm": "Supprimer le certficat du slot PIV {slot}?",
"@q_delete_certificate_confirm": { "@q_delete_certificate_confirm": {
"placeholders": { "placeholders": {
"slot": {} "slot": {}
} }
}, },
"q_delete_key_confirm": null,
"@q_delete_key_confirm": {
"placeholders": {
"slot": {}
}
},
"q_delete_certificate_and_key_confirm": null,
"@q_delete_certificate_and_key_confirm": {
"placeholders": {
"slot": {}
}
},
"l_certificate_deleted": "Certificat supprimé", "l_certificate_deleted": "Certificat supprimé",
"l_key_deleted": null,
"l_certificate_and_key_deleted": null,
"l_include_certificate": null,
"l_select_destination_slot": null,
"q_move_key_confirm": null,
"@q_move_key_confirm": {
"placeholders": {
"from_slot": {}
}
},
"q_move_key_to_slot_confirm": null,
"@q_move_key_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"q_move_key_and_certificate_to_slot_confirm": null,
"@q_move_key_and_certificate_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"p_password_protected_file": "Le fichier sélectionné est protégé par un mot de passe. Enterez le mot de passe pour continuer.", "p_password_protected_file": "Le fichier sélectionné est protégé par un mot de passe. Enterez le mot de passe pour continuer.",
"p_import_items_desc": "Les éléments suivants seront importés dans le slot PIV {slot}.", "p_import_items_desc": "Les éléments suivants seront importés dans le slot PIV {slot}.",
"@p_import_items_desc": { "@p_import_items_desc": {
@ -537,6 +597,8 @@
"slot": {} "slot": {}
} }
}, },
"l_key_moved": null,
"l_key_and_certificate_moved": null,
"p_subject_desc": "Un nom distinctif (DN) formaté conformément à la spécification RFC 4514.", "p_subject_desc": "Un nom distinctif (DN) formaté conformément à la spécification RFC 4514.",
"l_rfc4514_invalid": "Format RFC 4514 invalide", "l_rfc4514_invalid": "Format RFC 4514 invalide",
"rfc4514_examples": "Exemples:\nCN=Example Name\nCN=jsmith,DC=example,DC=net", "rfc4514_examples": "Exemples:\nCN=Example Name\nCN=jsmith,DC=example,DC=net",
@ -560,6 +622,12 @@
"hexid": {} "hexid": {}
} }
}, },
"s_retired_slot_display_name": null,
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": "Authentification", "s_slot_9a": "Authentification",
"s_slot_9c": "Signature digitale", "s_slot_9c": "Signature digitale",
"s_slot_9d": "Gestion des clés", "s_slot_9d": "Gestion des clés",

View File

@ -28,6 +28,7 @@
"s_cancel": "キャンセル", "s_cancel": "キャンセル",
"s_close": "閉じる", "s_close": "閉じる",
"s_delete": "消去", "s_delete": "消去",
"s_move": null,
"s_quit": "終了", "s_quit": "終了",
"s_status": null, "s_status": null,
"s_unlock": "ロック解除", "s_unlock": "ロック解除",
@ -352,6 +353,12 @@
}, },
"s_accounts": "アカウント", "s_accounts": "アカウント",
"s_no_accounts": "アカウントがありません", "s_no_accounts": "アカウントがありません",
"l_results_for": null,
"@l_results_for": {
"placeholders": {
"query": {}
}
},
"l_authenticator_get_started": null, "l_authenticator_get_started": null,
"l_no_accounts_desc": null, "l_no_accounts_desc": null,
"s_add_account": "アカウントの追加", "s_add_account": "アカウントの追加",
@ -419,15 +426,23 @@
} }
}, },
"s_passkeys": "パスキー", "s_passkeys": "パスキー",
"s_no_passkeys": null,
"l_ready_to_use": "すぐに使用可能", "l_ready_to_use": "すぐに使用可能",
"l_register_sk_on_websites": "Webサイトにセキュリティキーとして登録する", "l_register_sk_on_websites": "Webサイトにセキュリティキーとして登録する",
"l_no_discoverable_accounts": "パスキーは保存されていません", "l_no_discoverable_accounts": "パスキーは保存されていません",
"l_non_passkeys_note": null, "p_non_passkeys_note": null,
"s_delete_passkey": "パスキーを削除", "s_delete_passkey": "パスキーを削除",
"l_delete_passkey_desc": "YubiKeyからパスキーの削除", "l_delete_passkey_desc": "YubiKeyからパスキーの削除",
"s_passkey_deleted": "パスキーが削除されました", "s_passkey_deleted": "パスキーが削除されました",
"p_warning_delete_passkey": "これにより、YubiKeyからパスキーが削除されます", "p_warning_delete_passkey": "これにより、YubiKeyからパスキーが削除されます",
"s_search_passkeys": null,
"p_passkeys_used": null,
"@p_passkeys_used": {
"placeholders": {
"used": {},
"max": {}
}
},
"@_fingerprints": {}, "@_fingerprints": {},
"l_fingerprint": "指紋:{label}", "l_fingerprint": "指紋:{label}",
"@l_fingerprint": { "@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": null, "l_unsupported_key_type": null,
"l_delete_certificate": "証明書を削除", "l_delete_certificate": "証明書を削除",
"l_delete_certificate_desc": "YubiKeyか証明書の削除", "l_delete_certificate_desc": "YubiKeyか証明書の削除",
"l_delete_key": null,
"l_delete_key_desc": null,
"l_delete_certificate_or_key": null,
"l_delete_certificate_or_key_desc": null,
"l_move_key": null,
"l_move_key_desc": null,
"s_issuer": "発行者", "s_issuer": "発行者",
"s_serial": "シリアル番号", "s_serial": "シリアル番号",
"s_certificate_fingerprint": "指紋", "s_certificate_fingerprint": "指紋",
@ -522,14 +543,53 @@
}, },
"l_generating_private_key": "秘密鍵を生成しています\u2026", "l_generating_private_key": "秘密鍵を生成しています\u2026",
"s_private_key_generated": "秘密鍵を生成しました", "s_private_key_generated": "秘密鍵を生成しました",
"p_select_what_to_delete": null,
"p_warning_delete_certificate": "警告この操作によってYubiKeyから証明書が削除されます", "p_warning_delete_certificate": "警告この操作によってYubiKeyから証明書が削除されます",
"p_warning_delete_key": null,
"p_warning_delete_certificate_and_key": null,
"q_delete_certificate_confirm": "PIVスロット{slot}の証明書を削除しますか?", "q_delete_certificate_confirm": "PIVスロット{slot}の証明書を削除しますか?",
"@q_delete_certificate_confirm": { "@q_delete_certificate_confirm": {
"placeholders": { "placeholders": {
"slot": {} "slot": {}
} }
}, },
"q_delete_key_confirm": null,
"@q_delete_key_confirm": {
"placeholders": {
"slot": {}
}
},
"q_delete_certificate_and_key_confirm": null,
"@q_delete_certificate_and_key_confirm": {
"placeholders": {
"slot": {}
}
},
"l_certificate_deleted": "証明書が削除されました", "l_certificate_deleted": "証明書が削除されました",
"l_key_deleted": null,
"l_certificate_and_key_deleted": null,
"l_include_certificate": null,
"l_select_destination_slot": null,
"q_move_key_confirm": null,
"@q_move_key_confirm": {
"placeholders": {
"from_slot": {}
}
},
"q_move_key_to_slot_confirm": null,
"@q_move_key_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"q_move_key_and_certificate_to_slot_confirm": null,
"@q_move_key_and_certificate_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"p_password_protected_file": "選択したファイルはパスワードで保護されています。パスワードを入力して続行します", "p_password_protected_file": "選択したファイルはパスワードで保護されています。パスワードを入力して続行します",
"p_import_items_desc": "次のアイテムはPIVスロット{slot}にインポートされます", "p_import_items_desc": "次のアイテムはPIVスロット{slot}にインポートされます",
"@p_import_items_desc": { "@p_import_items_desc": {
@ -537,6 +597,8 @@
"slot": {} "slot": {}
} }
}, },
"l_key_moved": null,
"l_key_and_certificate_moved": null,
"p_subject_desc": "RFC 4514フォーマットの識別名識別名 (DN)", "p_subject_desc": "RFC 4514フォーマットの識別名識別名 (DN)",
"l_rfc4514_invalid": "無効な RFC 4514 形式です", "l_rfc4514_invalid": "無効な RFC 4514 形式です",
"rfc4514_examples": "例:\nCN=Example Name\nCN=jsmith,DC=example,DC=net", "rfc4514_examples": "例:\nCN=Example Name\nCN=jsmith,DC=example,DC=net",
@ -560,6 +622,12 @@
"hexid": {} "hexid": {}
} }
}, },
"s_retired_slot_display_name": null,
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": "認証", "s_slot_9a": "認証",
"s_slot_9c": "デジタル署名", "s_slot_9c": "デジタル署名",
"s_slot_9d": "鍵の管理", "s_slot_9d": "鍵の管理",

View File

@ -28,6 +28,7 @@
"s_cancel": "Anuluj", "s_cancel": "Anuluj",
"s_close": "Zamknij", "s_close": "Zamknij",
"s_delete": "Usuń", "s_delete": "Usuń",
"s_move": null,
"s_quit": "Wyjdź", "s_quit": "Wyjdź",
"s_status": "Status", "s_status": "Status",
"s_unlock": "Odblokuj", "s_unlock": "Odblokuj",
@ -352,6 +353,12 @@
}, },
"s_accounts": "Konta", "s_accounts": "Konta",
"s_no_accounts": "Brak kont", "s_no_accounts": "Brak kont",
"l_results_for": null,
"@l_results_for": {
"placeholders": {
"query": {}
}
},
"l_authenticator_get_started": "Rozpocznij korzystanie z kont OTP", "l_authenticator_get_started": "Rozpocznij korzystanie z kont OTP",
"l_no_accounts_desc": "Dodaj konta do swojego klucza YubiKey od dowolnego dostawcy usług obsługującego OATH TOTP/HOTP", "l_no_accounts_desc": "Dodaj konta do swojego klucza YubiKey od dowolnego dostawcy usług obsługującego OATH TOTP/HOTP",
"s_add_account": "Dodaj konto", "s_add_account": "Dodaj konto",
@ -419,15 +426,23 @@
} }
}, },
"s_passkeys": "Klucze dostępu", "s_passkeys": "Klucze dostępu",
"s_no_passkeys": null,
"l_ready_to_use": "Gotowe do użycia", "l_ready_to_use": "Gotowe do użycia",
"l_register_sk_on_websites": "Zarejestruj jako klucz bezpieczeństwa na stronach internetowych", "l_register_sk_on_websites": "Zarejestruj jako klucz bezpieczeństwa na stronach internetowych",
"l_no_discoverable_accounts": "Nie wykryto kont", "l_no_discoverable_accounts": "Nie wykryto kont",
"l_non_passkeys_note": "Mogą istnieć inne dane uwierzytelniające, ale nie mogą być wyświetlane", "p_non_passkeys_note": null,
"s_delete_passkey": "Usuń klucz dostępu", "s_delete_passkey": "Usuń klucz dostępu",
"l_delete_passkey_desc": "Usuń klucz dostępu z klucza YubiKey", "l_delete_passkey_desc": "Usuń klucz dostępu z klucza YubiKey",
"s_passkey_deleted": "Usunięto klucz dostępu", "s_passkey_deleted": "Usunięto klucz dostępu",
"p_warning_delete_passkey": "Spowoduje to usunięcie klucza dostępu z klucza YubiKey.", "p_warning_delete_passkey": "Spowoduje to usunięcie klucza dostępu z klucza YubiKey.",
"s_search_passkeys": null,
"p_passkeys_used": null,
"@p_passkeys_used": {
"placeholders": {
"used": {},
"max": {}
}
},
"@_fingerprints": {}, "@_fingerprints": {},
"l_fingerprint": "Odcisk palca: {label}", "l_fingerprint": "Odcisk palca: {label}",
"@l_fingerprint": { "@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": null, "l_unsupported_key_type": null,
"l_delete_certificate": "Usuń certyfikat", "l_delete_certificate": "Usuń certyfikat",
"l_delete_certificate_desc": "Usuń certyfikat z klucza YubiKey", "l_delete_certificate_desc": "Usuń certyfikat z klucza YubiKey",
"l_delete_key": null,
"l_delete_key_desc": null,
"l_delete_certificate_or_key": null,
"l_delete_certificate_or_key_desc": null,
"l_move_key": null,
"l_move_key_desc": null,
"s_issuer": "Wydawca", "s_issuer": "Wydawca",
"s_serial": "Nr. seryjny", "s_serial": "Nr. seryjny",
"s_certificate_fingerprint": "Odcisk palca", "s_certificate_fingerprint": "Odcisk palca",
@ -522,14 +543,53 @@
}, },
"l_generating_private_key": "Generowanie prywatnego klucza\u2026", "l_generating_private_key": "Generowanie prywatnego klucza\u2026",
"s_private_key_generated": "Wygenerowano klucz prywatny", "s_private_key_generated": "Wygenerowano klucz prywatny",
"p_select_what_to_delete": null,
"p_warning_delete_certificate": "Uwaga! Ta czynność spowoduje usunięcie certyfikatu z klucza YubiKey.", "p_warning_delete_certificate": "Uwaga! Ta czynność spowoduje usunięcie certyfikatu z klucza YubiKey.",
"p_warning_delete_key": null,
"p_warning_delete_certificate_and_key": null,
"q_delete_certificate_confirm": "Usunąć certyfikat ze slotu PIV {slot}?", "q_delete_certificate_confirm": "Usunąć certyfikat ze slotu PIV {slot}?",
"@q_delete_certificate_confirm": { "@q_delete_certificate_confirm": {
"placeholders": { "placeholders": {
"slot": {} "slot": {}
} }
}, },
"q_delete_key_confirm": null,
"@q_delete_key_confirm": {
"placeholders": {
"slot": {}
}
},
"q_delete_certificate_and_key_confirm": null,
"@q_delete_certificate_and_key_confirm": {
"placeholders": {
"slot": {}
}
},
"l_certificate_deleted": "Certyfikat został usunięty", "l_certificate_deleted": "Certyfikat został usunięty",
"l_key_deleted": null,
"l_certificate_and_key_deleted": null,
"l_include_certificate": null,
"l_select_destination_slot": null,
"q_move_key_confirm": null,
"@q_move_key_confirm": {
"placeholders": {
"from_slot": {}
}
},
"q_move_key_to_slot_confirm": null,
"@q_move_key_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"q_move_key_and_certificate_to_slot_confirm": null,
"@q_move_key_and_certificate_to_slot_confirm": {
"placeholders": {
"from_slot": {},
"to_slot": {}
}
},
"p_password_protected_file": "Wybrany plik jest chroniony hasłem. Wprowadź je, aby kontynuować.", "p_password_protected_file": "Wybrany plik jest chroniony hasłem. Wprowadź je, aby kontynuować.",
"p_import_items_desc": "Następujące elementy zostaną zaimportowane do slotu PIV {slot}.", "p_import_items_desc": "Następujące elementy zostaną zaimportowane do slotu PIV {slot}.",
"@p_import_items_desc": { "@p_import_items_desc": {
@ -537,6 +597,8 @@
"slot": {} "slot": {}
} }
}, },
"l_key_moved": null,
"l_key_and_certificate_moved": null,
"p_subject_desc": "Nazwa wyróżniająca (DN) sformatowana zgodnie ze specyfikacją RFC 4514.", "p_subject_desc": "Nazwa wyróżniająca (DN) sformatowana zgodnie ze specyfikacją RFC 4514.",
"l_rfc4514_invalid": "Nieprawidłowy format RFC 4514", "l_rfc4514_invalid": "Nieprawidłowy format RFC 4514",
"rfc4514_examples": "Przykłady:\nCN=Przykładowa Nazwa\nCN=jkowalski,DC=przyklad,DC=pl", "rfc4514_examples": "Przykłady:\nCN=Przykładowa Nazwa\nCN=jkowalski,DC=przyklad,DC=pl",
@ -560,6 +622,12 @@
"hexid": {} "hexid": {}
} }
}, },
"s_retired_slot_display_name": null,
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": "Uwierzytelnienie", "s_slot_9a": "Uwierzytelnienie",
"s_slot_9c": "Cyfrowy podpis", "s_slot_9c": "Cyfrowy podpis",
"s_slot_9d": "Menedżer kluczy", "s_slot_9d": "Menedżer kluczy",

View File

@ -20,9 +20,6 @@ const _prefix = 'oath.keys';
const _keyAction = '$_prefix.actions'; const _keyAction = '$_prefix.actions';
const _accountAction = '$_prefix.account.actions'; const _accountAction = '$_prefix.account.actions';
// This is global so we can access it from the global Ctrl+F shortcut.
final searchAccountsField = GlobalKey();
// Key actions // Key actions
const setOrManagePasswordAction = const setOrManagePasswordAction =
Key('$_keyAction.action.set_or_manage_password'); Key('$_keyAction.action.set_or_manage_password');

View File

@ -26,11 +26,12 @@ import '../app/state.dart';
import '../core/state.dart'; import '../core/state.dart';
import 'models.dart'; import 'models.dart';
final searchProvider = final accountsSearchProvider =
StateNotifierProvider<SearchNotifier, String>((ref) => SearchNotifier()); StateNotifierProvider<AccountsSearchNotifier, String>(
(ref) => AccountsSearchNotifier());
class SearchNotifier extends StateNotifier<String> { class AccountsSearchNotifier extends StateNotifier<String> {
SearchNotifier() : super(''); AccountsSearchNotifier() : super('');
void setFilter(String value) { void setFilter(String value) {
state = value; state = value;
@ -184,7 +185,7 @@ class FavoritesNotifier extends StateNotifier<List<String>> {
final filteredCredentialsProvider = StateNotifierProvider.autoDispose final filteredCredentialsProvider = StateNotifierProvider.autoDispose
.family<FilteredCredentialsNotifier, List<OathPair>, List<OathPair>>( .family<FilteredCredentialsNotifier, List<OathPair>, List<OathPair>>(
(ref, full) { (ref, full) {
return FilteredCredentialsNotifier(full, ref.watch(searchProvider)); return FilteredCredentialsNotifier(full, ref.watch(accountsSearchProvider));
}); });
class FilteredCredentialsNotifier extends StateNotifier<List<OathPair>> { class FilteredCredentialsNotifier extends StateNotifier<List<OathPair>> {

View File

@ -57,9 +57,11 @@ class AccountList extends ConsumerWidget {
), ),
), ),
if (pinnedCreds.isNotEmpty && creds.isNotEmpty) if (pinnedCreds.isNotEmpty && creds.isNotEmpty)
const Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(), child: Divider(
color: Theme.of(context).colorScheme.secondaryContainer,
),
), ),
...creds.map( ...creds.map(
(entry) => AccountView( (entry) => AccountView(

View File

@ -30,6 +30,7 @@ import '../../app/state.dart';
import '../../app/views/action_list.dart'; import '../../app/views/action_list.dart';
import '../../app/views/app_failure_page.dart'; import '../../app/views/app_failure_page.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/keys.dart';
import '../../app/views/message_page.dart'; import '../../app/views/message_page.dart';
import '../../app/views/message_page_not_initialized.dart'; import '../../app/views/message_page_not_initialized.dart';
import '../../core/state.dart'; import '../../core/state.dart';
@ -121,7 +122,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
void initState() { void initState() {
super.initState(); super.initState();
searchFocus = FocusNode(); searchFocus = FocusNode();
searchController = TextEditingController(text: ref.read(searchProvider)); searchController =
TextEditingController(text: ref.read(accountsSearchProvider));
searchFocus.addListener(_onFocusChange); searchFocus.addListener(_onFocusChange);
} }
@ -144,6 +146,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
.select((value) => value?.length)); .select((value) => value?.length));
final hasFeature = ref.watch(featureProvider); final hasFeature = ref.watch(featureProvider);
final hasActions = hasFeature(features.actions); final hasActions = hasFeature(features.actions);
final searchText = searchController.text;
Future<void> onFileDropped(File file) async { Future<void> onFileDropped(File file) async {
final qrScanner = ref.read(qrScannerProvider); final qrScanner = ref.read(qrScannerProvider);
@ -210,7 +213,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) { SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
searchController.selection = TextSelection( searchController.selection = TextSelection(
baseOffset: 0, extentOffset: searchController.text.length); baseOffset: 0, extentOffset: searchController.text.length);
searchFocus.requestFocus(); searchFocus.unfocus();
Timer.run(() => searchFocus.requestFocus());
return null; return null;
}), }),
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) { EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
@ -261,6 +265,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
}, },
builder: (context) => AppPage( builder: (context) => AppPage(
title: l10n.s_accounts, title: l10n.s_accounts,
alternativeTitle:
searchText != '' ? l10n.l_results_for(searchText) : null,
capabilities: const [Capability.oath], capabilities: const [Capability.oath],
keyActionsBuilder: hasActions keyActionsBuilder: hasActions
? (context) => oathBuildActions( ? (context) => oathBuildActions(
@ -350,6 +356,79 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
); );
} }
: null, : null,
headerSliver: Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
node.focusInDirection(TraversalDirection.down);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.escape) {
searchController.clear();
ref.read(accountsSearchProvider.notifier).setFilter('');
node.unfocus();
setState(() {});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: AppTextFormField(
key: searchField,
controller: searchController,
focusNode: searchFocus,
// Use the default style, but with a smaller font size:
style: textTheme.titleMedium
?.copyWith(fontSize: textTheme.titleSmall?.fontSize),
decoration: AppInputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(48),
borderSide: BorderSide(
width: 0,
style: searchFocus.hasFocus
? BorderStyle.solid
: BorderStyle.none,
),
),
contentPadding: const EdgeInsets.all(16),
fillColor: Theme.of(context).hoverColor,
filled: true,
hintText: l10n.s_search_accounts,
isDense: true,
prefixIcon: const Padding(
padding: EdgeInsetsDirectional.only(start: 8.0),
child: Icon(Icons.search_outlined),
),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
iconSize: 16,
onPressed: () {
searchController.clear();
ref
.read(accountsSearchProvider.notifier)
.setFilter('');
setState(() {});
},
)
: null,
),
onChanged: (value) {
ref.read(accountsSearchProvider.notifier).setFilter(value);
setState(() {});
},
textInputAction: TextInputAction.next,
onFieldSubmitted: (value) {
Focus.of(context).focusInDirection(TraversalDirection.down);
},
),
);
}),
),
builder: (context, expanded) { builder: (context, expanded) {
// De-select if window is resized to be non-expanded. // De-select if window is resized to be non-expanded.
if (!expanded && _selected != null) { if (!expanded && _selected != null) {
@ -373,80 +452,6 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
}, },
child: Column( child: Column(
children: [ children: [
Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
node.focusInDirection(TraversalDirection.down);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.escape) {
searchController.clear();
ref.read(searchProvider.notifier).setFilter('');
node.unfocus();
setState(() {});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 8.0),
child: AppTextFormField(
key: keys.searchAccountsField,
controller: searchController,
focusNode: searchFocus,
// Use the default style, but with a smaller font size:
style: textTheme.titleMedium?.copyWith(
fontSize: textTheme.titleSmall?.fontSize),
decoration: AppInputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
borderSide: BorderSide(
width: 0,
style: searchFocus.hasFocus
? BorderStyle.solid
: BorderStyle.none,
),
),
contentPadding: const EdgeInsets.all(16),
fillColor: Theme.of(context).hoverColor,
filled: true,
hintText: l10n.s_search_accounts,
isDense: true,
prefixIcon: const Padding(
padding: EdgeInsetsDirectional.only(start: 8.0),
child: Icon(Symbols.search),
),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Symbols.clear),
iconSize: 16,
onPressed: () {
searchController.clear();
ref
.read(searchProvider.notifier)
.setFilter('');
setState(() {});
},
)
: null,
),
onChanged: (value) {
ref.read(searchProvider.notifier).setFilter(value);
setState(() {});
},
textInputAction: TextInputAction.next,
onFieldSubmitted: (value) {
Focus.of(context)
.focusInDirection(TraversalDirection.down);
},
).init(),
);
}),
),
Consumer( Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
return AccountList( return AccountList(

View File

@ -29,3 +29,4 @@ final slotsGenerate = slots.feature('generate');
final slotsImport = slots.feature('import'); final slotsImport = slots.feature('import');
final slotsExport = slots.feature('export'); final slotsExport = slots.feature('export');
final slotsDelete = slots.feature('delete'); final slotsDelete = slots.feature('delete');
final slotsMove = slots.feature('move');

View File

@ -32,6 +32,7 @@ const generateAction = Key('$_slotAction.generate');
const importAction = Key('$_slotAction.import'); const importAction = Key('$_slotAction.import');
const exportAction = Key('$_slotAction.export'); const exportAction = Key('$_slotAction.export');
const deleteAction = Key('$_slotAction.delete'); const deleteAction = Key('$_slotAction.delete');
const moveAction = Key('$_slotAction.move');
const saveButton = Key('$_prefix.save'); const saveButton = Key('$_prefix.save');
const deleteButton = Key('$_prefix.delete'); const deleteButton = Key('$_prefix.delete');
@ -50,11 +51,51 @@ const meatballButton9a = Key('$_prefix.9a.meatball.button');
const meatballButton9c = Key('$_prefix.9c.meatball.button'); const meatballButton9c = Key('$_prefix.9c.meatball.button');
const meatballButton9d = Key('$_prefix.9d.meatball.button'); const meatballButton9d = Key('$_prefix.9d.meatball.button');
const meatballButton9e = Key('$_prefix.9e.meatball.button'); const meatballButton9e = Key('$_prefix.9e.meatball.button');
const meatballButton82 = Key('$_prefix.82.meatball.button');
const meatballButton83 = Key('$_prefix.83.meatball.button');
const meatballButton84 = Key('$_prefix.84.meatball.button');
const meatballButton85 = Key('$_prefix.85.meatball.button');
const meatballButton86 = Key('$_prefix.86.meatball.button');
const meatballButton87 = Key('$_prefix.87.meatball.button');
const meatballButton88 = Key('$_prefix.88.meatball.button');
const meatballButton89 = Key('$_prefix.89.meatball.button');
const meatballButton8a = Key('$_prefix.8a.meatball.button');
const meatballButton8b = Key('$_prefix.8b.meatball.button');
const meatballButton8c = Key('$_prefix.8c.meatball.button');
const meatballButton8d = Key('$_prefix.8d.meatball.button');
const meatballButton8e = Key('$_prefix.8e.meatball.button');
const meatballButton8f = Key('$_prefix.8f.meatball.button');
const meatballButton90 = Key('$_prefix.90.meatball.button');
const meatballButton91 = Key('$_prefix.91.meatball.button');
const meatballButton92 = Key('$_prefix.92.meatball.button');
const meatballButton93 = Key('$_prefix.93.meatball.button');
const meatballButton94 = Key('$_prefix.94.meatball.button');
const meatballButton95 = Key('$_prefix.95.meatball.button');
const appListItem9a = Key('$_prefix.9a.applistitem'); const appListItem9a = Key('$_prefix.9a.applistitem');
const appListItem9c = Key('$_prefix.9c.applistitem'); const appListItem9c = Key('$_prefix.9c.applistitem');
const appListItem9d = Key('$_prefix.9d.applistitem'); const appListItem9d = Key('$_prefix.9d.applistitem');
const appListItem9e = Key('$_prefix.9e.applistitem'); const appListItem9e = Key('$_prefix.9e.applistitem');
const appListItem82 = Key('$_prefix.82.applistitem');
const appListItem83 = Key('$_prefix.83.applistitem');
const appListItem84 = Key('$_prefix.84.applistitem');
const appListItem85 = Key('$_prefix.85.applistitem');
const appListItem86 = Key('$_prefix.86.applistitem');
const appListItem87 = Key('$_prefix.87.applistitem');
const appListItem88 = Key('$_prefix.88.applistitem');
const appListItem89 = Key('$_prefix.89.applistitem');
const appListItem8a = Key('$_prefix.8a.applistitem');
const appListItem8b = Key('$_prefix.8b.applistitem');
const appListItem8c = Key('$_prefix.8c.applistitem');
const appListItem8d = Key('$_prefix.8d.applistitem');
const appListItem8e = Key('$_prefix.8e.applistitem');
const appListItem8f = Key('$_prefix.8f.applistitem');
const appListItem90 = Key('$_prefix.90.applistitem');
const appListItem91 = Key('$_prefix.91.applistitem');
const appListItem92 = Key('$_prefix.92.applistitem');
const appListItem93 = Key('$_prefix.93.applistitem');
const appListItem94 = Key('$_prefix.94.applistitem');
const appListItem95 = Key('$_prefix.95.applistitem');
// SlotMetadata body keys // SlotMetadata body keys
const slotMetadataKeyType = Key('$_prefix.slotMetadata.keyType'); const slotMetadataKeyType = Key('$_prefix.slotMetadata.keyType');

View File

@ -47,10 +47,31 @@ enum SlotId {
authentication(0x9a), authentication(0x9a),
signature(0x9c), signature(0x9c),
keyManagement(0x9d), keyManagement(0x9d),
cardAuth(0x9e); cardAuth(0x9e),
retired1(0x82, true),
retired2(0x83, true),
retired3(0x84, true),
retired4(0x85, true),
retired5(0x86, true),
retired6(0x87, true),
retired7(0x88, true),
retired8(0x89, true),
retired9(0x8a, true),
retired10(0x8b, true),
retired11(0x8c, true),
retired12(0x8d, true),
retired13(0x8e, true),
retired14(0x8f, true),
retired15(0x90, true),
retired16(0x91, true),
retired17(0x92, true),
retired18(0x93, true),
retired19(0x94, true),
retired20(0x95, true);
final int id; final int id;
const SlotId(this.id); final bool isRetired;
const SlotId(this.id, [this.isRetired = false]);
String get hexId => id.toRadixString(16).padLeft(2, '0'); String get hexId => id.toRadixString(16).padLeft(2, '0');
@ -61,6 +82,7 @@ enum SlotId {
SlotId.signature => nameFor(l10n.s_slot_9c), SlotId.signature => nameFor(l10n.s_slot_9c),
SlotId.keyManagement => nameFor(l10n.s_slot_9d), SlotId.keyManagement => nameFor(l10n.s_slot_9d),
SlotId.cardAuth => nameFor(l10n.s_slot_9e), SlotId.cardAuth => nameFor(l10n.s_slot_9e),
_ => l10n.s_retired_slot_display_name(hexId)
}; };
} }
@ -186,8 +208,8 @@ class PinMetadata with _$PinMetadata {
@freezed @freezed
class PinVerificationStatus with _$PinVerificationStatus { class PinVerificationStatus with _$PinVerificationStatus {
const factory PinVerificationStatus.success() = _PinSuccess; const factory PinVerificationStatus.success() = PinSuccess;
factory PinVerificationStatus.failure(int attemptsRemaining) = _PinFailure; factory PinVerificationStatus.failure(int attemptsRemaining) = PinFailure;
} }
@freezed @freezed

View File

@ -211,20 +211,20 @@ mixin _$PinVerificationStatus {
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@optionalTypeArgs @optionalTypeArgs
TResult map<TResult extends Object?>({ TResult map<TResult extends Object?>({
required TResult Function(_PinSuccess value) success, required TResult Function(PinSuccess value) success,
required TResult Function(_PinFailure value) failure, required TResult Function(PinFailure value) failure,
}) => }) =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@optionalTypeArgs @optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({ TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_PinSuccess value)? success, TResult? Function(PinSuccess value)? success,
TResult? Function(_PinFailure value)? failure, TResult? Function(PinFailure value)? failure,
}) => }) =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@optionalTypeArgs @optionalTypeArgs
TResult maybeMap<TResult extends Object?>({ TResult maybeMap<TResult extends Object?>({
TResult Function(_PinSuccess value)? success, TResult Function(PinSuccess value)? success,
TResult Function(_PinFailure value)? failure, TResult Function(PinFailure value)? failure,
required TResult orElse(), required TResult orElse(),
}) => }) =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@ -267,7 +267,7 @@ class __$$PinSuccessImplCopyWithImpl<$Res>
/// @nodoc /// @nodoc
class _$PinSuccessImpl implements _PinSuccess { class _$PinSuccessImpl implements PinSuccess {
const _$PinSuccessImpl(); const _$PinSuccessImpl();
@override @override
@ -318,8 +318,8 @@ class _$PinSuccessImpl implements _PinSuccess {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult map<TResult extends Object?>({ TResult map<TResult extends Object?>({
required TResult Function(_PinSuccess value) success, required TResult Function(PinSuccess value) success,
required TResult Function(_PinFailure value) failure, required TResult Function(PinFailure value) failure,
}) { }) {
return success(this); return success(this);
} }
@ -327,8 +327,8 @@ class _$PinSuccessImpl implements _PinSuccess {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({ TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_PinSuccess value)? success, TResult? Function(PinSuccess value)? success,
TResult? Function(_PinFailure value)? failure, TResult? Function(PinFailure value)? failure,
}) { }) {
return success?.call(this); return success?.call(this);
} }
@ -336,8 +336,8 @@ class _$PinSuccessImpl implements _PinSuccess {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult maybeMap<TResult extends Object?>({ TResult maybeMap<TResult extends Object?>({
TResult Function(_PinSuccess value)? success, TResult Function(PinSuccess value)? success,
TResult Function(_PinFailure value)? failure, TResult Function(PinFailure value)? failure,
required TResult orElse(), required TResult orElse(),
}) { }) {
if (success != null) { if (success != null) {
@ -347,8 +347,8 @@ class _$PinSuccessImpl implements _PinSuccess {
} }
} }
abstract class _PinSuccess implements PinVerificationStatus { abstract class PinSuccess implements PinVerificationStatus {
const factory _PinSuccess() = _$PinSuccessImpl; const factory PinSuccess() = _$PinSuccessImpl;
} }
/// @nodoc /// @nodoc
@ -384,7 +384,7 @@ class __$$PinFailureImplCopyWithImpl<$Res>
/// @nodoc /// @nodoc
class _$PinFailureImpl implements _PinFailure { class _$PinFailureImpl implements PinFailure {
_$PinFailureImpl(this.attemptsRemaining); _$PinFailureImpl(this.attemptsRemaining);
@override @override
@ -447,8 +447,8 @@ class _$PinFailureImpl implements _PinFailure {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult map<TResult extends Object?>({ TResult map<TResult extends Object?>({
required TResult Function(_PinSuccess value) success, required TResult Function(PinSuccess value) success,
required TResult Function(_PinFailure value) failure, required TResult Function(PinFailure value) failure,
}) { }) {
return failure(this); return failure(this);
} }
@ -456,8 +456,8 @@ class _$PinFailureImpl implements _PinFailure {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({ TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_PinSuccess value)? success, TResult? Function(PinSuccess value)? success,
TResult? Function(_PinFailure value)? failure, TResult? Function(PinFailure value)? failure,
}) { }) {
return failure?.call(this); return failure?.call(this);
} }
@ -465,8 +465,8 @@ class _$PinFailureImpl implements _PinFailure {
@override @override
@optionalTypeArgs @optionalTypeArgs
TResult maybeMap<TResult extends Object?>({ TResult maybeMap<TResult extends Object?>({
TResult Function(_PinSuccess value)? success, TResult Function(PinSuccess value)? success,
TResult Function(_PinFailure value)? failure, TResult Function(PinFailure value)? failure,
required TResult orElse(), required TResult orElse(),
}) { }) {
if (failure != null) { if (failure != null) {
@ -476,8 +476,8 @@ class _$PinFailureImpl implements _PinFailure {
} }
} }
abstract class _PinFailure implements PinVerificationStatus { abstract class PinFailure implements PinVerificationStatus {
factory _PinFailure(final int attemptsRemaining) = _$PinFailureImpl; factory PinFailure(final int attemptsRemaining) = _$PinFailureImpl;
int get attemptsRemaining; int get attemptsRemaining;
@JsonKey(ignore: true) @JsonKey(ignore: true)

View File

@ -20,6 +20,18 @@ import '../app/models.dart';
import '../core/state.dart'; import '../core/state.dart';
import 'models.dart'; import 'models.dart';
final passkeysSearchProvider =
StateNotifierProvider<PasskeysSearchNotifier, String>(
(ref) => PasskeysSearchNotifier());
class PasskeysSearchNotifier extends StateNotifier<String> {
PasskeysSearchNotifier() : super('');
void setFilter(String value) {
state = value;
}
}
final pivStateProvider = AsyncNotifierProvider.autoDispose final pivStateProvider = AsyncNotifierProvider.autoDispose
.family<PivStateNotifier, PivState, DevicePath>( .family<PivStateNotifier, PivState, DevicePath>(
() => throw UnimplementedError(), () => throw UnimplementedError(),
@ -66,5 +78,7 @@ abstract class PivSlotsNotifier
PinPolicy pinPolicy = PinPolicy.dfault, PinPolicy pinPolicy = PinPolicy.dfault,
TouchPolicy touchPolicy = TouchPolicy.dfault, TouchPolicy touchPolicy = TouchPolicy.dfault,
}); });
Future<void> delete(SlotId slot); Future<void> delete(SlotId slot, bool deleteCert, bool deleteKey);
Future<void> moveKey(SlotId source, SlotId destination, bool overwriteKey,
bool includeCertificate);
} }

View File

@ -35,6 +35,7 @@ import 'authentication_dialog.dart';
import 'delete_certificate_dialog.dart'; import 'delete_certificate_dialog.dart';
import 'generate_key_dialog.dart'; import 'generate_key_dialog.dart';
import 'import_file_dialog.dart'; import 'import_file_dialog.dart';
import 'move_key_dialog.dart';
import 'pin_dialog.dart'; import 'pin_dialog.dart';
class GenerateIntent extends Intent { class GenerateIntent extends Intent {
@ -52,15 +53,19 @@ class ExportIntent extends Intent {
const ExportIntent(this.slot); const ExportIntent(this.slot);
} }
class MoveIntent extends Intent {
final PivSlot slot;
const MoveIntent(this.slot);
}
Future<bool> _authIfNeeded(BuildContext context, WidgetRef ref, Future<bool> _authIfNeeded(BuildContext context, WidgetRef ref,
DevicePath devicePath, PivState pivState) async { DevicePath devicePath, PivState pivState) async {
if (pivState.needsAuth) { if (pivState.needsAuth) {
if (pivState.protectedKey && if (pivState.protectedKey &&
pivState.metadata?.pinMetadata.defaultValue == true) { pivState.metadata?.pinMetadata.defaultValue == true) {
final status = await ref return await ref
.read(pivStateProvider(devicePath).notifier) .read(pivStateProvider(devicePath).notifier)
.verifyPin(defaultPin); .verifyPin(defaultPin) is PinSuccess;
return status.when(success: () => true, failure: (_) => false);
} }
return await showBlurDialog( return await showBlurDialog(
context: context, context: context,
@ -108,11 +113,9 @@ class PivActions extends ConsumerWidget {
if (!pivState.protectedKey) { if (!pivState.protectedKey) {
bool verified; bool verified;
if (pivState.metadata?.pinMetadata.defaultValue == true) { if (pivState.metadata?.pinMetadata.defaultValue == true) {
final status = await ref verified = await ref
.read(pivStateProvider(devicePath).notifier) .read(pivStateProvider(devicePath).notifier)
.verifyPin(defaultPin); .verifyPin(defaultPin) is PinSuccess;
verified =
status.when(success: () => true, failure: (_) => false);
} else { } else {
verified = await withContext((context) async => verified = await withContext((context) async =>
await showBlurDialog( await showBlurDialog(
@ -266,12 +269,32 @@ class PivActions extends ConsumerWidget {
context: context, context: context,
builder: (context) => DeleteCertificateDialog( builder: (context) => DeleteCertificateDialog(
devicePath, devicePath,
pivState,
intent.target, intent.target,
), ),
) ?? ) ??
false); false);
return deleted; return deleted;
}), }),
if (hasFeature(features.slotsMove))
MoveIntent: CallbackAction<MoveIntent>(onInvoke: (intent) async {
if (!await withContext((context) =>
_authIfNeeded(context, ref, devicePath, pivState))) {
return false;
}
final bool? moved = await withContext((context) async =>
await showBlurDialog(
context: context,
builder: (context) => MoveKeyDialog(
devicePath,
pivState,
intent.slot,
),
) ??
false);
return moved;
}),
}, },
child: Builder( child: Builder(
// Builder to ensure new scope for actions, they can invoke parent actions // Builder to ensure new scope for actions, they can invoke parent actions
@ -286,10 +309,13 @@ class PivActions extends ConsumerWidget {
} }
} }
List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) { List<ActionItem> buildSlotActions(
PivState pivState, PivSlot slot, AppLocalizations l10n) {
final hasCert = slot.certInfo != null; final hasCert = slot.certInfo != null;
final hasKey = slot.metadata != null; final hasKey = slot.metadata != null;
final canDeleteOrMoveKey = hasKey && pivState.version.isAtLeast(5, 7);
return [ return [
if (!slot.slot.isRetired) ...[
ActionItem( ActionItem(
key: keys.generateAction, key: keys.generateAction,
feature: features.slotsGenerate, feature: features.slotsGenerate,
@ -307,6 +333,7 @@ List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
subtitle: l10n.l_import_desc, subtitle: l10n.l_import_desc,
intent: ImportIntent(slot), intent: ImportIntent(slot),
), ),
],
if (hasCert) ...[ if (hasCert) ...[
ActionItem( ActionItem(
key: keys.exportAction, key: keys.exportAction,
@ -316,15 +343,6 @@ List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
subtitle: l10n.l_export_certificate_desc, subtitle: l10n.l_export_certificate_desc,
intent: ExportIntent(slot), intent: ExportIntent(slot),
), ),
ActionItem(
key: keys.deleteAction,
feature: features.slotsDelete,
actionStyle: ActionStyle.error,
icon: const Icon(Symbols.delete),
title: l10n.l_delete_certificate,
subtitle: l10n.l_delete_certificate_desc,
intent: DeleteIntent(slot),
),
] else if (hasKey) ...[ ] else if (hasKey) ...[
ActionItem( ActionItem(
key: keys.exportAction, key: keys.exportAction,
@ -335,5 +353,33 @@ List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
intent: ExportIntent(slot), intent: ExportIntent(slot),
), ),
], ],
if (canDeleteOrMoveKey)
ActionItem(
key: keys.moveAction,
feature: features.slotsMove,
actionStyle: ActionStyle.error,
icon: const Icon(Symbols.move_item),
title: l10n.l_move_key,
subtitle: l10n.l_move_key_desc,
intent: MoveIntent(slot),
),
if (hasCert || canDeleteOrMoveKey)
ActionItem(
key: keys.deleteAction,
feature: features.slotsDelete,
actionStyle: ActionStyle.error,
icon: const Icon(Symbols.delete),
title: hasCert && canDeleteOrMoveKey
? l10n.l_delete_certificate_or_key
: hasCert
? l10n.l_delete_certificate
: l10n.l_delete_key,
subtitle: hasCert && canDeleteOrMoveKey
? l10n.l_delete_certificate_or_key_desc
: hasCert
? l10n.l_delete_certificate_desc
: l10n.l_delete_key_desc,
intent: DeleteIntent(slot),
),
]; ];
} }

View File

@ -27,34 +27,75 @@ import '../keys.dart' as keys;
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
class DeleteCertificateDialog extends ConsumerWidget { class DeleteCertificateDialog extends ConsumerStatefulWidget {
final DevicePath devicePath; final DevicePath devicePath;
final PivState pivState;
final PivSlot pivSlot; final PivSlot pivSlot;
const DeleteCertificateDialog(this.devicePath, this.pivSlot, {super.key}); const DeleteCertificateDialog(this.devicePath, this.pivState, this.pivSlot,
{super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<ConsumerStatefulWidget> createState() =>
_DeleteCertificateDialogState();
}
class _DeleteCertificateDialogState
extends ConsumerState<DeleteCertificateDialog> {
late bool _deleteCertificate;
late bool _deleteKey;
@override
void initState() {
super.initState();
_deleteCertificate = widget.pivSlot.certInfo != null;
_deleteKey = widget.pivSlot.metadata != null &&
widget.pivState.version.isAtLeast(5, 7);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canDeleteCertificate = widget.pivSlot.certInfo != null;
final canDeleteKey = widget.pivSlot.metadata != null &&
widget.pivState.version.isAtLeast(5, 7);
return ResponsiveDialog( return ResponsiveDialog(
title: Text(l10n.l_delete_certificate), title: Text(canDeleteKey && canDeleteCertificate
? l10n.l_delete_certificate_or_key
: canDeleteCertificate
? l10n.l_delete_certificate
: l10n.l_delete_key),
actions: [ actions: [
TextButton( TextButton(
key: keys.deleteButton, key: keys.deleteButton,
onPressed: () async { onPressed: _deleteKey || _deleteCertificate
? () async {
try { try {
await ref await ref
.read(pivSlotsProvider(devicePath).notifier) .read(pivSlotsProvider(widget.devicePath).notifier)
.delete(pivSlot.slot); .delete(widget.pivSlot.slot, _deleteCertificate,
_deleteKey);
await ref.read(withContextProvider)( await ref.read(withContextProvider)(
(context) async { (context) async {
String message;
if (_deleteCertificate && _deleteKey) {
message = l10n.l_certificate_and_key_deleted;
} else if (_deleteCertificate) {
message = l10n.l_certificate_deleted;
} else {
message = l10n.l_key_deleted;
}
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
showMessage(context, l10n.l_certificate_deleted); showMessage(context, message);
}, },
); );
} on CancellationException catch (_) { } on CancellationException catch (_) {
// ignored // ignored
} }
}, }
: null,
child: Text(l10n.s_delete), child: Text(l10n.s_delete),
), ),
], ],
@ -63,9 +104,55 @@ class DeleteCertificateDialog extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(l10n.p_warning_delete_certificate), if (_deleteCertificate || _deleteKey) ...[
Text(l10n.q_delete_certificate_confirm( Text(
pivSlot.slot.getDisplayName(l10n))), _deleteCertificate && _deleteKey
? l10n.p_warning_delete_certificate_and_key
: _deleteCertificate
? l10n.p_warning_delete_certificate
: l10n.p_warning_delete_key,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w700),
),
Text(_deleteCertificate && _deleteKey
? l10n.q_delete_certificate_and_key_confirm(
widget.pivSlot.slot.getDisplayName(l10n))
: _deleteCertificate
? l10n.q_delete_certificate_confirm(
widget.pivSlot.slot.getDisplayName(l10n))
: l10n.q_delete_key_confirm(
widget.pivSlot.slot.getDisplayName(l10n)))
],
if (!_deleteCertificate && !_deleteKey)
Text(l10n.p_select_what_to_delete),
if (canDeleteKey && canDeleteCertificate)
Wrap(
spacing: 4.0,
runSpacing: 8.0,
children: [
if (canDeleteCertificate)
FilterChip(
label: Text(l10n.s_certificate),
selected: _deleteCertificate,
onSelected: (value) {
setState(() {
_deleteCertificate = value;
});
},
),
if (canDeleteKey)
FilterChip(
label: Text(l10n.s_private_key),
selected: _deleteKey,
onSelected: (value) {
setState(() {
_deleteKey = value;
});
})
],
),
] ]
.map((e) => Padding( .map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -0,0 +1,162 @@
/*
* Copyright (C) 2023 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/choice_filter_chip.dart';
import '../../widgets/responsive_dialog.dart';
import '../keys.dart' as keys;
import '../models.dart';
import '../state.dart';
import 'overwrite_confirm_dialog.dart';
class MoveKeyDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final PivState pivState;
final PivSlot pivSlot;
const MoveKeyDialog(this.devicePath, this.pivState, this.pivSlot,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _MoveKeyDialogState();
}
class _MoveKeyDialogState extends ConsumerState<MoveKeyDialog> {
SlotId? _destination;
late bool _includeCertificate;
@override
void initState() {
super.initState();
_includeCertificate = widget.pivSlot.certInfo != null;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ResponsiveDialog(
title: Text(l10n.l_move_key),
actions: [
TextButton(
key: keys.deleteButton,
onPressed: _destination != null
? () async {
try {
final pivSlots =
ref.read(pivSlotsProvider(widget.devicePath)).asData;
if (pivSlots != null) {
final destination = pivSlots.value.firstWhere(
(element) => element.slot == _destination);
if (!await confirmOverwrite(context, destination,
writeKey: true, writeCert: _includeCertificate)) {
return;
}
await ref
.read(pivSlotsProvider(widget.devicePath).notifier)
.moveKey(
widget.pivSlot.slot,
destination.slot,
destination.metadata != null,
_includeCertificate);
await ref.read(withContextProvider)(
(context) async {
String message;
if (_includeCertificate) {
message = l10n.l_key_and_certificate_moved;
} else {
message = l10n.l_key_moved;
}
Navigator.of(context).pop(true);
showMessage(context, message);
},
);
}
} on CancellationException catch (_) {
// ignored
}
}
: null,
child: Text(l10n.s_move),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_destination == null
? l10n.q_move_key_confirm(
widget.pivSlot.slot.getDisplayName(l10n))
: widget.pivSlot.certInfo != null && _includeCertificate
? l10n.q_move_key_and_certificate_to_slot_confirm(
widget.pivSlot.slot.getDisplayName(l10n),
_destination!.getDisplayName(l10n))
: l10n.q_move_key_to_slot_confirm(
widget.pivSlot.slot.getDisplayName(l10n),
_destination!.getDisplayName(l10n))),
Wrap(
spacing: 4.0,
runSpacing: 8.0,
children: [
ChoiceFilterChip<SlotId?>(
menuConstraints: const BoxConstraints(maxHeight: 200),
value: _destination,
items: SlotId.values
.where((element) => element != widget.pivSlot.slot)
.toList(),
labelBuilder: (value) => Text(_destination == null
? l10n.l_select_destination_slot
: _destination!.getDisplayName(l10n)),
itemBuilder: (value) => Text(value!.getDisplayName(l10n)),
onChanged: (value) {
setState(() {
_destination = value;
});
},
),
if (widget.pivSlot.certInfo != null)
FilterChip(
label: Text(l10n.l_include_certificate),
selected: _includeCertificate,
onSelected: (value) {
setState(() {
_includeCertificate = value;
});
})
],
),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
),
),
);
}
}

View File

@ -72,6 +72,16 @@ class _PivScreenState extends ConsumerState<PivScreen> {
final selected = _selected != null final selected = _selected != null
? pivSlots?.value.firstWhere((e) => e.slot == _selected) ? pivSlots?.value.firstWhere((e) => e.slot == _selected)
: null; : null;
final normalSlots = pivSlots?.value
.where((element) => !element.slot.isRetired)
.toList() ??
[];
final shownRetiredSlots = pivSlots?.value
.where((element) =>
element.slot.isRetired &&
(element.certInfo != null && element.metadata != null))
.toList() ??
[];
final theme = Theme.of(context); final theme = Theme.of(context);
final textTheme = theme.textTheme; final textTheme = theme.textTheme;
// This is what ListTile uses for subtitle // This is what ListTile uses for subtitle
@ -150,7 +160,8 @@ class _PivScreenState extends ConsumerState<PivScreen> {
ActionListSection.fromMenuActions( ActionListSection.fromMenuActions(
context, context,
l10n.s_actions, l10n.s_actions,
actions: buildSlotActions(selected, l10n), actions:
buildSlotActions(pivState, selected, l10n),
), ),
], ],
) )
@ -183,14 +194,22 @@ class _PivScreenState extends ConsumerState<PivScreen> {
}, },
child: Column( child: Column(
children: [ children: [
if (pivSlots?.hasValue == true) ...normalSlots.map(
...pivSlots!.value.map(
(e) => _CertificateListItem( (e) => _CertificateListItem(
pivState,
e, e,
expanded: expanded, expanded: expanded,
selected: e == selected, selected: e == selected,
), ),
), ),
...shownRetiredSlots.map(
(e) => _CertificateListItem(
pivState,
e,
expanded: expanded,
selected: e == selected,
),
)
], ],
), ),
); );
@ -204,11 +223,12 @@ class _PivScreenState extends ConsumerState<PivScreen> {
} }
class _CertificateListItem extends ConsumerWidget { class _CertificateListItem extends ConsumerWidget {
final PivState pivState;
final PivSlot pivSlot; final PivSlot pivSlot;
final bool expanded; final bool expanded;
final bool selected; final bool selected;
const _CertificateListItem(this.pivSlot, const _CertificateListItem(this.pivState, this.pivSlot,
{required this.expanded, required this.selected}); {required this.expanded, required this.selected});
@override @override
@ -226,7 +246,7 @@ class _CertificateListItem extends ConsumerWidget {
leading: CircleAvatar( leading: CircleAvatar(
foregroundColor: colorScheme.onSecondary, foregroundColor: colorScheme.onSecondary,
backgroundColor: colorScheme.secondary, backgroundColor: colorScheme.secondary,
child: const Icon(Symbols.badge), child: Icon(slot.isRetired ? Symbols.manage_history : Symbols.badge),
), ),
title: slot.getDisplayName(l10n), title: slot.getDisplayName(l10n),
subtitle: certInfo != null subtitle: certInfo != null
@ -245,7 +265,7 @@ class _CertificateListItem extends ConsumerWidget {
tapIntent: isDesktop && !expanded ? null : OpenIntent(pivSlot), tapIntent: isDesktop && !expanded ? null : OpenIntent(pivSlot),
doubleTapIntent: isDesktop && !expanded ? OpenIntent(pivSlot) : null, doubleTapIntent: isDesktop && !expanded ? OpenIntent(pivSlot) : null,
buildPopupActions: hasFeature(features.slots) buildPopupActions: hasFeature(features.slots)
? (context) => buildSlotActions(pivSlot, l10n) ? (context) => buildSlotActions(pivState, pivSlot, l10n)
: null, : null,
); );
} }
@ -255,12 +275,52 @@ class _CertificateListItem extends ConsumerWidget {
SlotId.signature => meatballButton9c, SlotId.signature => meatballButton9c,
SlotId.keyManagement => meatballButton9d, SlotId.keyManagement => meatballButton9d,
SlotId.cardAuth => meatballButton9e, SlotId.cardAuth => meatballButton9e,
SlotId.retired1 => meatballButton82,
SlotId.retired2 => meatballButton83,
SlotId.retired3 => meatballButton84,
SlotId.retired4 => meatballButton85,
SlotId.retired5 => meatballButton86,
SlotId.retired6 => meatballButton87,
SlotId.retired7 => meatballButton88,
SlotId.retired8 => meatballButton89,
SlotId.retired9 => meatballButton8a,
SlotId.retired10 => meatballButton8b,
SlotId.retired11 => meatballButton8c,
SlotId.retired12 => meatballButton8d,
SlotId.retired13 => meatballButton8e,
SlotId.retired14 => meatballButton8f,
SlotId.retired15 => meatballButton90,
SlotId.retired16 => meatballButton91,
SlotId.retired17 => meatballButton92,
SlotId.retired18 => meatballButton93,
SlotId.retired19 => meatballButton94,
SlotId.retired20 => meatballButton95
}; };
Key _getAppListItemKey(SlotId slotId) => switch (slotId) { Key _getAppListItemKey(SlotId slotId) => switch (slotId) {
SlotId.authentication => appListItem9a, SlotId.authentication => appListItem9a,
SlotId.signature => appListItem9c, SlotId.signature => appListItem9c,
SlotId.keyManagement => appListItem9d, SlotId.keyManagement => appListItem9d,
SlotId.cardAuth => appListItem9e SlotId.cardAuth => appListItem9e,
SlotId.retired1 => appListItem82,
SlotId.retired2 => appListItem83,
SlotId.retired3 => appListItem84,
SlotId.retired4 => appListItem85,
SlotId.retired5 => appListItem86,
SlotId.retired6 => appListItem87,
SlotId.retired7 => appListItem88,
SlotId.retired8 => appListItem89,
SlotId.retired9 => appListItem8a,
SlotId.retired10 => appListItem8b,
SlotId.retired11 => appListItem8c,
SlotId.retired12 => appListItem8d,
SlotId.retired13 => appListItem8e,
SlotId.retired14 => appListItem8f,
SlotId.retired15 => appListItem90,
SlotId.retired16 => appListItem91,
SlotId.retired17 => appListItem92,
SlotId.retired18 => appListItem93,
SlotId.retired19 => appListItem94,
SlotId.retired20 => appListItem95
}; };
} }

View File

@ -111,7 +111,7 @@ class SlotDialog extends ConsumerWidget {
ActionListSection.fromMenuActions( ActionListSection.fromMenuActions(
context, context,
l10n.s_actions, l10n.s_actions,
actions: buildSlotActions(slotData, l10n), actions: buildSlotActions(pivState, slotData, l10n),
), ),
], ],
), ),

View File

@ -29,6 +29,7 @@ class ChoiceFilterChip<T> extends StatefulWidget {
final Widget? avatar; final Widget? avatar;
final bool selected; final bool selected;
final bool? disableHover; final bool? disableHover;
final BoxConstraints? menuConstraints;
const ChoiceFilterChip({ const ChoiceFilterChip({
super.key, super.key,
required this.value, required this.value,
@ -40,6 +41,7 @@ class ChoiceFilterChip<T> extends StatefulWidget {
this.selected = false, this.selected = false,
this.disableHover, this.disableHover,
this.labelBuilder, this.labelBuilder,
this.menuConstraints,
}); });
@override @override
@ -63,6 +65,7 @@ class _ChoiceFilterChipState<T> extends State<ChoiceFilterChip<T>> {
Offset.zero & overlay.size, Offset.zero & overlay.size,
); );
return await showMenu( return await showMenu(
constraints: widget.menuConstraints,
context: context, context: context,
position: position, position: position,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(

View File

@ -815,6 +815,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.99"
sliver_tools:
dependency: "direct main"
description:
name: sliver_tools
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
url: "https://pub.dev"
source: hosted
version: "0.2.12"
source_gen: source_gen:
dependency: transitive dependency: transitive
description: description:

View File

@ -71,6 +71,7 @@ dependencies:
base32: ^2.1.3 base32: ^2.1.3
convert: ^3.1.1 convert: ^3.1.1
material_symbols_icons: ^4.2719.1 material_symbols_icons: ^4.2719.1
sliver_tools: ^0.2.12
dev_dependencies: dev_dependencies:
integration_test: integration_test: