Merge branch 'main' into patryk/product-images

This commit is contained in:
Adam Velebil 2022-06-01 14:00:07 +02:00
commit 8303cc376b
No known key found for this signature in database
GPG Key ID: AC6D6B9D715FC084
55 changed files with 1124 additions and 632 deletions

View File

@ -35,6 +35,12 @@ jobs:
with:
path: 'app'
- name: Check app versions
run: |
python set-version.py
git diff --exit-code
working-directory: ./app
- name: Run flutter tests
run: |
flutter test
@ -50,6 +56,10 @@ jobs:
YUBIOATH_STORE_PASSWORD: ${{ secrets.YUBIOATH_STORE_PASSWORD }}
working-directory: ./app
- name: Check generated files
run: git diff --exit-code
working-directory: ./app
- name: Run android tests
run: |
./gradlew test

View File

@ -18,6 +18,11 @@ jobs:
with:
python-version: '3.9'
- name: Check app versions
run: |
python set-version.py
git diff --exit-code
- name: Install dependencies
run: |
sudo apt-get update

View File

@ -15,6 +15,11 @@ jobs:
with:
python-version: '3.9'
- name: Check app versions
run: |
python set-version.py
git diff --exit-code
- name: Install dependencies
run: |
brew update

View File

@ -15,6 +15,11 @@ jobs:
with:
python-version: '3.9'
- name: Check app versions
run: |
python set-version.py
git diff --exit-code
- name: Install dependencies
run: |
choco install swig

View File

@ -23,9 +23,6 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
def authenticatorVersionCode = 59900
def authenticatorVersionName = '6.0.0-alpha.2'
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
@ -52,8 +49,8 @@ android {
applicationId "com.yubico.yubioath"
minSdkVersion project.minSdkVersion
targetSdkVersion project.targetSdkVersion
versionCode authenticatorVersionCode
versionName authenticatorVersionName
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
assets/graphics/no-permission.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -26,7 +26,14 @@
# POSSIBILITY OF SUCH DAMAGE.
from .base import RpcNode, child, action, NoSuchNodeException, ChildResetException
from .base import (
RpcNode,
child,
action,
RpcException,
NoSuchNodeException,
ChildResetException,
)
from .oath import OathNode
from .fido import Ctap2Node
from .yubiotp import YubiOtpNode
@ -47,6 +54,7 @@ from yubikit.logging import LOG_LEVEL
from ykman.pcsc import list_devices, YK_READER_NAME
from smartcard.Exceptions import SmartcardException
from smartcard.pcsc.PCSCExceptions import EstablishContextException
from hashlib import sha256
from dataclasses import asdict
from typing import Mapping, Tuple
@ -65,6 +73,15 @@ def _is_admin():
return os.getuid() == 0
class ConnectionException(RpcException):
def __init__(self, connection, exc_type):
super().__init__(
"connection-error",
f"Error connecting to {connection} interface",
dict(connection=connection, exc_type=type(exc_type).__name__),
)
class RootNode(RpcNode):
def __init__(self):
super().__init__()
@ -127,9 +144,16 @@ class ReadersNode(RpcNode):
return self.list_children()
def list_children(self):
devices = [
d for d in list_devices("") if YK_READER_NAME not in d.reader.name.lower()
]
try:
devices = [
d
for d in list_devices("")
if YK_READER_NAME not in d.reader.name.lower()
]
except EstablishContextException:
logger.warning("Unable to list readers", exc_info=True)
return {}
state = {d.reader.name for d in devices}
if self._state != state:
self._readers = {}
@ -271,15 +295,27 @@ class UsbDeviceNode(AbstractDeviceNode):
@child(condition=lambda self: self._supports_connection(SmartCardConnection))
def ccid(self):
return self._create_connection(SmartCardConnection)
try:
return self._create_connection(SmartCardConnection)
except (ValueError, SmartcardException) as e:
logger.warning("Error opening connection", exc_info=True)
raise ConnectionException("ccid", e)
@child(condition=lambda self: self._supports_connection(OtpConnection))
def otp(self):
return self._create_connection(OtpConnection)
try:
return self._create_connection(OtpConnection)
except (ValueError, OSError) as e:
logger.warning("Error opening connection", exc_info=True)
raise ConnectionException("otp", e)
@child(condition=lambda self: self._supports_connection(FidoConnection))
def fido(self):
return self._create_connection(FidoConnection)
try:
return self._create_connection(FidoConnection)
except (ValueError, OSError) as e:
logger.warning("Error opening connection", exc_info=True)
raise ConnectionException("fido", e)
class ReaderDeviceNode(AbstractDeviceNode):

View File

@ -6,8 +6,8 @@ VSVersionInfo(
ffi=FixedFileInfo(
# filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4)
# Set not needed items to zero 0.
filevers=(4, 1, 0, 0),
prodvers=(4, 1, 0, 0),
filevers=(6, 0, 0, 0),
prodvers=(6, 0, 0, 0),
# Contains a bitmask that specifies the valid bits 'flags'r
mask=0x3f,
# Contains a bitmask that specifies the Boolean attributes of the file.

View File

@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:yubico_authenticator/app/state.dart';
import 'version.dart';
import 'app/logging.dart';
import 'app/message.dart';
import 'core/state.dart';
@ -26,8 +27,7 @@ class AboutPage extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// TODO: Store the version number elsewhere
const Text('Yubico Authenticator: 6.0.0-alpha.2'),
const Text('Yubico Authenticator: $version'),
if (isDesktop)
Text('ykman version: ${ref.watch(rpcStateProvider).version}'),
Text('Dart version: ${Platform.version}'),
@ -41,7 +41,8 @@ class AboutPage extends ConsumerWidget {
_log.info('Running diagnostics...');
final response =
await ref.read(rpcProvider).command('diagnose', []);
final data = response['diagnostics'];
final data = response['diagnostics'] as List;
data.insert(0, {'app_version': version});
final text = const JsonEncoder.withIndent(' ').convert(data);
await Clipboard.setData(ClipboardData(text: text));
await ref.read(withContextProvider)(
@ -87,7 +88,7 @@ class LoggingPanel extends ConsumerWidget {
OutlinedButton(
child: const Text('Copy log'),
onPressed: () async {
_log.info('Copying log to clipboard...');
_log.info('Copying log to clipboard ($version)...');
final logs = await ref.read(logLevelProvider.notifier).getLogs();
await Clipboard.setData(ClipboardData(text: logs.join('\n')));
await ref.read(withContextProvider)(

View File

@ -185,19 +185,19 @@ class _QrScannerViewState extends State<QrScannerView> {
Text('Looking for a code...',
style: Theme.of(context)
.textTheme
.headline6
.titleLarge
?.copyWith(color: Colors.black)),
if (_status == _ScanStatus.success)
Text('Found a valid code',
style: Theme.of(context)
.textTheme
.headline6
.titleLarge
?.copyWith(color: Colors.white)),
if (_status == _ScanStatus.error)
Text('This code is not valid, try again.',
style: Theme.of(context)
.textTheme
.headline6
.titleLarge
?.copyWith(color: Colors.white)),
]),
Row(

View File

@ -0,0 +1,83 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../desktop/models.dart';
import '../../desktop/state.dart';
import '../../theme.dart';
import '../message.dart';
import 'graphics.dart';
import 'message_page.dart';
class AppFailurePage extends ConsumerWidget {
final Widget? title;
final Object cause;
const AppFailurePage({this.title, required this.cause, super.key}) : super();
@override
Widget build(BuildContext context, WidgetRef ref) {
final reason = cause;
Widget? graphic = const Icon(Icons.error);
String? header = 'An error has occured';
String? message = reason.toString();
List<Widget> actions = [];
if (reason is RpcError) {
if (reason.status == 'connection-error') {
switch (reason.body['connection']) {
case 'ccid':
header = 'Failed to open smart card connection';
if (Platform.isMacOS) {
message = 'Try to remove and re-insert your YubiKey.';
} else if (Platform.isLinux) {
message = 'Make sure pcscd is running.';
} else {
message = 'Make sure your smart card service is functioning.';
}
break;
case 'fido':
if (Platform.isWindows &&
!ref.watch(rpcStateProvider.select((state) => state.isAdmin))) {
graphic = noPermission;
header = null;
message = 'WebAuthn management requires elevated privileges.';
actions = [
OutlinedButton.icon(
label: const Text('Unlock'),
icon: const Icon(Icons.lock_open),
style: AppTheme.primaryOutlinedButtonStyle(context),
onPressed: () async {
final controller = showMessage(
context, 'Elevating permissions...',
duration: const Duration(seconds: 30));
try {
if (await ref.read(rpcProvider).elevate()) {
ref.refresh(rpcProvider);
} else {
showMessage(context, 'Permission denied');
}
} finally {
controller.close();
}
}),
];
}
break;
default:
header = 'Failed to open connection';
message = 'Try to remove and re-insert your YubiKey.';
}
}
}
return MessagePage(
title: title,
graphic: graphic,
header: header,
message: message,
actions: actions,
);
}
}

View File

@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
class AppFailureScreen extends StatelessWidget {
final String reason;
const AppFailureScreen(this.reason, {super.key}) : super();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
reason,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@ -8,14 +8,15 @@ class AppPage extends ConsumerWidget {
final Key _scaffoldKey = GlobalKey();
final Widget? title;
final Widget child;
final Widget? floatingActionButton;
final List<Widget> actions;
final bool centered;
AppPage(
{super.key,
this.title,
required this.child,
this.floatingActionButton,
this.centered = false});
AppPage({
super.key,
this.title,
required this.child,
this.actions = const [],
this.centered = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
@ -29,7 +30,7 @@ class AppPage extends ConsumerWidget {
body: Row(
children: [
const SizedBox(
width: 240,
width: 280,
child: ListTileTheme(
style: ListTileStyle.drawer,
child: MainPageDrawer(shouldPop: false)),
@ -46,11 +47,26 @@ class AppPage extends ConsumerWidget {
Widget _buildScrollView() => SafeArea(
child: SingleChildScrollView(
// Make sure FAB doesn't block content
padding: floatingActionButton != null
? const EdgeInsets.only(bottom: 72)
: null,
child: child,
child: Builder(builder: (context) {
return Column(
children: [
child,
if (actions.isNotEmpty)
Align(
alignment:
centered ? Alignment.center : Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: actions,
),
),
),
],
);
}),
),
);
@ -58,13 +74,14 @@ class AppPage extends ConsumerWidget {
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
titleSpacing: 8,
title: title,
centerTitle: true,
titleTextStyle: Theme.of(context).textTheme.titleLarge,
actions: const [DeviceButton()],
),
drawer: hasDrawer ? const MainPageDrawer() : null,
body: centered ? Center(child: _buildScrollView()) : _buildScrollView(),
floatingActionButton: floatingActionButton,
);
}
}

View File

@ -46,7 +46,7 @@ class DeviceAvatar extends StatelessWidget {
CircleAvatar(
radius: 22,
backgroundColor: selected
? Theme.of(context).colorScheme.secondary
? Theme.of(context).colorScheme.primary
: Colors.transparent,
child: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.background,

View File

@ -33,6 +33,7 @@ class DeviceButton extends ConsumerWidget {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
tooltip: 'Select YubiKey or device',
icon: OverflowBox(
maxHeight: 44,
maxWidth: 44,

8
lib/app/views/graphics.dart Executable file
View File

@ -0,0 +1,8 @@
import 'package:flutter/material.dart';
final Image noAccounts = _graphic('no-accounts');
final Image noDiscoverable = _graphic('no-discoverable');
final Image noFingerprints = _graphic('no-fingerprints');
final Image noPermission = _graphic('no-permission');
Image _graphic(String name) => Image.asset('assets/graphics/$name.png');

View File

@ -38,17 +38,20 @@ class MainPageDrawer extends ConsumerWidget {
final data = ref.watch(currentDeviceDataProvider);
final currentApp = ref.watch(currentAppProvider);
MediaQuery? mediaQuery =
context.findAncestorWidgetOfExactType<MediaQuery>();
final width = mediaQuery?.data.size.width ?? 400;
return Drawer(
width: width < 357 ? 0.85 * width : null,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(20.0),
bottomRight: Radius.circular(20.0))),
child: ListView(
primary: false, //Prevents conflict with the MainPage scroll view.
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
'Yubico Authenticator',
style: Theme.of(context).textTheme.headline6,
),
),
const SizedBox(height: 24.0),
if (data != null) ...[
// Normal YubiKey Applications
...supportedApps
@ -68,14 +71,6 @@ class MainPageDrawer extends ConsumerWidget {
if (supportedApps.contains(Application.management) &&
Application.management.getAvailability(data) ==
Availability.enabled) ...[
const Divider(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Configuration',
style: Theme.of(context).textTheme.bodyText2,
),
),
DrawerItem(
titleText: 'Toggle applications',
icon: Icon(Application.management._icon),
@ -87,17 +82,10 @@ class MainPageDrawer extends ConsumerWidget {
);
},
),
const Divider(),
],
const Divider(indent: 16.0, endIndent: 28.0),
],
// Non-YubiKey pages
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Application',
style: Theme.of(context).textTheme.bodyText2,
),
),
DrawerItem(
titleText: 'Settings',
icon: const Icon(Icons.settings),
@ -110,7 +98,7 @@ class MainPageDrawer extends ConsumerWidget {
),
DrawerItem(
titleText: 'Help and feedback',
icon: const Icon(Icons.help_outline),
icon: const Icon(Icons.help),
onTap: () {
final nav = Navigator.of(context);
if (shouldPop) nav.pop();
@ -173,17 +161,22 @@ class DrawerItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.only(left: 12.0, right: 12.0),
child: ListTile(
enabled: enabled,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.horizontal(right: Radius.circular(20)),
borderRadius: BorderRadius.all(Radius.circular(30)),
),
dense: true,
minLeadingWidth: 24,
minVerticalPadding: 18,
selected: selected,
selectedColor: Theme.of(context).backgroundColor,
selectedTileColor: Theme.of(context).colorScheme.secondary,
leading: icon,
selectedColor: Theme.of(context).colorScheme.onPrimary,
selectedTileColor: Theme.of(context).colorScheme.primary,
leading: IconTheme.merge(
data: const IconThemeData(size: 24),
child: icon,
),
title: Text(titleText),
onTap: onTap,
),

View File

@ -4,29 +4,40 @@ import 'app_page.dart';
class MessagePage extends StatelessWidget {
final Widget? title;
final String header;
final String message;
final Widget? floatingActionButton;
final Widget? graphic;
final String? header;
final String? message;
final List<Widget> actions;
const MessagePage({
super.key,
this.title,
required this.header,
required this.message,
this.floatingActionButton,
this.graphic,
this.header,
this.message,
this.actions = const [],
});
@override
Widget build(BuildContext context) => AppPage(
title: title,
centered: true,
floatingActionButton: floatingActionButton,
child: Column(
children: [
Text(header, style: Theme.of(context).textTheme.headline6),
const SizedBox(height: 12.0),
Text(message, textAlign: TextAlign.center),
],
actions: actions,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
if (graphic != null) graphic!,
if (header != null)
Text(header!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12.0),
if (message != null) ...[
Text(message!, textAlign: TextAlign.center),
],
],
),
),
);
}

View File

@ -5,25 +5,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models.dart';
import '../../desktop/state.dart';
import '../../theme.dart';
import '../message.dart';
import '../models.dart';
import 'app_page.dart';
import 'device_avatar.dart';
import 'graphics.dart';
import 'message_page.dart';
class NoDeviceScreen extends ConsumerWidget {
final DeviceNode? node;
const NoDeviceScreen(this.node, {super.key});
List<Widget> _buildUsbPid(BuildContext context, WidgetRef ref, UsbPid pid) {
Widget _buildUsbPid(BuildContext context, WidgetRef ref, UsbPid pid) {
if (pid.usbInterfaces == UsbInterface.fido.value) {
if (Platform.isWindows &&
!ref.watch(rpcStateProvider.select((state) => state.isAdmin))) {
return [
const DeviceAvatar(child: Icon(Icons.lock)),
const Text('WebAuthn management requires elevated privileges.'),
OutlinedButton.icon(
icon: const Icon(Icons.lock_open),
return MessagePage(
graphic: noPermission,
message: 'Managing this device requires elevated privileges.',
actions: [
OutlinedButton.icon(
style: AppTheme.primaryOutlinedButtonStyle(context),
label: const Text('Unlock'),
icon: const Icon(Icons.lock_open),
onPressed: () async {
final controller = showMessage(
context, 'Elevating permissions...',
@ -37,43 +41,31 @@ class NoDeviceScreen extends ConsumerWidget {
} finally {
controller.close();
}
}),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList();
},
),
],
);
}
}
return [
const DeviceAvatar(child: Icon(Icons.usb_off)),
const Text(
'This YubiKey cannot be accessed',
textAlign: TextAlign.center,
),
];
return const MessagePage(
graphic: DeviceAvatar(child: Icon(Icons.usb_off)),
message: 'This YubiKey cannot be accessed',
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppPage(
centered: true,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: node?.map(usbYubiKey: (node) {
return _buildUsbPid(context, ref, node.pid);
}, nfcReader: (node) {
return const [
DeviceAvatar(child: Icon(Icons.wifi)),
Text('Place your YubiKey on the NFC reader'),
];
}) ??
const [
DeviceAvatar(child: Icon(Icons.usb)),
Text('Insert your YubiKey'),
],
),
);
return node?.map(usbYubiKey: (node) {
return _buildUsbPid(context, ref, node.pid);
}, nfcReader: (node) {
return const MessagePage(
graphic: DeviceAvatar(child: Icon(Icons.wifi)),
message: 'Place your YubiKey on the NFC reader',
);
}) ??
const MessagePage(
graphic: DeviceAvatar(child: Icon(Icons.usb)),
message: 'Insert your YubiKey',
);
}
}

View File

@ -71,6 +71,16 @@ Future<Widget> initialize(List<String> argv) async {
exe = Uri.file(Platform.resolvedExecutable)
.resolve(relativePath)
.toFilePath();
if (Platform.isMacOS && Platform.version.contains('arm64')) {
// See if there is an arm64 specific helper on arm64 Mac.
final arm64exe = Uri.file(exe)
.resolve('../helper-arm64/authenticator-helper')
.toFilePath();
if (await Directory(arm64exe).exists()) {
exe = arm64exe;
}
}
}
_log.info('Starting Helper subprocess: $exe');

View File

@ -133,26 +133,27 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Step 1/2: Capture fingerprint'),
Card(
child: Column(
children: [
AnimatedBuilder(
Column(
children: [
Padding(
padding: const EdgeInsets.all(36.0),
child: AnimatedBuilder(
animation: _color,
builder: (context, _) {
return Icon(
_fingerprint == null ? Icons.fingerprint : Icons.check,
size: 200.0,
size: 128.0,
color: _color.value,
);
},
),
LinearProgressIndicator(value: progress),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(_getMessage()),
),
],
),
),
LinearProgressIndicator(value: progress),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(_getMessage()),
),
],
),
const Text('Step 2/2: Name fingerprint'),
TextFormField(

View File

@ -1,16 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/views/app_failure_screen.dart';
import '../../app/views/app_failure_page.dart';
import '../../app/views/app_loading_screen.dart';
import '../../app/views/app_page.dart';
import '../../app/views/device_avatar.dart';
import '../../app/views/message_page.dart';
import '../../desktop/state.dart';
import '../../management/models.dart';
import '../state.dart';
import 'locked_page.dart';
@ -51,52 +46,10 @@ class FidoScreen extends ConsumerWidget {
'WebAuthn requires the FIDO2 application to be enabled on your YubiKey',
);
}
if (Platform.isWindows) {
if (!ref
.watch(rpcStateProvider.select((state) => state.isAdmin))) {
return AppPage(
title: const Text('WebAuthn'),
centered: true,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const DeviceAvatar(child: Icon(Icons.lock)),
const Text(
'WebAuthn management requires elevated privileges.',
textAlign: TextAlign.center,
),
OutlinedButton.icon(
icon: const Icon(Icons.lock_open),
label: const Text('Unlock'),
onPressed: () async {
final controller = showMessage(
context, 'Elevating permissions...',
duration: const Duration(seconds: 30));
try {
if (await ref.read(rpcProvider).elevate()) {
ref.refresh(rpcProvider);
} else {
showMessage(context, 'Permission denied');
}
} finally {
controller.close();
}
}),
]
.map((e) => Padding(
padding:
const EdgeInsets.symmetric(vertical: 8.0),
child: e,
))
.toList(),
));
}
}
return AppPage(
return AppFailurePage(
title: const Text('WebAuthn'),
centered: true,
child: AppFailureScreen('$error'),
cause: error,
);
},
data: (fidoState) {

View File

@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart';
import '../../theme.dart';
import '../models.dart';
import '../state.dart';
import 'pin_dialog.dart';
@ -22,23 +24,26 @@ class FidoLockedPage extends ConsumerWidget {
if (state.bioEnroll != null) {
return MessagePage(
title: const Text('WebAuthn'),
graphic: noFingerprints,
header: 'No fingerprints',
message: 'Set a PIN to register fingerprints',
floatingActionButton: _buildFab(context),
message: 'Set a PIN to register fingerprints.',
actions: _buildActions(context),
);
} else {
return MessagePage(
title: const Text('WebAuthn'),
graphic: noDiscoverable,
header: 'No discoverable accounts',
message:
'Optionally set a PIN to protect access to your YubiKey\nRegister as a Security Key on websites',
floatingActionButton: _buildFab(context),
actions: _buildActions(context),
);
}
}
return AppPage(
title: const Text('WebAuthn'),
actions: _buildActions(context),
child: Column(
children: [
const ListTile(title: Text('Unlock')),
@ -48,41 +53,49 @@ class FidoLockedPage extends ConsumerWidget {
);
}
FloatingActionButton _buildFab(BuildContext context) {
return FloatingActionButton.extended(
icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin),
label: const Text('Setup'),
onPressed: () {
showBottomMenu(context, [
if (state.bioEnroll != null)
MenuAction(
text: 'Add fingerprint',
icon: const Icon(Icons.fingerprint),
),
MenuAction(
text: 'Set PIN',
icon: const Icon(Icons.pin_outlined),
action: (context) {
List<Widget> _buildActions(BuildContext context) => [
if (!state.hasPin)
OutlinedButton.icon(
style: AppTheme.primaryOutlinedButtonStyle(context),
label: const Text('Set PIN'),
icon: const Icon(Icons.pin),
onPressed: () {
showDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
},
),
MenuAction(
text: 'Reset FIDO',
icon: const Icon(Icons.delete_outline),
action: (context) {
showDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
]);
},
);
}
OutlinedButton.icon(
label: const Text('Options'),
icon: const Icon(Icons.tune),
onPressed: () {
showBottomMenu(context, [
if (state.hasPin)
MenuAction(
text: 'Change PIN',
icon: const Icon(Icons.pin),
action: (context) {
showDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
},
),
MenuAction(
text: 'Reset FIDO',
icon: const Icon(Icons.delete),
action: (context) {
showDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
]);
},
),
];
}
class _PinEntryForm extends ConsumerStatefulWidget {
@ -149,34 +162,6 @@ class _PinEntryFormState extends ConsumerState<_PinEntryForm> {
onSubmitted: (_) => _submit(),
),
),
Wrap(
spacing: 4.0,
runSpacing: 8.0,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.pin_outlined),
label: const Text('Change PIN'),
onPressed: () {
showDialog(
context: context,
builder: (context) =>
FidoPinDialog(widget._deviceNode.path, widget._state),
);
},
),
OutlinedButton.icon(
icon: const Icon(Icons.delete_outlined),
label: const Text('Reset FIDO'),
onPressed: () {
showDialog(
context: context,
builder: (context) => ResetDialog(widget._deviceNode),
);
},
),
],
),
const SizedBox(height: 16.0),
ListTile(
leading:
noFingerprints ? const Icon(Icons.warning_amber_rounded) : null,

View File

@ -50,7 +50,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
if (hasPin) ...[
Text(
'Current PIN',
style: Theme.of(context).textTheme.headline6,
style: Theme.of(context).textTheme.titleLarge,
),
TextFormField(
initialValue: _currentPin,
@ -70,7 +70,7 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
],
Text(
'New PIN',
style: Theme.of(context).textTheme.headline6,
style: Theme.of(context).textTheme.titleLarge,
),
TextFormField(
initialValue: _newPin,

View File

@ -94,7 +94,7 @@ class _ResetDialogState extends ConsumerState<ResetDialog> {
),
Center(
child: Text(_getMessage(),
style: Theme.of(context).textTheme.headline6),
style: Theme.of(context).textTheme.titleLarge),
),
]
.map((e) => Padding(

View File

@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart';
import '../../theme.dart';
import '../models.dart';
import '../state.dart';
import 'add_fingerprint_dialog.dart';
@ -29,10 +31,23 @@ class FidoUnlockedPage extends ConsumerWidget {
? [
const ListTile(title: Text('Credentials')),
...creds.map((cred) => ListTile(
leading:
const CircleAvatar(child: Icon(Icons.link)),
title: Text(cred.userName),
subtitle: Text(cred.rpId),
leading: CircleAvatar(
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
backgroundColor:
Theme.of(context).colorScheme.primary,
child: const Icon(Icons.person),
),
title: Text(
cred.userName,
softWrap: false,
overflow: TextOverflow.fade,
),
subtitle: Text(
cred.rpId,
softWrap: false,
overflow: TextOverflow.fade,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -45,7 +60,7 @@ class FidoUnlockedPage extends ConsumerWidget {
node.path, cred),
);
},
icon: const Icon(Icons.delete_outlined)),
icon: const Icon(Icons.delete_outline)),
],
),
)),
@ -59,9 +74,18 @@ class FidoUnlockedPage extends ConsumerWidget {
? [
const ListTile(title: Text('Fingerprints')),
...fingerprints.map((fp) => ListTile(
leading: const CircleAvatar(
child: Icon(Icons.fingerprint)),
title: Text(fp.label),
leading: CircleAvatar(
foregroundColor:
Theme.of(context).colorScheme.onSecondary,
backgroundColor:
Theme.of(context).colorScheme.secondary,
child: const Icon(Icons.fingerprint),
),
title: Text(
fp.label,
softWrap: false,
overflow: TextOverflow.fade,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -74,7 +98,7 @@ class FidoUnlockedPage extends ConsumerWidget {
node.path, fp),
);
},
icon: const Icon(Icons.edit)),
icon: const Icon(Icons.edit_outlined)),
IconButton(
onPressed: () {
showDialog(
@ -84,7 +108,7 @@ class FidoUnlockedPage extends ConsumerWidget {
node.path, fp),
);
},
icon: const Icon(Icons.delete_outlined)),
icon: const Icon(Icons.delete_outline)),
],
),
))
@ -97,69 +121,76 @@ class FidoUnlockedPage extends ConsumerWidget {
if (children.isNotEmpty) {
return AppPage(
title: const Text('WebAuthn'),
floatingActionButton: _buildFab(context),
actions: _buildActions(context),
child: Column(
children: children,
),
);
}
if (state.bioEnroll == false) {
if (state.bioEnroll != null) {
return MessagePage(
title: const Text('WebAuthn'),
graphic: noFingerprints,
header: 'No fingerprints',
message: 'Add one or more (up to five) fingerprints',
floatingActionButton: _buildFab(context),
actions: _buildActions(context, fingerprintPrimary: true),
);
}
return MessagePage(
title: const Text('WebAuthn'),
graphic: noDiscoverable,
header: 'No discoverable accounts',
message: 'Register as a Security Key on websites',
floatingActionButton: _buildFab(context),
actions: _buildActions(context),
);
}
FloatingActionButton _buildFab(BuildContext context) {
return FloatingActionButton.extended(
icon: Icon(state.bioEnroll != null ? Icons.fingerprint : Icons.pin),
label: const Text('Setup'),
onPressed: () {
showBottomMenu(context, [
if (state.bioEnroll != null)
MenuAction(
text: 'Add fingerprint',
icon: const Icon(Icons.fingerprint),
action: (context) {
showDialog(
context: context,
builder: (context) => AddFingerprintDialog(node.path),
);
},
),
MenuAction(
text: 'Change PIN',
icon: const Icon(Icons.pin_outlined),
action: (context) {
List<Widget> _buildActions(BuildContext context,
{bool fingerprintPrimary = false}) =>
[
if (state.bioEnroll != null)
OutlinedButton.icon(
style: fingerprintPrimary
? AppTheme.primaryOutlinedButtonStyle(context)
: null,
label: const Text('Add fingerprint'),
icon: const Icon(Icons.fingerprint),
onPressed: () {
showDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
builder: (context) => AddFingerprintDialog(node.path),
);
},
),
MenuAction(
text: 'Delete all data',
icon: const Icon(Icons.delete_outline),
action: (context) {
showDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
]);
},
);
}
OutlinedButton.icon(
label: const Text('Options'),
icon: const Icon(Icons.tune),
onPressed: () {
showBottomMenu(context, [
MenuAction(
text: 'Change PIN',
icon: const Icon(Icons.pin),
action: (context) {
showDialog(
context: context,
builder: (context) => FidoPinDialog(node.path, state),
);
},
),
MenuAction(
text: 'Reset FIDO',
icon: const Icon(Icons.delete),
action: (context) {
showDialog(
context: context,
builder: (context) => ResetDialog(node),
);
},
),
]);
},
),
];
}

View File

@ -5,7 +5,6 @@ import 'package:collection/collection.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../app/views/app_failure_screen.dart';
import '../../app/views/app_loading_screen.dart';
import '../../core/models.dart';
import '../../widgets/responsive_dialog.dart';
@ -27,8 +26,8 @@ class _CapabilityForm extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 4.0,
runSpacing: 8.0,
spacing: 8,
runSpacing: 16,
children: Capability.values
.where((c) => capabilities & c.value != 0)
.map((c) => FilterChip(
@ -85,30 +84,41 @@ class _CapabilitiesForm extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (usbCapabilities != 0)
if (usbCapabilities != 0) ...[
const ListTile(
leading: Icon(Icons.usb),
title: Text('USB applications'),
title: Text('USB'),
contentPadding: EdgeInsets.only(bottom: 8),
horizontalTitleGap: 0,
),
_CapabilityForm(
capabilities: usbCapabilities,
enabled: enabled[Transport.usb] ?? 0,
onChanged: (value) {
onChanged({...enabled, Transport.usb: value});
},
),
if (nfcCapabilities != 0)
_CapabilityForm(
capabilities: usbCapabilities,
enabled: enabled[Transport.usb] ?? 0,
onChanged: (value) {
onChanged({...enabled, Transport.usb: value});
},
),
],
if (nfcCapabilities != 0) ...[
if (usbCapabilities != 0)
const Padding(
padding: EdgeInsets.only(top: 24, bottom: 12),
child: Divider(),
),
const ListTile(
leading: Icon(Icons.wifi),
title: Text('NFC applications'),
title: Text('NFC'),
contentPadding: EdgeInsets.only(bottom: 8),
horizontalTitleGap: 0,
),
_CapabilityForm(
capabilities: nfcCapabilities,
enabled: enabled[Transport.nfc] ?? 0,
onChanged: (value) {
onChanged({...enabled, Transport.nfc: value});
},
),
_CapabilityForm(
capabilities: nfcCapabilities,
enabled: enabled[Transport.nfc] ?? 0,
onChanged: (value) {
onChanged({...enabled, Transport.nfc: value});
},
),
]
],
);
}
@ -126,7 +136,6 @@ class ManagementScreen extends ConsumerStatefulWidget {
class _ManagementScreenState extends ConsumerState<ManagementScreen> {
late Map<Transport, int> _enabled;
late int _interfaces;
bool _canSave = false;
@override
void initState() {
@ -227,46 +236,55 @@ class _ManagementScreenState extends ConsumerState<ManagementScreen> {
Navigator.of(context).popUntil((route) => route.isFirst);
});
var canSave = false;
final child =
ref.watch(managementStateProvider(widget.deviceData.node.path)).when(
loading: () => const AppLoadingScreen(),
error: (error, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
error.toString(),
textAlign: TextAlign.center,
),
],
),
),
data: (info) {
bool hasConfig = info.version.major > 4;
if (hasConfig) {
canSave = !_mapEquals(
_enabled,
info.config.enabledCapabilities,
);
} else {
canSave = _interfaces != 0 &&
_interfaces !=
UsbInterface.forCapabilites(widget.deviceData.info
.config.enabledCapabilities[Transport.usb] ??
0);
}
return Column(
children: [
hasConfig
? _buildCapabilitiesForm(context, ref, info)
: _buildModeForm(context, ref, info),
],
);
},
);
return ResponsiveDialog(
title: const Text('Toggle applications'),
actions: [
TextButton(
onPressed: _canSave ? _submitForm : null,
onPressed: canSave ? _submitForm : null,
child: const Text('Save'),
),
],
child:
ref.watch(managementStateProvider(widget.deviceData.node.path)).when(
loading: () => const AppLoadingScreen(),
error: (error, _) => AppFailureScreen('$error'),
data: (info) {
bool hasConfig = info.version.major > 4;
setState(() {
if (hasConfig) {
_canSave = !_mapEquals(
_enabled,
info.config.enabledCapabilities,
);
} else {
_canSave = _interfaces != 0 &&
_interfaces !=
UsbInterface.forCapabilites(widget
.deviceData
.info
.config
.enabledCapabilities[Transport.usb] ??
0);
}
});
return Column(
children: [
hasConfig
? _buildCapabilitiesForm(context, ref, info)
: _buildModeForm(context, ref, info),
],
);
},
),
child: child,
);
}
}

View File

@ -3,7 +3,9 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../widgets/dialog_frame.dart';
import '../models.dart';
@ -44,17 +46,38 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
return deleted;
}
Pair<Color?, Color?> _getColors(BuildContext context, MenuAction action) {
final theme =
ButtonTheme.of(context).colorScheme ?? Theme.of(context).colorScheme;
return action.text.startsWith('Copy')
? Pair(theme.primary, theme.onPrimary)
: (action.text.startsWith('Delete')
? Pair(theme.error, theme.onError)
: Pair(theme.secondary, theme.onSecondary));
}
List<Widget> _buildActions(BuildContext context, WidgetRef ref) {
return buildActions(context, ref).map((e) {
final action = e.action;
return IconButton(
icon: e.icon,
tooltip: e.text,
onPressed: action != null
? () {
action(context);
}
: null,
final colors = _getColors(context, e);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: CircleAvatar(
// TODO: Hardcoded color
backgroundColor: action != null ? colors.first : Colors.grey.shade900,
foregroundColor: colors.second,
child: IconButton(
icon: e.icon,
iconSize: 22,
tooltip: e.text,
disabledColor: Colors.white70,
onPressed: action != null
? () {
action(context);
}
: null,
),
),
);
}).toList();
}
@ -69,20 +92,62 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
}
return DialogFrame(
child: AlertDialog(
title: Text(title),
contentPadding: const EdgeInsets.symmetric(horizontal: 24.0),
actionsAlignment: MainAxisAlignment.center,
actionsPadding: EdgeInsets.zero,
title: Center(
child: Text(
title,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 1,
softWrap: false,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(subtitle ?? ''),
const SizedBox(height: 8.0),
Center(child: FittedBox(child: buildCodeView(ref, big: true))),
if (subtitle != null)
Text(
subtitle!,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
softWrap: false,
),
const SizedBox(height: 12.0),
DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: CardTheme.of(context).color,
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
),
child: Center(
child: FittedBox(
child: DefaultTextStyle.merge(
style: const TextStyle(fontSize: 28),
child: IconTheme(
data: IconTheme.of(context).copyWith(size: 24),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 8.0),
child: buildCodeView(ref),
),
),
),
),
),
),
],
),
actions: [FittedBox(child: Row(children: _buildActions(context, ref)))],
actionsPadding: const EdgeInsets.only(top: 10.0, right: -16.0),
actions: [
Center(
child: FittedBox(
alignment: Alignment.center,
child: Row(children: _buildActions(context, ref)),
),
)
],
),
);
}

View File

@ -94,9 +94,11 @@ class _AccountListState extends ConsumerState<AccountList> {
return Column(
children: [
if (pinnedCreds.isNotEmpty)
const ListTile(
ListTile(
minVerticalPadding: 16,
title: Text(
'Pinned',
'PINNED',
style: Theme.of(context).textTheme.labelSmall,
),
),
...pinnedCreds.map(
@ -106,9 +108,11 @@ class _AccountListState extends ConsumerState<AccountList> {
),
),
if (creds.isNotEmpty)
const ListTile(
ListTile(
minVerticalPadding: 16,
title: Text(
'Accounts',
'ACCOUNTS',
style: Theme.of(context).textTheme.labelSmall,
),
),
...creds.map(

View File

@ -147,6 +147,16 @@ mixin AccountMixin {
final pinned = isPinned(ref);
return [
MenuAction(
text: 'Copy to clipboard',
icon: const Icon(Icons.copy),
action: code == null || expired
? null
: (context) {
Clipboard.setData(ClipboardData(text: code.value));
showMessage(context, 'Code copied to clipboard');
},
),
if (manual)
MenuAction(
text: 'Calculate',
@ -157,27 +167,19 @@ mixin AccountMixin {
}
: null,
),
MenuAction(
text: 'Copy to clipboard',
icon: const Icon(Icons.copy),
action: code == null || expired
? null
: (context) {
Clipboard.setData(ClipboardData(text: code.value));
showMessage(context, 'Code copied to clipboard');
},
),
MenuAction(
text: pinned ? 'Unpin account' : 'Pin account',
//TODO: Replace this with a custom icon.
icon: pinned
? CustomPaint(
painter: _StrikethroughPainter(
Theme.of(context).iconTheme.color ?? Colors.black),
child: ClipPath(
clipper: _StrikethroughClipper(),
child: const Icon(Icons.push_pin_outlined)),
)
? Builder(builder: (context) {
return CustomPaint(
painter: _StrikethroughPainter(
IconTheme.of(context).color ?? Colors.black),
child: ClipPath(
clipper: _StrikethroughClipper(),
child: const Icon(Icons.push_pin)),
);
})
: const Icon(Icons.push_pin_outlined),
action: (context) {
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
@ -194,7 +196,7 @@ mixin AccountMixin {
),
MenuAction(
text: 'Delete account',
icon: const Icon(Icons.delete_outlined),
icon: const Icon(Icons.delete_outline),
action: (context) async {
await deleteCredential(context, ref);
},
@ -203,69 +205,59 @@ mixin AccountMixin {
}
@protected
Widget buildCodeView(WidgetRef ref, {bool big = false}) {
Widget buildCodeView(WidgetRef ref) {
final code = getCode(ref);
final expired = isExpired(code, ref);
return DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
border: Border.all(width: 1.0, color: Colors.grey.shade500),
),
child: AnimatedSize(
alignment: Alignment.centerRight,
duration: const Duration(milliseconds: 100),
child: Padding(
padding: big
? const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0)
: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: code == null
? [
Icon(
credential.oathType == OathType.hotp
? Icons.refresh
: Icons.touch_app,
size: big ? 36 : 18,
),
Text('', style: TextStyle(fontSize: big ? 32.0 : 22.0)),
]
: [
if (credential.oathType == OathType.totp) ...[
...expired
? [
if (credential.touchRequired) ...[
const Icon(Icons.touch_app),
const SizedBox(width: 8.0),
]
]
: [
SizedBox.square(
dimension: big ? 32 : 16,
child: CircleTimer(
code.validFrom * 1000,
code.validTo * 1000,
),
),
return AnimatedSize(
alignment: Alignment.centerRight,
duration: const Duration(milliseconds: 100),
child: Builder(builder: (context) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: code == null
? [
Icon(
credential.oathType == OathType.hotp
? Icons.refresh
: Icons.touch_app,
),
const Text(''),
]
: [
if (credential.oathType == OathType.totp) ...[
...expired
? [
if (credential.touchRequired) ...[
const Icon(Icons.touch_app),
const SizedBox(width: 8.0),
],
],
Opacity(
opacity: expired ? 0.4 : 1.0,
child: Text(
formatCode(code),
style: TextStyle(
fontSize: big ? 32.0 : 22.0,
fontFeatures: const [FontFeature.tabularFigures()],
),
]
]
: [
SizedBox.square(
dimension:
(IconTheme.of(context).size ?? 18) * 0.8,
child: CircleTimer(
code.validFrom * 1000,
code.validTo * 1000,
),
),
const SizedBox(width: 8.0),
],
],
Opacity(
opacity: expired ? 0.4 : 1.0,
child: Text(
formatCode(code),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
//fontWeight: FontWeight.w400,
),
),
],
),
),
),
),
],
);
}),
);
}
}

View File

@ -88,56 +88,80 @@ class AccountView extends ConsumerWidget with AccountMixin {
items: _buildPopupMenu(context, ref),
);
},
child: ListTile(
focusNode: focusNode,
onTap: () {
showDialog(
context: context,
builder: (context) {
return AccountDialog(credential);
},
);
},
onLongPress: () async {
if (calculateReady) {
await calculateCode(
context,
ref,
child: LayoutBuilder(builder: (context, constraints) {
final showAvatar = constraints.maxWidth >= 315;
return ListTile(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
focusNode: focusNode,
onTap: () {
showDialog(
context: context,
builder: (context) {
return AccountDialog(credential);
},
);
}
await ref.read(withContextProvider)(
(context) async {
copyToClipboard(context, ref);
},
);
},
leading: CircleAvatar(
foregroundColor: darkMode ? Colors.black : Colors.white,
backgroundColor: _iconColor(darkMode ? 300 : 400),
child: Text(
(credential.issuer ?? credential.name)
.characters
.first
.toUpperCase(),
style: const TextStyle(fontSize: 18),
},
onLongPress: () async {
if (calculateReady) {
await calculateCode(
context,
ref,
);
}
await ref.read(withContextProvider)(
(context) async {
copyToClipboard(context, ref);
},
);
},
leading: showAvatar
? CircleAvatar(
foregroundColor: darkMode ? Colors.black : Colors.white,
backgroundColor: _iconColor(darkMode ? 300 : 400),
child: Text(
(credential.issuer ?? credential.name)
.characters
.first
.toUpperCase(),
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w300),
),
)
: null,
title: Text(
title,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 1,
softWrap: false,
),
),
title: Text(
title,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
),
subtitle: subtitle != null
? Text(
subtitle!,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
)
: null,
trailing: buildCodeView(ref),
),
subtitle: subtitle != null
? Text(
subtitle!,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
softWrap: false,
)
: null,
trailing: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: CardTheme.of(context).color,
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0),
child: DefaultTextStyle.merge(
style: Theme.of(context).textTheme.bodyLarge,
child: buildCodeView(ref),
),
),
),
);
}),
);
}
}

View File

@ -196,7 +196,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
children: [
Text(
'Account details',
style: Theme.of(context).textTheme.headline6,
style: Theme.of(context).textTheme.titleLarge,
),
TextField(
key: const Key('issuer'),
@ -280,7 +280,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
const Divider(),
Text(
'Options',
style: Theme.of(context).textTheme.headline6,
style: Theme.of(context).textTheme.titleLarge,
),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
@ -297,6 +297,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
},
),
Chip(
backgroundColor: ChipTheme.of(context).selectedColor,
label: DropdownButtonHideUnderline(
child: DropdownButton<OathType>(
value: _oathType,
@ -319,6 +320,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
),
),
Chip(
backgroundColor: ChipTheme.of(context).selectedColor,
label: DropdownButtonHideUnderline(
child: DropdownButton<HashAlgorithm>(
value: _hashAlgorithm,
@ -342,6 +344,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
),
if (_oathType == OathType.totp)
Chip(
backgroundColor: ChipTheme.of(context).selectedColor,
label: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: int.tryParse(_periodController.text) ??
@ -366,6 +369,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
),
),
Chip(
backgroundColor: ChipTheme.of(context).selectedColor,
label: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _digits,

View File

@ -64,7 +64,7 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
if (widget.state.hasKey) ...[
Text(
'Current password',
style: Theme.of(context).textTheme.headline6,
style: Theme.of(context).textTheme.titleLarge,
),
TextField(
autofocus: true,
@ -120,7 +120,7 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
],
Text(
'New password',
style: Theme.of(context).textTheme.headline6,
style: Theme.of(context).textTheme.titleLarge,
),
TextField(
autofocus: !widget.state.hasKey,

View File

@ -6,10 +6,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/views/app_failure_screen.dart';
import '../../app/views/app_failure_page.dart';
import '../../app/views/app_loading_screen.dart';
import '../../app/views/app_page.dart';
import '../../app/views/graphics.dart';
import '../../app/views/message_page.dart';
import '../../theme.dart';
import '../models.dart';
import '../state.dart';
import 'account_list.dart';
@ -29,10 +31,9 @@ class OathScreen extends ConsumerWidget {
centered: true,
child: const AppLoadingScreen(),
),
error: (error, _) => AppPage(
error: (error, _) => AppFailurePage(
title: const Text('Authenticator'),
centered: true,
child: AppFailureScreen('$error'),
cause: error,
),
data: (oathState) => oathState.locked
? _LockedView(devicePath, oathState)
@ -50,12 +51,42 @@ class _LockedView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => AppPage(
title: const Text('Authenticator'),
actions: [
OutlinedButton.icon(
label: const Text('Options'),
icon: const Icon(Icons.tune),
onPressed: () {
showBottomMenu(context, [
MenuAction(
text: 'Manage password',
icon: const Icon(Icons.password),
action: (context) {
showDialog(
context: context,
builder: (context) =>
ManagePasswordDialog(devicePath, oathState),
);
},
),
MenuAction(
text: 'Reset OATH',
icon: const Icon(Icons.delete),
action: (context) {
showDialog(
context: context,
builder: (context) => ResetDialog(devicePath),
);
},
),
]);
},
),
],
child: Column(
children: [
const ListTile(title: Text('Unlock')),
_UnlockForm(
devicePath,
oathState,
keystore: oathState.keystore,
),
],
@ -76,9 +107,9 @@ class _UnlockedView extends ConsumerWidget {
if (isEmpty) {
return MessagePage(
title: const Text('Authenticator'),
graphic: noAccounts,
header: 'No accounts',
message: 'Follow the instructions on a website to add an account',
floatingActionButton: _buildFab(context),
actions: _buildActions(context, true),
);
}
@ -96,6 +127,7 @@ class _UnlockedView extends ConsumerWidget {
return TextFormField(
key: const Key('search_accounts'),
initialValue: ref.read(searchProvider),
style: Theme.of(context).textTheme.titleSmall,
decoration: const InputDecoration(
hintText: 'Search accounts',
border: InputBorder.none,
@ -110,64 +142,64 @@ class _UnlockedView extends ConsumerWidget {
);
}),
),
floatingActionButton: _buildFab(context),
actions: _buildActions(context, false),
child: AccountList(devicePath, oathState),
);
}
FloatingActionButton _buildFab(BuildContext context) {
final fab = FloatingActionButton.extended(
icon: const Icon(Icons.person_add_alt_1),
label: const Text('Setup'),
onPressed: () {
showBottomMenu(context, [
MenuAction(
text: 'Add account',
icon: const Icon(Icons.person_add_alt),
action: (context) {
showDialog(
context: context,
builder: (context) => OathAddAccountPage(
devicePath,
openQrScanner: Platform.isAndroid,
),
);
},
),
MenuAction(
text: oathState.hasKey ? 'Manage password' : 'Set password',
icon: const Icon(Icons.password),
action: (context) {
showDialog(
context: context,
builder: (context) =>
ManagePasswordDialog(devicePath, oathState),
);
},
),
MenuAction(
text: 'Reset OATH',
icon: const Icon(Icons.delete_outline),
action: (context) {
showDialog(
context: context,
builder: (context) => ResetDialog(devicePath),
);
},
),
]);
},
);
return fab;
List<Widget> _buildActions(BuildContext context, bool isEmpty) {
return [
OutlinedButton.icon(
style: isEmpty ? AppTheme.primaryOutlinedButtonStyle(context) : null,
label: const Text('Add account'),
icon: const Icon(Icons.person_add_alt_1),
onPressed: () {
showDialog(
context: context,
builder: (context) => OathAddAccountPage(
devicePath,
openQrScanner: Platform.isAndroid,
),
);
},
),
OutlinedButton.icon(
label: const Text('Options'),
icon: const Icon(Icons.tune),
onPressed: () {
showBottomMenu(context, [
MenuAction(
text: oathState.hasKey ? 'Manage password' : 'Set password',
icon: const Icon(Icons.password),
action: (context) {
showDialog(
context: context,
builder: (context) =>
ManagePasswordDialog(devicePath, oathState),
);
},
),
MenuAction(
text: 'Reset OATH',
icon: const Icon(Icons.delete),
action: (context) {
showDialog(
context: context,
builder: (context) => ResetDialog(devicePath),
);
},
),
]);
},
),
];
}
}
class _UnlockForm extends ConsumerStatefulWidget {
final DevicePath _devicePath;
final OathState _oathState;
final KeystoreState keystore;
const _UnlockForm(this._devicePath, this._oathState,
{required this.keystore});
const _UnlockForm(this._devicePath, {required this.keystore});
@override
ConsumerState<_UnlockForm> createState() => _UnlockFormState();
@ -224,33 +256,6 @@ class _UnlockFormState extends ConsumerState<_UnlockForm> {
onChanged: (_) => setState(() {}), // Update state on change
onSubmitted: (_) => _submit(),
),
Wrap(
spacing: 4.0,
runSpacing: 8.0,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.password),
label: const Text('Manage password'),
onPressed: () {
showDialog(
context: context,
builder: (context) => ManagePasswordDialog(
widget._devicePath, widget._oathState),
);
},
),
OutlinedButton.icon(
icon: const Icon(Icons.delete_outlined),
label: const Text('Reset OATH'),
onPressed: () {
showDialog(
context: context,
builder: (context) => ResetDialog(widget._devicePath),
);
},
),
],
),
],
),
),

View File

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
const primaryGreen = Color(0xffa8c86c);
const primaryGreen = Color(0xffaed581);
const accentGreen = Color(0xff9aca3c);
const primaryBlue = Color(0xff325f74);
const primaryRed = Color(0xffea4335);
class AppTheme {
static ThemeData get lightTheme => ThemeData(
@ -14,26 +15,71 @@ class AppTheme {
secondary: accentGreen,
background: Colors.grey.shade200,
),
backgroundColor: Colors.white,
iconTheme: IconThemeData(
color: Colors.grey.shade400,
size: 18.0,
),
//backgroundColor: Colors.white,
toggleableActiveColor: accentGreen,
appBarTheme: AppBarTheme(
elevation: 0.5,
backgroundColor: Colors.white,
elevation: 0,
toolbarHeight: 48,
//shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
backgroundColor: Colors.transparent,
foregroundColor: Colors.grey.shade800,
),
// Mainly used for the OATH dialog view at the moment
buttonTheme: ButtonThemeData(
colorScheme: ColorScheme.light(
secondary: Colors.grey.shade300,
onSecondary: Colors.grey.shade900,
primary: primaryGreen,
onPrimary: Colors.grey.shade900,
error: primaryRed,
onError: Colors.grey.shade100,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
primary: Colors.grey.shade800,
side: BorderSide(width: 1, color: Colors.grey.shade400),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
)),
cardTheme: CardTheme(
color: Colors.grey.shade300,
),
chipTheme: ChipThemeData(
backgroundColor: Colors.transparent,
selectedColor: const Color(0xffd2dbdf),
side: BorderSide(width: 1, color: Colors.grey.shade400),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: primaryBlue,
),
textTheme: TextTheme(
bodyText1: TextStyle(
color: Colors.grey.shade600,
),
bodyText2: TextStyle(
color: Colors.grey.shade800,
),
headline2: TextStyle(
color: Colors.grey.shade800,
),
fontFamily: 'Roboto',
textTheme: const TextTheme(
//bodySmall: TextStyle(color: Colors.grey.shade500),
//bodyLarge: const TextStyle(color: Colors.white70),
//bodyMedium: TextStyle(color: Colors.grey.shade200),
//labelSmall: TextStyle(color: Colors.grey.shade500),
//labelMedium: TextStyle(color: Colors.cyan.shade200),
//labelLarge: TextStyle(color: Colors.cyan.shade500),
//titleSmall: TextStyle(color: Colors.grey.shade600),
//titleMedium: const TextStyle(),
titleLarge: TextStyle(
//color: Colors.grey.shade500,
fontWeight: FontWeight.w400,
fontSize: 18),
headlineSmall: TextStyle(
//color: Colors.grey.shade200,
fontWeight: FontWeight.w300,
fontSize: 16),
),
);
@ -43,23 +89,85 @@ class AppTheme {
colorScheme:
ColorScheme.fromSwatch(brightness: Brightness.dark).copyWith(
primary: primaryGreen,
secondary: primaryGreen,
onPrimary: Colors.black,
secondary: const Color(0xff5d7d90),
),
iconTheme: const IconThemeData(
color: Colors.white70,
size: 18.0,
),
toggleableActiveColor: primaryGreen,
appBarTheme: AppBarTheme(
elevation: 0,
toolbarHeight: 48,
//shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
backgroundColor: Colors.transparent,
foregroundColor: Colors.grey.shade400,
),
// Mainly used for the OATH dialog view at the moment
buttonTheme: ButtonThemeData(
colorScheme: ColorScheme.dark(
secondary: Colors.grey.shade800,
onSecondary: Colors.white70,
primary: primaryGreen,
onPrimary: Colors.grey.shade900,
error: primaryRed,
onError: Colors.grey.shade100,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
primary: Colors.white70,
side: const BorderSide(width: 1, color: Colors.white12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
)),
cardTheme: CardTheme(
color: Colors.grey.shade800,
),
chipTheme: ChipThemeData(
backgroundColor: Colors.transparent,
selectedColor: Colors.white12,
side: const BorderSide(width: 1, color: Colors.white12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
dialogTheme: const DialogTheme(
backgroundColor: Color(0xff323232),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
foregroundColor: Colors.grey.shade900,
backgroundColor: primaryGreen,
),
fontFamily: 'Roboto',
textTheme: TextTheme(
bodyText1: TextStyle(
color: Colors.grey.shade400,
),
bodyText2: TextStyle(
color: Colors.grey.shade500,
),
headline2: TextStyle(
color: Colors.grey.shade100,
),
bodySmall: TextStyle(color: Colors.grey.shade500),
bodyLarge: const TextStyle(color: Colors.white70),
bodyMedium: TextStyle(color: Colors.grey.shade200),
labelSmall: TextStyle(color: Colors.grey.shade500),
labelMedium: TextStyle(color: Colors.cyan.shade200),
labelLarge: TextStyle(color: Colors.cyan.shade500),
titleSmall: TextStyle(color: Colors.grey.shade600),
titleMedium: const TextStyle(),
titleLarge: TextStyle(
color: Colors.grey.shade500,
fontWeight: FontWeight.w400,
fontSize: 18),
headlineSmall: TextStyle(
color: Colors.grey.shade200,
fontWeight: FontWeight.w300,
fontSize: 16),
),
);
static ButtonStyle primaryOutlinedButtonStyle(BuildContext context) =>
OutlinedButton.styleFrom(
primary: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
side:
BorderSide(width: 1, color: Theme.of(context).colorScheme.primary),
);
}

5
lib/version.dart Executable file
View File

@ -0,0 +1,5 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// This file is generated by running ./set-version.py <version> <build>
const String version = '6.0.0-alpha.3';
const int build = 59900;

View File

@ -59,6 +59,8 @@ class _CircleTimerState extends State<CircleTimer>
@override
Widget build(BuildContext context) {
return ProgressCircle(Colors.grey, _progress.value);
return ProgressCircle(
Theme.of(context).iconTheme.color ?? Colors.grey.shade600,
_progress.value);
}
}

View File

@ -51,7 +51,6 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
: 'Cancel';
return DialogFrame(
child: AlertDialog(
insetPadding: EdgeInsets.zero,
title: widget.title,
scrollable: true,
content: SizedBox(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -15,7 +15,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.0+1
# This field is updated by running ./set-version.py <version>
# DO NOT MANUALLY EDIT THIS!
version: 6.0.0+59900
environment:
sdk: ">=2.17.0 <3.0.0"
@ -85,7 +88,7 @@ flutter:
# - images/a_dot_ham.jpeg
assets:
- assets/product-images/
- assets/graphics/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.
@ -112,3 +115,13 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages
fonts:
- family: Roboto
fonts:
- asset: assets/fonts/Roboto-Regular.ttf
weight: 400
- asset: assets/fonts/Roboto-Light.ttf
weight: 300
- asset: assets/fonts/Roboto-Thin.ttf
weight: 100

131
set-version.py Executable file
View File

@ -0,0 +1,131 @@
#!/usr/bin/env python3
import re
import sys
"""
This script updates version numbers in various files.
"""
version_pattern = r"(\d+)\.(\d+)\.(\d+)(-[^\s+]+)?"
lib_version_pattern = rf"const\s+String\s+version\s+=\s+'({version_pattern})';"
lib_build_pattern = rf"const\s+int\s+build\s+=\s+(\d+);"
def update_file(fname, func):
with open(fname) as f:
orig = f.read()
buf = func(orig)
if buf != orig:
with open(fname, "w") as f:
f.write(buf)
print("Updated", fname)
def read_lib_version():
with open("lib/version.dart") as f:
buf = f.read()
m = re.search(
lib_version_pattern,
buf,
re.MULTILINE,
)
version = m.group(1)
m = re.search(
lib_build_pattern,
buf,
re.MULTILINE,
)
build = int(m.group(1))
return version, build
def update_lib(buf):
buf = re.sub(
lib_version_pattern,
f"const String version = '{version}';",
buf,
)
buf = re.sub(
lib_build_pattern,
f"const int build = {build};",
buf,
)
return buf
# Handle invocation
args = sys.argv[1:]
if not args:
version, build = read_lib_version()
print(f"Using version: {version}, build: {build}...")
elif len(args) == 2:
version = args[0]
if not re.fullmatch(version_pattern, version):
print("Version is not a valid semver string!")
sys.exit(1)
build = int(args[1])
print(f"Setting new version: {version}, build: {build}...")
update_file("lib/version.dart", update_lib)
else:
print("Usage: set-version.py <version> <build>")
sys.exit(1)
# x.y.z without trailing part
short_version = re.search("(\d+\.\d+\.\d+)", version).group()
# pubspec.yaml
def update_pubspec(buf):
return re.sub(
r'version:\s+\d+\.\d+\.\d+\+\d+',
f'version: {short_version}+{build}',
buf,
)
# Windows Runner.rc
def update_runner_rc(buf):
buf = re.sub(
rf'#define VERSION_AS_STRING "{version_pattern}"',
f'#define VERSION_AS_STRING "{version}"',
buf,
)
version_as_number = short_version.replace(".", ",")
buf = re.sub(
r"#define VERSION_AS_NUMBER \d+,\d+,\d+",
f"#define VERSION_AS_NUMBER {version_as_number}",
buf,
)
return buf
# Helper version_info
def update_helper_version(buf):
version_tuple = repr(tuple(int(d) for d in short_version.split(".")) + (0,))
buf = re.sub(
rf'filevers=\(\d+, \d+, \d+, \d+\)',
f'filevers={version_tuple}',
buf,
)
buf = re.sub(
rf'prodvers=\(\d+, \d+, \d+, \d+\)',
f'prodvers={version_tuple}',
buf,
)
buf = re.sub(
rf"'FileVersion', '{version_pattern}'",
f"'FileVersion', '{version}'",
buf,
)
buf = re.sub(
rf"'ProductVersion', '{version_pattern}'",
f"'ProductVersion', '{version}'",
buf,
)
return buf
update_file("pubspec.yaml", update_pubspec)
update_file("windows/runner/Runner.rc", update_runner_rc)
update_file("helper/version_info.txt", update_helper_version)

View File

@ -63,13 +63,13 @@ IDI_APP_ICON ICON "resources\\app_icon.ico"
#ifdef FLUTTER_BUILD_NUMBER
#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER
#else
#define VERSION_AS_NUMBER 1,0,0
#define VERSION_AS_NUMBER 6,0,0
#endif
#ifdef FLUTTER_BUILD_NAME
#define VERSION_AS_STRING #FLUTTER_BUILD_NAME
#else
#define VERSION_AS_STRING "1.0.0"
#define VERSION_AS_STRING "6.0.0-alpha.3"
#endif
VS_VERSION_INFO VERSIONINFO

View File

@ -110,7 +110,7 @@ bool Win32Window::CreateAndShow(const std::wstring& title,
// Attempt to create a mutex to enforce single instance.
CreateMutex(NULL, TRUE, L"com.yubico.authenticator.mutex");
if (GetLastError() == ERROR_ALREADY_EXISTS) {
HWND handle=FindWindowA(NULL, "Yubico Authenticator");
HWND handle=FindWindow(L"FLUTTER_RUNNER_WIN32_WINDOW", L"Yubico Authenticator");
WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) };
GetWindowPlacement(handle, &place);
switch(place.showCmd) {