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

View File

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

View File

@ -42,6 +42,7 @@ from yubikit.logging import LOG_LEVEL
from ykman.pcsc import list_devices, YK_READER_NAME
from smartcard.Exceptions import SmartcardException, NoCardException
from smartcard.pcsc.PCSCExceptions import EstablishContextException
from smartcard.CardMonitoring import CardObserver, CardMonitor
from hashlib import sha256
from dataclasses import asdict
from typing import Mapping, Tuple
@ -263,9 +264,6 @@ class AbstractDeviceNode(RpcNode):
class UsbDeviceNode(AbstractDeviceNode):
def __init__(self, device, info):
super().__init__(device, info)
def _supports_connection(self, conn_type):
return self._device.pid.supports_connection(conn_type)
@ -308,15 +306,53 @@ class UsbDeviceNode(AbstractDeviceNode):
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):
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):
if self._observer.data is None:
card = self._observer.card
if card is None:
return dict(present=False, status="no-card")
try:
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:
return dict(present=False, status="no-card")
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
def ccid(self):

View File

@ -399,12 +399,41 @@ class SlotNode(RpcNode):
else None,
)
@action(condition=lambda self: self.certificate)
@action(condition=lambda self: self.certificate or self.metadata)
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.put_object(OBJECT_ID.CHUID, generate_chuid())
self._refresh()
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()
@action

View File

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

View File

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

View File

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

View File

@ -14,11 +14,15 @@
* limitations under the License.
*/
import 'dart:async';
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.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:sliver_tools/sliver_tools.dart';
import '../../core/state.dart';
import '../../management/models.dart';
@ -30,12 +34,30 @@ import 'fs_dialog.dart';
import 'keys.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 _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? alternativeTitle;
final String? footnote;
final Widget Function(BuildContext context, bool expanded) builder;
final Widget Function(BuildContext context)? detailViewBuilder;
@ -49,9 +71,11 @@ class AppPage extends StatelessWidget {
final Widget? fileDropOverlay;
final Function(File file)? onFileDropped;
final List<Capability>? capabilities;
const AppPage({
super.key,
final Widget? headerSliver;
const AppPage(
{super.key,
this.title,
this.alternativeTitle,
this.footnote,
required this.builder,
this.centered = false,
@ -64,14 +88,45 @@ class AppPage extends StatelessWidget {
this.onFileDropped,
this.delayedContent = false,
this.keyActionsBadge = false,
}) : assert(!(onFileDropped != null && fileDropOverlay == null),
this.headerSliver})
: assert(!(onFileDropped != null && fileDropOverlay == null),
'Declaring onFileDropped requires declaring a fileDropOverlay');
@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) {
final width = constraints.maxWidth;
if (width < 400 ||
(isAndroid && width < 600 && width < constraints.maxHeight)) {
return _buildScaffold(context, true, false, false);
@ -87,34 +142,11 @@ class AppPage extends StatelessWidget {
if (scaffoldState?.isDrawerOpen == true) {
scaffoldState?.openEndDrawer();
}
return Scaffold(
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),
),
],
),
);
return _buildScaffold(context, false, true, true);
}
},
);
}
Widget _buildLogo(BuildContext context) {
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) {
return ListenableBuilder(
listenable: _sliverTitleController,
builder: (context, child) {
_scrollElement(
context,
_sliverTitleScrollController,
_sliverTitleController.scrollDirection,
_sliverTitleController,
_sliverTitleWrapperGlobalKey,
null);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title!,
Flexible(
child: Text(
key: _sliverTitleGlobalKey,
widget.alternativeTitle ?? widget.title!,
style: Theme.of(context).textTheme.displaySmall!.copyWith(
color: Theme.of(context).colorScheme.primary.withOpacity(0.9))),
if (capabilities != null)
color: widget.alternativeTitle != 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(
spacing: 4.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) {
final actions = actionsBuilder?.call(context, expanded) ?? [];
final actions = widget.actionsBuilder?.call(context, expanded) ?? [];
final content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
crossAxisAlignment: widget.centered
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
if (title != null && !centered)
Padding(
padding:
const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 24.0),
child: _buildTitle(context),
),
builder(context, expanded),
widget.builder(context, expanded),
if (actions.isNotEmpty)
Align(
alignment: centered ? Alignment.center : Alignment.centerLeft,
alignment:
widget.centered ? Alignment.center : Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
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:
const EdgeInsets.only(bottom: 16, top: 33, left: 16, right: 16),
child: Opacity(
opacity: 0.6,
child: Text(
footnote!,
widget.footnote!,
style: Theme.of(context).textTheme.bodySmall,
),
),
@ -220,7 +371,7 @@ class AppPage extends StatelessWidget {
);
final safeArea = SafeArea(
child: delayedContent
child: widget.delayedContent
? DelayedVisibility(
key: GlobalKey(), // Ensure we reset the delay on rebuild
delay: const Duration(milliseconds: 400),
@ -229,10 +380,9 @@ class AppPage extends StatelessWidget {
: content,
);
if (centered) {
return Stack(
children: [
if (title != null)
if (widget.centered) {
return Stack(children: [
if (widget.title != null)
Positioned.fill(
child: Align(
alignment: Alignment.topLeft,
@ -244,24 +394,85 @@ class AppPage extends StatelessWidget {
),
),
Positioned.fill(
top: title != null ? 68.0 : 0,
top: widget.title != null ? 68.0 : 0,
child: Align(
alignment: Alignment.center,
child: ScrollConfiguration(
behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
physics: isAndroid
? const ClampingScrollPhysics(
parent: AlwaysScrollableScrollPhysics())
: null,
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(
physics: isAndroid
? const ClampingScrollPhysics(parent: AlwaysScrollableScrollPhysics())
: null,
primary: false,
child: safeArea,
);
@ -269,12 +480,14 @@ class AppPage extends StatelessWidget {
Scaffold _buildScaffold(
BuildContext context, bool hasDrawer, bool hasRail, bool hasManage) {
final fullyExpanded = !hasDrawer && hasRail && hasManage;
final showNavigation = ref.watch(_navigationProvider);
var body = _buildMainContent(context, hasManage);
if (onFileDropped != null) {
if (widget.onFileDropped != null) {
body = FileDropTarget(
onFileDropped: onFileDropped!,
overlay: fileDropOverlay!,
onFileDropped: widget.onFileDropped!,
overlay: widget.fileDropOverlay!,
child: body,
);
}
@ -282,9 +495,12 @@ class AppPage extends StatelessWidget {
body = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasRail)
if (hasRail && (!fullyExpanded || !showNavigation))
SizedBox(
width: 72,
child: _VisibilityListener(
targetKey: _navKey,
controller: _navController,
child: SingleChildScrollView(
child: NavigationContent(
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),
Expanded(
child: GestureDetector(
@ -308,47 +542,85 @@ class AppPage extends StatelessWidget {
]),
)),
if (hasManage &&
(detailViewBuilder != null || keyActionsBuilder != null))
SingleChildScrollView(
(widget.detailViewBuilder != null ||
widget.keyActionsBuilder != null))
_VisibilityListener(
controller: _detailsController,
targetKey: _detailsViewGlobalKey,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 320,
child: Column(
key: _detailsViewGlobalKey,
children: [
if (detailViewBuilder != null)
detailViewBuilder!(context),
if (keyActionsBuilder != null)
keyActionsBuilder!(context),
if (widget.detailViewBuilder != null)
widget.detailViewBuilder!(context),
if (widget.keyActionsBuilder != null)
widget.keyActionsBuilder!(context),
],
),
),
),
),
),
],
);
}
return Scaffold(
key: scaffoldGlobalKey,
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,
leadingWidth: hasRail ? 84 : null,
backgroundColor: Theme.of(context).colorScheme.background,
title: _buildAppBarTitle(
context,
hasRail,
hasManage,
fullyExpanded,
),
leading: hasRail
? const Row(
? Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: DrawerButton(),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: DrawerButton(
onPressed: fullyExpanded
? () {
ref
.read(_navigationProvider.notifier)
.toggleExpanded();
}
: null,
),
)),
SizedBox(width: 12),
const SizedBox(width: 12),
],
)
: null,
actions: [
if (actionButtonBuilder == null &&
(keyActionsBuilder != null && !hasManage))
if (widget.actionButtonBuilder == null &&
(widget.keyActionsBuilder != null && !hasManage))
Padding(
padding: const EdgeInsets.only(left: 4),
child: IconButton(
@ -360,12 +632,12 @@ class AppPage extends StatelessWidget {
builder: (context) => FsDialog(
child: Padding(
padding: const EdgeInsets.only(top: 32),
child: keyActionsBuilder!(context),
child: widget.keyActionsBuilder!(context),
),
),
);
},
icon: keyActionsBadge
icon: widget.keyActionsBadge
? const Badge(
child: Icon(Symbols.more_vert),
)
@ -375,10 +647,10 @@ class AppPage extends StatelessWidget {
padding: const EdgeInsets.all(12),
),
),
if (actionButtonBuilder != null)
if (widget.actionButtonBuilder != null)
Padding(
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,
onLongPressStart: isAndroid ? showMenuFn : null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6.5),
padding: const EdgeInsets.only(bottom: 6.5),
child: widget.selected
? IconButton.filled(
tooltip: isDesktop ? tooltip : null,

View File

@ -18,6 +18,8 @@ import 'package:flutter/material.dart';
// global keys
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 deviceInfoListTile = Key('$_prefix.device_info_list_tile');

View File

@ -128,7 +128,8 @@ class NavigationContent extends ConsumerWidget {
final currentSection = ref.watch(currentSectionProvider);
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(
children: [
AnimatedSize(

View File

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

View File

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

View File

@ -72,8 +72,25 @@ class _DesktopPivStateNotifier extends PivStateNotifier {
ref.invalidate(_sessionProvider(devicePath));
})
..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;
if (state.valueOrNull?.metadata?.managementKeyMetadata.defaultValue ==
if (state.valueOrNull?.metadata?.managementKeyMetadata
.defaultValue ==
true) {
mgmtKey = defaultManagementKey;
} else {
@ -81,15 +98,16 @@ class _DesktopPivStateNotifier extends PivStateNotifier {
}
if (mgmtKey != null) {
if (await authenticate(mgmtKey)) {
ref.invalidateSelf();
return;
} else {
ref.read(_managementKeyProvider(devicePath).notifier).state = null;
ref.invalidateSelf();
throw e;
ref.read(_managementKeyProvider(devicePath).notifier).state =
null;
}
}
}
} else {
ref.invalidateSelf();
throw e;
} finally {
ref.invalidateSelf();
}
});
ref.onDispose(() {
@ -299,8 +317,24 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier {
}
@override
Future<void> delete(SlotId slot) async {
await _session.command('delete', target: ['slots', slot.hexId]);
Future<void> delete(SlotId slot, bool deleteCert, bool deleteKey) async {
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();
}

View File

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

View File

@ -20,6 +20,18 @@ import '../app/models.dart';
import '../core/state.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
.family<FidoStateNotifier, FidoState, DevicePath>(
() => throw UnimplementedError(),
@ -52,3 +64,23 @@ abstract class FidoCredentialsNotifier
extends AutoDisposeFamilyAsyncNotifier<List<FidoCredential>, DevicePath> {
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 Icon(Symbols.person, size: 72),
const Icon(Symbols.passkey, size: 72),
],
),
),

View File

@ -17,6 +17,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.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_list_item.dart';
import '../../app/views/app_page.dart';
import '../../app/views/keys.dart';
import '../../app/views/message_page.dart';
import '../../app/views/message_page_not_initialized.dart';
import '../../core/state.dart';
import '../../exception/no_data_exception.dart';
import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
import '../../widgets/list_title.dart';
import '../features.dart' as features;
import '../models.dart';
@ -137,7 +141,7 @@ class _FidoLockedPage extends ConsumerWidget {
: alwaysUv
? l10n.l_pin_change_required_desc
: 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,
keyActionsBadge: passkeysShowActionsNotifier(state),
);
@ -149,7 +153,7 @@ class _FidoLockedPage extends ConsumerWidget {
capabilities: const [Capability.fido2],
header: l10n.l_ready_to_use,
message: l10n.l_register_sk_on_websites,
footnote: l10n.l_non_passkeys_note,
footnote: l10n.p_non_passkeys_note,
keyActionsBuilder: hasActions ? _buildActions : null,
keyActionsBadge: passkeysShowActionsNotifier(state),
);
@ -206,8 +210,30 @@ class _FidoUnlockedPage extends ConsumerStatefulWidget {
}
class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
late FocusNode searchFocus;
late TextEditingController searchController;
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
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@ -222,7 +248,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
capabilities: const [Capability.fido2],
header: l10n.l_no_discoverable_accounts,
message: l10n.l_register_sk_on_websites,
footnote: l10n.l_non_passkeys_note,
footnote: l10n.p_non_passkeys_note,
keyActionsBuilder: hasActions
? (context) =>
passkeysBuildActions(context, widget.node, widget.state)
@ -236,6 +262,12 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
return _buildLoadingPage(context);
}
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) {
return MessagePage(
@ -265,14 +297,22 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
passkeysBuildActions(context, widget.node, widget.state)
: null,
keyActionsBadge: passkeysShowActionsNotifier(widget.state),
footnote: l10n.l_non_passkeys_note,
footnote: l10n.p_non_passkeys_note,
);
}
final credential = _selected;
final searchText = searchController.text;
return FidoActions(
devicePath: widget.node.path,
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) {
if (_selected != null) {
setState(() {
@ -307,8 +347,84 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
},
builder: (context) => AppPage(
title: l10n.s_passkeys,
alternativeTitle:
searchText != '' ? l10n.l_results_for(searchText) : null,
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
? (context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -347,7 +463,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> {
),
),
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: credentials
.map(
children: [
if (filteredCredentials.isEmpty)
Center(
child: Text(l10n.s_no_passkeys),
),
...filteredCredentials.map(
(cred) => _CredentialListItem(
cred,
expanded: expanded,
selected: _selected == cred,
),
)
.toList(),
),
],
),
);
},
@ -423,13 +543,14 @@ class _CredentialListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AppListItem(
credential,
selected: selected,
leading: CircleAvatar(
foregroundColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(Symbols.person),
foregroundColor: colorScheme.onSecondary,
backgroundColor: colorScheme.secondary,
child: const Icon(Symbols.passkey),
),
title: credential.userName,
subtitle: credential.rpId,

View File

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

View File

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

View File

@ -28,6 +28,7 @@
"s_cancel": "Abbrechen",
"s_close": "Schließen",
"s_delete": "Löschen",
"s_move": null,
"s_quit": "Beenden",
"s_status": null,
"s_unlock": "Entsperren",
@ -352,6 +353,12 @@
},
"s_accounts": "Konten",
"s_no_accounts": "Keine Konten",
"l_results_for": null,
"@l_results_for": {
"placeholders": {
"query": {}
}
},
"l_authenticator_get_started": null,
"l_no_accounts_desc": null,
"s_add_account": "Konto hinzufügen",
@ -419,15 +426,23 @@
}
},
"s_passkeys": null,
"s_no_passkeys": null,
"l_ready_to_use": "Bereit zur Verwendung",
"l_register_sk_on_websites": "Als Sicherheitsschlüssel auf Webseiten registrieren",
"l_no_discoverable_accounts": "Keine erkennbaren Konten",
"l_non_passkeys_note": null,
"p_non_passkeys_note": null,
"s_delete_passkey": null,
"l_delete_passkey_desc": null,
"s_passkey_deleted": null,
"p_warning_delete_passkey": null,
"s_search_passkeys": null,
"p_passkeys_used": null,
"@p_passkeys_used": {
"placeholders": {
"used": {},
"max": {}
}
},
"@_fingerprints": {},
"l_fingerprint": "Fingerabdruck: {label}",
"@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": null,
"l_delete_certificate": 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_serial": null,
"s_certificate_fingerprint": null,
@ -522,14 +543,53 @@
},
"l_generating_private_key": null,
"s_private_key_generated": null,
"p_select_what_to_delete": 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": {
"placeholders": {
"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_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_import_items_desc": null,
"@p_import_items_desc": {
@ -537,6 +597,8 @@
"slot": {}
}
},
"l_key_moved": null,
"l_key_and_certificate_moved": null,
"p_subject_desc": null,
"l_rfc4514_invalid": null,
"rfc4514_examples": null,
@ -560,6 +622,12 @@
"hexid": {}
}
},
"s_retired_slot_display_name": null,
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": null,
"s_slot_9c": null,
"s_slot_9d": null,

View File

@ -28,6 +28,7 @@
"s_cancel": "Cancel",
"s_close": "Close",
"s_delete": "Delete",
"s_move": "Move",
"s_quit": "Quit",
"s_status": "Status",
"s_unlock": "Unlock",
@ -352,6 +353,12 @@
},
"s_accounts": "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_no_accounts_desc": "Add accounts to your YubiKey from any service provider supporting OATH TOTP/HOTP",
"s_add_account": "Add account",
@ -419,15 +426,23 @@
}
},
"s_passkeys": "Passkeys",
"s_no_passkeys": "No passkeys",
"l_ready_to_use": "Ready to use",
"l_register_sk_on_websites": "Register as a Security Key on websites",
"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",
"l_delete_passkey_desc": "Remove the passkey from the YubiKey",
"s_passkey_deleted": "Passkey deleted",
"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": {},
"l_fingerprint": "Fingerprint: {label}",
"@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": "Unsupported key type",
"l_delete_certificate": "Delete certificate",
"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_serial": "Serial",
"s_certificate_fingerprint": "Fingerprint",
@ -522,14 +543,53 @@
},
"l_generating_private_key": "Generating private key\u2026",
"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_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": {
"placeholders": {
"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_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_import_items_desc": "The following item(s) will be imported into PIV slot {slot}.",
"@p_import_items_desc": {
@ -537,6 +597,8 @@
"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.",
"l_rfc4514_invalid": "Invalid RFC 4514 format",
"rfc4514_examples": "Examples:\nCN=Example Name\nCN=jsmith,DC=example,DC=net",
@ -560,6 +622,12 @@
"hexid": {}
}
},
"s_retired_slot_display_name": "Retired Key Management ({hexid})",
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": "Authentication",
"s_slot_9c": "Digital Signature",
"s_slot_9d": "Key Management",

View File

@ -28,6 +28,7 @@
"s_cancel": "Annuler",
"s_close": "Fermer",
"s_delete": "Supprimer",
"s_move": null,
"s_quit": "Quitter",
"s_status": null,
"s_unlock": "Déverrouiller",
@ -352,6 +353,12 @@
},
"s_accounts": "Comptes",
"s_no_accounts": "Aucun compte",
"l_results_for": null,
"@l_results_for": {
"placeholders": {
"query": {}
}
},
"l_authenticator_get_started": null,
"l_no_accounts_desc": null,
"s_add_account": "Ajouter un compte",
@ -419,15 +426,23 @@
}
},
"s_passkeys": "Passkeys",
"s_no_passkeys": null,
"l_ready_to_use": "Prêt à l'emploi",
"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_non_passkeys_note": null,
"p_non_passkeys_note": null,
"s_delete_passkey": "Supprimer une Passkey",
"l_delete_passkey_desc": "Supprimer la Passkey de votre YubiKey",
"s_passkey_deleted": "Passkey supprimée",
"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": {},
"l_fingerprint": "Empreinte: {label}",
"@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": null,
"l_delete_certificate": "Supprimer un certificat",
"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_serial": "Série",
"s_certificate_fingerprint": "Empreinte digitale",
@ -522,14 +543,53 @@
},
"l_generating_private_key": "Génération d'une clé privée\u2026",
"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_key": null,
"p_warning_delete_certificate_and_key": null,
"q_delete_certificate_confirm": "Supprimer le certficat du slot PIV {slot}?",
"@q_delete_certificate_confirm": {
"placeholders": {
"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_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_import_items_desc": "Les éléments suivants seront importés dans le slot PIV {slot}.",
"@p_import_items_desc": {
@ -537,6 +597,8 @@
"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.",
"l_rfc4514_invalid": "Format RFC 4514 invalide",
"rfc4514_examples": "Exemples:\nCN=Example Name\nCN=jsmith,DC=example,DC=net",
@ -560,6 +622,12 @@
"hexid": {}
}
},
"s_retired_slot_display_name": null,
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": "Authentification",
"s_slot_9c": "Signature digitale",
"s_slot_9d": "Gestion des clés",

View File

@ -28,6 +28,7 @@
"s_cancel": "キャンセル",
"s_close": "閉じる",
"s_delete": "消去",
"s_move": null,
"s_quit": "終了",
"s_status": null,
"s_unlock": "ロック解除",
@ -352,6 +353,12 @@
},
"s_accounts": "アカウント",
"s_no_accounts": "アカウントがありません",
"l_results_for": null,
"@l_results_for": {
"placeholders": {
"query": {}
}
},
"l_authenticator_get_started": null,
"l_no_accounts_desc": null,
"s_add_account": "アカウントの追加",
@ -419,15 +426,23 @@
}
},
"s_passkeys": "パスキー",
"s_no_passkeys": null,
"l_ready_to_use": "すぐに使用可能",
"l_register_sk_on_websites": "Webサイトにセキュリティキーとして登録する",
"l_no_discoverable_accounts": "パスキーは保存されていません",
"l_non_passkeys_note": null,
"p_non_passkeys_note": null,
"s_delete_passkey": "パスキーを削除",
"l_delete_passkey_desc": "YubiKeyからパスキーの削除",
"s_passkey_deleted": "パスキーが削除されました",
"p_warning_delete_passkey": "これにより、YubiKeyからパスキーが削除されます",
"s_search_passkeys": null,
"p_passkeys_used": null,
"@p_passkeys_used": {
"placeholders": {
"used": {},
"max": {}
}
},
"@_fingerprints": {},
"l_fingerprint": "指紋:{label}",
"@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": null,
"l_delete_certificate": "証明書を削除",
"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_serial": "シリアル番号",
"s_certificate_fingerprint": "指紋",
@ -522,14 +543,53 @@
},
"l_generating_private_key": "秘密鍵を生成しています\u2026",
"s_private_key_generated": "秘密鍵を生成しました",
"p_select_what_to_delete": null,
"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": {
"placeholders": {
"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_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_import_items_desc": "次のアイテムはPIVスロット{slot}にインポートされます",
"@p_import_items_desc": {
@ -537,6 +597,8 @@
"slot": {}
}
},
"l_key_moved": null,
"l_key_and_certificate_moved": null,
"p_subject_desc": "RFC 4514フォーマットの識別名識別名 (DN)",
"l_rfc4514_invalid": "無効な RFC 4514 形式です",
"rfc4514_examples": "例:\nCN=Example Name\nCN=jsmith,DC=example,DC=net",
@ -560,6 +622,12 @@
"hexid": {}
}
},
"s_retired_slot_display_name": null,
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": "認証",
"s_slot_9c": "デジタル署名",
"s_slot_9d": "鍵の管理",

View File

@ -28,6 +28,7 @@
"s_cancel": "Anuluj",
"s_close": "Zamknij",
"s_delete": "Usuń",
"s_move": null,
"s_quit": "Wyjdź",
"s_status": "Status",
"s_unlock": "Odblokuj",
@ -352,6 +353,12 @@
},
"s_accounts": "Konta",
"s_no_accounts": "Brak kont",
"l_results_for": null,
"@l_results_for": {
"placeholders": {
"query": {}
}
},
"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",
"s_add_account": "Dodaj konto",
@ -419,15 +426,23 @@
}
},
"s_passkeys": "Klucze dostępu",
"s_no_passkeys": null,
"l_ready_to_use": "Gotowe do użycia",
"l_register_sk_on_websites": "Zarejestruj jako klucz bezpieczeństwa na stronach internetowych",
"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",
"l_delete_passkey_desc": "Usuń klucz dostępu z klucza YubiKey",
"s_passkey_deleted": "Usunięto klucz dostępu",
"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": {},
"l_fingerprint": "Odcisk palca: {label}",
"@l_fingerprint": {
@ -505,6 +520,12 @@
"l_unsupported_key_type": null,
"l_delete_certificate": "Usuń certyfikat",
"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_serial": "Nr. seryjny",
"s_certificate_fingerprint": "Odcisk palca",
@ -522,14 +543,53 @@
},
"l_generating_private_key": "Generowanie prywatnego klucza\u2026",
"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_key": null,
"p_warning_delete_certificate_and_key": null,
"q_delete_certificate_confirm": "Usunąć certyfikat ze slotu PIV {slot}?",
"@q_delete_certificate_confirm": {
"placeholders": {
"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_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_import_items_desc": "Następujące elementy zostaną zaimportowane do slotu PIV {slot}.",
"@p_import_items_desc": {
@ -537,6 +597,8 @@
"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.",
"l_rfc4514_invalid": "Nieprawidłowy format RFC 4514",
"rfc4514_examples": "Przykłady:\nCN=Przykładowa Nazwa\nCN=jkowalski,DC=przyklad,DC=pl",
@ -560,6 +622,12 @@
"hexid": {}
}
},
"s_retired_slot_display_name": null,
"@s_retired_slot_display_name": {
"placeholders": {
"hexid": {}
}
},
"s_slot_9a": "Uwierzytelnienie",
"s_slot_9c": "Cyfrowy podpis",
"s_slot_9d": "Menedżer kluczy",

View File

@ -20,9 +20,6 @@ const _prefix = 'oath.keys';
const _keyAction = '$_prefix.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
const setOrManagePasswordAction =
Key('$_keyAction.action.set_or_manage_password');

View File

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

View File

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

View File

@ -30,6 +30,7 @@ import '../../app/state.dart';
import '../../app/views/action_list.dart';
import '../../app/views/app_failure_page.dart';
import '../../app/views/app_page.dart';
import '../../app/views/keys.dart';
import '../../app/views/message_page.dart';
import '../../app/views/message_page_not_initialized.dart';
import '../../core/state.dart';
@ -121,7 +122,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
void initState() {
super.initState();
searchFocus = FocusNode();
searchController = TextEditingController(text: ref.read(searchProvider));
searchController =
TextEditingController(text: ref.read(accountsSearchProvider));
searchFocus.addListener(_onFocusChange);
}
@ -144,6 +146,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
.select((value) => value?.length));
final hasFeature = ref.watch(featureProvider);
final hasActions = hasFeature(features.actions);
final searchText = searchController.text;
Future<void> onFileDropped(File file) async {
final qrScanner = ref.read(qrScannerProvider);
@ -210,7 +213,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
SearchIntent: CallbackAction<SearchIntent>(onInvoke: (_) {
searchController.selection = TextSelection(
baseOffset: 0, extentOffset: searchController.text.length);
searchFocus.requestFocus();
searchFocus.unfocus();
Timer.run(() => searchFocus.requestFocus());
return null;
}),
EscapeIntent: CallbackAction<EscapeIntent>(onInvoke: (intent) {
@ -261,6 +265,8 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
},
builder: (context) => AppPage(
title: l10n.s_accounts,
alternativeTitle:
searchText != '' ? l10n.l_results_for(searchText) : null,
capabilities: const [Capability.oath],
keyActionsBuilder: hasActions
? (context) => oathBuildActions(
@ -350,6 +356,79 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
);
}
: 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) {
// De-select if window is resized to be non-expanded.
if (!expanded && _selected != null) {
@ -373,80 +452,6 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> {
},
child: Column(
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(
builder: (context, ref, _) {
return AccountList(

View File

@ -29,3 +29,4 @@ final slotsGenerate = slots.feature('generate');
final slotsImport = slots.feature('import');
final slotsExport = slots.feature('export');
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 exportAction = Key('$_slotAction.export');
const deleteAction = Key('$_slotAction.delete');
const moveAction = Key('$_slotAction.move');
const saveButton = Key('$_prefix.save');
const deleteButton = Key('$_prefix.delete');
@ -50,11 +51,51 @@ const meatballButton9a = Key('$_prefix.9a.meatball.button');
const meatballButton9c = Key('$_prefix.9c.meatball.button');
const meatballButton9d = Key('$_prefix.9d.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 appListItem9c = Key('$_prefix.9c.applistitem');
const appListItem9d = Key('$_prefix.9d.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
const slotMetadataKeyType = Key('$_prefix.slotMetadata.keyType');

View File

@ -47,10 +47,31 @@ enum SlotId {
authentication(0x9a),
signature(0x9c),
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;
const SlotId(this.id);
final bool isRetired;
const SlotId(this.id, [this.isRetired = false]);
String get hexId => id.toRadixString(16).padLeft(2, '0');
@ -61,6 +82,7 @@ enum SlotId {
SlotId.signature => nameFor(l10n.s_slot_9c),
SlotId.keyManagement => nameFor(l10n.s_slot_9d),
SlotId.cardAuth => nameFor(l10n.s_slot_9e),
_ => l10n.s_retired_slot_display_name(hexId)
};
}
@ -186,8 +208,8 @@ class PinMetadata with _$PinMetadata {
@freezed
class PinVerificationStatus with _$PinVerificationStatus {
const factory PinVerificationStatus.success() = _PinSuccess;
factory PinVerificationStatus.failure(int attemptsRemaining) = _PinFailure;
const factory PinVerificationStatus.success() = PinSuccess;
factory PinVerificationStatus.failure(int attemptsRemaining) = PinFailure;
}
@freezed

View File

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

View File

@ -20,6 +20,18 @@ import '../app/models.dart';
import '../core/state.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
.family<PivStateNotifier, PivState, DevicePath>(
() => throw UnimplementedError(),
@ -66,5 +78,7 @@ abstract class PivSlotsNotifier
PinPolicy pinPolicy = PinPolicy.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 'generate_key_dialog.dart';
import 'import_file_dialog.dart';
import 'move_key_dialog.dart';
import 'pin_dialog.dart';
class GenerateIntent extends Intent {
@ -52,15 +53,19 @@ class ExportIntent extends Intent {
const ExportIntent(this.slot);
}
class MoveIntent extends Intent {
final PivSlot slot;
const MoveIntent(this.slot);
}
Future<bool> _authIfNeeded(BuildContext context, WidgetRef ref,
DevicePath devicePath, PivState pivState) async {
if (pivState.needsAuth) {
if (pivState.protectedKey &&
pivState.metadata?.pinMetadata.defaultValue == true) {
final status = await ref
return await ref
.read(pivStateProvider(devicePath).notifier)
.verifyPin(defaultPin);
return status.when(success: () => true, failure: (_) => false);
.verifyPin(defaultPin) is PinSuccess;
}
return await showBlurDialog(
context: context,
@ -108,11 +113,9 @@ class PivActions extends ConsumerWidget {
if (!pivState.protectedKey) {
bool verified;
if (pivState.metadata?.pinMetadata.defaultValue == true) {
final status = await ref
verified = await ref
.read(pivStateProvider(devicePath).notifier)
.verifyPin(defaultPin);
verified =
status.when(success: () => true, failure: (_) => false);
.verifyPin(defaultPin) is PinSuccess;
} else {
verified = await withContext((context) async =>
await showBlurDialog(
@ -266,12 +269,32 @@ class PivActions extends ConsumerWidget {
context: context,
builder: (context) => DeleteCertificateDialog(
devicePath,
pivState,
intent.target,
),
) ??
false);
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(
// 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 hasKey = slot.metadata != null;
final canDeleteOrMoveKey = hasKey && pivState.version.isAtLeast(5, 7);
return [
if (!slot.slot.isRetired) ...[
ActionItem(
key: keys.generateAction,
feature: features.slotsGenerate,
@ -307,6 +333,7 @@ List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
subtitle: l10n.l_import_desc,
intent: ImportIntent(slot),
),
],
if (hasCert) ...[
ActionItem(
key: keys.exportAction,
@ -316,15 +343,6 @@ List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
subtitle: l10n.l_export_certificate_desc,
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) ...[
ActionItem(
key: keys.exportAction,
@ -335,5 +353,33 @@ List<ActionItem> buildSlotActions(PivSlot slot, AppLocalizations l10n) {
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 '../state.dart';
class DeleteCertificateDialog extends ConsumerWidget {
class DeleteCertificateDialog extends ConsumerStatefulWidget {
final DevicePath devicePath;
final PivState pivState;
final PivSlot pivSlot;
const DeleteCertificateDialog(this.devicePath, this.pivSlot, {super.key});
const DeleteCertificateDialog(this.devicePath, this.pivState, this.pivSlot,
{super.key});
@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 canDeleteCertificate = widget.pivSlot.certInfo != null;
final canDeleteKey = widget.pivSlot.metadata != null &&
widget.pivState.version.isAtLeast(5, 7);
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: [
TextButton(
key: keys.deleteButton,
onPressed: () async {
onPressed: _deleteKey || _deleteCertificate
? () async {
try {
await ref
.read(pivSlotsProvider(devicePath).notifier)
.delete(pivSlot.slot);
.read(pivSlotsProvider(widget.devicePath).notifier)
.delete(widget.pivSlot.slot, _deleteCertificate,
_deleteKey);
await ref.read(withContextProvider)(
(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);
showMessage(context, l10n.l_certificate_deleted);
showMessage(context, message);
},
);
} on CancellationException catch (_) {
// ignored
}
},
}
: null,
child: Text(l10n.s_delete),
),
],
@ -63,9 +104,55 @@ class DeleteCertificateDialog extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.p_warning_delete_certificate),
Text(l10n.q_delete_certificate_confirm(
pivSlot.slot.getDisplayName(l10n))),
if (_deleteCertificate || _deleteKey) ...[
Text(
_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(
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
? pivSlots?.value.firstWhere((e) => e.slot == _selected)
: 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 textTheme = theme.textTheme;
// This is what ListTile uses for subtitle
@ -150,7 +160,8 @@ class _PivScreenState extends ConsumerState<PivScreen> {
ActionListSection.fromMenuActions(
context,
l10n.s_actions,
actions: buildSlotActions(selected, l10n),
actions:
buildSlotActions(pivState, selected, l10n),
),
],
)
@ -183,14 +194,22 @@ class _PivScreenState extends ConsumerState<PivScreen> {
},
child: Column(
children: [
if (pivSlots?.hasValue == true)
...pivSlots!.value.map(
...normalSlots.map(
(e) => _CertificateListItem(
pivState,
e,
expanded: expanded,
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 {
final PivState pivState;
final PivSlot pivSlot;
final bool expanded;
final bool selected;
const _CertificateListItem(this.pivSlot,
const _CertificateListItem(this.pivState, this.pivSlot,
{required this.expanded, required this.selected});
@override
@ -226,7 +246,7 @@ class _CertificateListItem extends ConsumerWidget {
leading: CircleAvatar(
foregroundColor: colorScheme.onSecondary,
backgroundColor: colorScheme.secondary,
child: const Icon(Symbols.badge),
child: Icon(slot.isRetired ? Symbols.manage_history : Symbols.badge),
),
title: slot.getDisplayName(l10n),
subtitle: certInfo != null
@ -245,7 +265,7 @@ class _CertificateListItem extends ConsumerWidget {
tapIntent: isDesktop && !expanded ? null : OpenIntent(pivSlot),
doubleTapIntent: isDesktop && !expanded ? OpenIntent(pivSlot) : null,
buildPopupActions: hasFeature(features.slots)
? (context) => buildSlotActions(pivSlot, l10n)
? (context) => buildSlotActions(pivState, pivSlot, l10n)
: null,
);
}
@ -255,12 +275,52 @@ class _CertificateListItem extends ConsumerWidget {
SlotId.signature => meatballButton9c,
SlotId.keyManagement => meatballButton9d,
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) {
SlotId.authentication => appListItem9a,
SlotId.signature => appListItem9c,
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(
context,
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 bool selected;
final bool? disableHover;
final BoxConstraints? menuConstraints;
const ChoiceFilterChip({
super.key,
required this.value,
@ -40,6 +41,7 @@ class ChoiceFilterChip<T> extends StatefulWidget {
this.selected = false,
this.disableHover,
this.labelBuilder,
this.menuConstraints,
});
@override
@ -63,6 +65,7 @@ class _ChoiceFilterChipState<T> extends State<ChoiceFilterChip<T>> {
Offset.zero & overlay.size,
);
return await showMenu(
constraints: widget.menuConstraints,
context: context,
position: position,
shape: const RoundedRectangleBorder(

View File

@ -815,6 +815,14 @@ packages:
description: flutter
source: sdk
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:
dependency: transitive
description:

View File

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