mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-22 16:32:01 +03:00
Merge branch 'main' into adamve/fix/android_bitesize
This commit is contained in:
commit
4f866a89ab
2
.github/workflows/android.yml
vendored
2
.github/workflows/android.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version: '3.10.1'
|
flutter-version: '3.10.6'
|
||||||
- run: |
|
- run: |
|
||||||
flutter config
|
flutter config
|
||||||
flutter --version
|
flutter --version
|
||||||
|
2
.github/workflows/check-strings.yml
vendored
2
.github/workflows/check-strings.yml
vendored
@ -7,7 +7,7 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
FLUTTER: '3.10.1'
|
FLUTTER: '3.10.6'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@ -43,7 +43,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version: '3.10.1'
|
flutter-version: '3.10.6'
|
||||||
- run: |
|
- run: |
|
||||||
flutter config
|
flutter config
|
||||||
flutter --version
|
flutter --version
|
||||||
|
12
.github/workflows/linux.yml
vendored
12
.github/workflows/linux.yml
vendored
@ -7,10 +7,10 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
PYVER: '3.11.3'
|
PYVER: '3.11.4'
|
||||||
FLUTTER: '3.10.1'
|
FLUTTER: '3.10.6'
|
||||||
container:
|
container:
|
||||||
image: ubuntu:18.04
|
image: ubuntu:20.04
|
||||||
env:
|
env:
|
||||||
DEBIAN_FRONTEND: noninteractive
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ jobs:
|
|||||||
export PYVER_MINOR=${PYVER%.*}
|
export PYVER_MINOR=${PYVER%.*}
|
||||||
echo "PYVER_MINOR: $PYVER_MINOR"
|
echo "PYVER_MINOR: $PYVER_MINOR"
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -qq software-properties-common libnotify-dev libayatana-appindicator3-dev patchelf
|
apt-get install -qq curl software-properties-common libnotify-dev libayatana-appindicator3-dev patchelf
|
||||||
add-apt-repository -y ppa:git-core/ppa
|
add-apt-repository -y ppa:git-core/ppa
|
||||||
add-apt-repository -y ppa:deadsnakes/ppa
|
add-apt-repository -y ppa:deadsnakes/ppa
|
||||||
apt-get install -qq git python$PYVER_MINOR-dev python$PYVER_MINOR-venv
|
apt-get install -qq git python$PYVER_MINOR-dev python$PYVER_MINOR-venv
|
||||||
@ -61,7 +61,9 @@ jobs:
|
|||||||
apt-get install -qq swig libpcsclite-dev build-essential cmake
|
apt-get install -qq swig libpcsclite-dev build-essential cmake
|
||||||
python -m ensurepip --user
|
python -m ensurepip --user
|
||||||
python -m pip install -U pip pipx
|
python -m pip install -U pip pipx
|
||||||
pipx ensurepath
|
# pipx ensurepath
|
||||||
|
echo "export PATH=$PATH:$HOME/.local/bin" >> ~/.bashrc
|
||||||
|
. ~/.bashrc # Needed to ensure poetry on PATH
|
||||||
pipx install poetry
|
pipx install poetry
|
||||||
|
|
||||||
- name: Build the Helper
|
- name: Build the Helper
|
||||||
|
4
.github/workflows/macos.yml
vendored
4
.github/workflows/macos.yml
vendored
@ -7,7 +7,7 @@ jobs:
|
|||||||
|
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
env:
|
env:
|
||||||
PYVER: '3.11.3'
|
PYVER: '3.11.4'
|
||||||
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@ -49,7 +49,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
architecture: 'x64'
|
architecture: 'x64'
|
||||||
flutter-version: '3.10.1'
|
flutter-version: '3.10.6'
|
||||||
- run: flutter config --enable-macos-desktop
|
- run: flutter config --enable-macos-desktop
|
||||||
- run: flutter --version
|
- run: flutter --version
|
||||||
|
|
||||||
|
4
.github/workflows/windows.yml
vendored
4
.github/workflows/windows.yml
vendored
@ -7,7 +7,7 @@ jobs:
|
|||||||
|
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
env:
|
env:
|
||||||
PYVER: '3.11.3'
|
PYVER: '3.11.4'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@ -45,7 +45,7 @@ jobs:
|
|||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
flutter-version: '3.10.1'
|
flutter-version: '3.10.6'
|
||||||
- run: flutter config --enable-windows-desktop
|
- run: flutter config --enable-windows-desktop
|
||||||
- run: flutter --version
|
- run: flutter --version
|
||||||
|
|
||||||
|
@ -100,11 +100,13 @@ dependencies {
|
|||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
|
||||||
|
|
||||||
implementation "androidx.core:core-ktx:1.10.1"
|
implementation "androidx.core:core-ktx:1.10.1"
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.5.7'
|
implementation 'androidx.fragment:fragment-ktx:1.6.0'
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||||
|
|
||||||
implementation 'com.google.android.material:material:1.9.0'
|
implementation 'com.google.android.material:material:1.9.0'
|
||||||
|
|
||||||
|
implementation 'com.github.tony19:logback-android:3.0.0'
|
||||||
|
|
||||||
// testing dependencies
|
// testing dependencies
|
||||||
testImplementation "junit:junit:$project.junitVersion"
|
testImplementation "junit:junit:$project.junitVersion"
|
||||||
testImplementation "org.mockito:mockito-core:$project.mockitoVersion"
|
testImplementation "org.mockito:mockito-core:$project.mockitoVersion"
|
||||||
|
31
android/app/src/main/assets/logback.xml
Normal file
31
android/app/src/main/assets/logback.xml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<!--
|
||||||
|
~ 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<configuration xmlns="https://tony19.github.io/logback-android/xml"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="https://tony19.github.io/logback-android/xml https://cdn.jsdelivr.net/gh/tony19/logback-android/logback.xsd" >
|
||||||
|
|
||||||
|
<appender name="logcat" class="ch.qos.logback.classic.android.LogcatAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>%msg</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- Write TRACE (and higher-level) messages to logcat -->
|
||||||
|
<root level="TRACE">
|
||||||
|
<appender-ref ref="logcat" />
|
||||||
|
</root>
|
||||||
|
</configuration>
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2022 Yubico.
|
* Copyright (C) 2022-2023 Yubico.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -16,14 +16,18 @@
|
|||||||
|
|
||||||
package com.yubico.authenticator
|
package com.yubico.authenticator
|
||||||
|
|
||||||
import com.yubico.authenticator.logging.Log
|
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, private val appViewModel: MainViewModel) {
|
class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, private val appViewModel: MainViewModel) {
|
||||||
private val channel = MethodChannel(messenger, "android.state.appContext")
|
private val channel = MethodChannel(messenger, "android.state.appContext")
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(AppContext::class.java)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
channel.setHandler(coroutineScope) { method, args ->
|
channel.setHandler(coroutineScope) { method, args ->
|
||||||
when (method) {
|
when (method) {
|
||||||
@ -36,11 +40,7 @@ class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, pri
|
|||||||
private suspend fun setContext(subPageIndex: Int): String {
|
private suspend fun setContext(subPageIndex: Int): String {
|
||||||
val appContext = OperationContext.getByValue(subPageIndex)
|
val appContext = OperationContext.getByValue(subPageIndex)
|
||||||
appViewModel.setAppContext(appContext)
|
appViewModel.setAppContext(appContext)
|
||||||
Log.d(TAG, "App context is now $appContext")
|
logger.debug("App context is now {}", appContext)
|
||||||
return NULL
|
return NULL
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "appContext"
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2022 Yubico.
|
* Copyright (C) 2022-2023 Yubico.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -19,7 +19,8 @@ package com.yubico.authenticator
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
import com.yubico.authenticator.logging.Log
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
class AppPreferences(context: Context) {
|
class AppPreferences(context: Context) {
|
||||||
companion object {
|
companion object {
|
||||||
@ -32,15 +33,15 @@ class AppPreferences(context: Context) {
|
|||||||
|
|
||||||
const val PREF_CLIP_KBD_LAYOUT = "flutter.prefClipKbdLayout"
|
const val PREF_CLIP_KBD_LAYOUT = "flutter.prefClipKbdLayout"
|
||||||
const val DEFAULT_CLIP_KBD_LAYOUT = "US"
|
const val DEFAULT_CLIP_KBD_LAYOUT = "US"
|
||||||
|
|
||||||
const val TAG = "AppPreferences"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(AppPreferences::class.java)
|
||||||
|
|
||||||
private val prefs: SharedPreferences =
|
private val prefs: SharedPreferences =
|
||||||
context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE).also {
|
context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE).also {
|
||||||
Log.d(TAG, "Current app preferences:")
|
logger.debug("Current app preferences:")
|
||||||
it.all.map { preference ->
|
it.all.map { preference ->
|
||||||
Log.d(TAG, "${preference.key}: ${preference.value}")
|
logger.debug("{}: {}", preference.key, preference.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,12 +67,12 @@ class AppPreferences(context: Context) {
|
|||||||
get() = prefs.getBoolean(PREF_USB_OPEN_APP, false)
|
get() = prefs.getBoolean(PREF_USB_OPEN_APP, false)
|
||||||
|
|
||||||
fun registerListener(listener: OnSharedPreferenceChangeListener) {
|
fun registerListener(listener: OnSharedPreferenceChangeListener) {
|
||||||
Log.d(TAG, "registering change listener")
|
logger.debug("registering change listener")
|
||||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterListener(listener: OnSharedPreferenceChangeListener) {
|
fun unregisterListener(listener: OnSharedPreferenceChangeListener) {
|
||||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
Log.d(TAG, "unregistered change listener")
|
logger.debug("unregistered change listener")
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -23,11 +23,12 @@ import android.content.ClipboardManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PersistableBundle
|
import android.os.PersistableBundle
|
||||||
import com.yubico.authenticator.logging.Log
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
object ClipboardUtil {
|
object ClipboardUtil {
|
||||||
|
|
||||||
private const val TAG = "ClipboardUtil"
|
private val logger = LoggerFactory.getLogger(ClipboardUtil::class.java)
|
||||||
|
|
||||||
fun setPrimaryClip(context: Context, toClipboard: String, isSensitive: Boolean) {
|
fun setPrimaryClip(context: Context, toClipboard: String, isSensitive: Boolean) {
|
||||||
try {
|
try {
|
||||||
@ -41,7 +42,7 @@ object ClipboardUtil {
|
|||||||
|
|
||||||
clipboardManager.setPrimaryClip(clipData)
|
clipboardManager.setPrimaryClip(clipData)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to set string to clipboard", e.stackTraceToString())
|
logger.error( "Failed to set string to clipboard", e)
|
||||||
throw UnsupportedOperationException()
|
throw UnsupportedOperationException()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2022 Yubico.
|
* Copyright (C) 2022-2023 Yubico.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -91,8 +91,4 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro
|
|||||||
}
|
}
|
||||||
return NULL
|
return NULL
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "dialogManager"
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -39,7 +39,6 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.yubico.authenticator.logging.FlutterLog
|
import com.yubico.authenticator.logging.FlutterLog
|
||||||
import com.yubico.authenticator.logging.Log
|
|
||||||
import com.yubico.authenticator.oath.AppLinkMethodChannel
|
import com.yubico.authenticator.oath.AppLinkMethodChannel
|
||||||
import com.yubico.authenticator.oath.OathManager
|
import com.yubico.authenticator.oath.OathManager
|
||||||
import com.yubico.authenticator.oath.OathViewModel
|
import com.yubico.authenticator.oath.OathViewModel
|
||||||
@ -48,7 +47,6 @@ import com.yubico.yubikit.android.transport.nfc.NfcConfiguration
|
|||||||
import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable
|
import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable
|
||||||
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
|
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
|
||||||
import com.yubico.yubikit.android.transport.usb.UsbConfiguration
|
import com.yubico.yubikit.android.transport.usb.UsbConfiguration
|
||||||
import com.yubico.yubikit.core.Logger
|
|
||||||
import com.yubico.yubikit.core.YubiKeyDevice
|
import com.yubico.yubikit.core.YubiKeyDevice
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
@ -56,6 +54,7 @@ import io.flutter.plugin.common.BinaryMessenger
|
|||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@ -73,6 +72,8 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
private val qrScannerCameraClosedBR = QRScannerCameraClosedBR()
|
private val qrScannerCameraClosedBR = QRScannerCameraClosedBR()
|
||||||
private val nfcAdapterStateChangeBR = NfcAdapterStateChangedBR()
|
private val nfcAdapterStateChangeBR = NfcAdapterStateChangedBR()
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(MainActivity::class.java)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@ -85,8 +86,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
allowScreenshots(false)
|
allowScreenshots(false)
|
||||||
|
|
||||||
yubikit = YubiKitManager(this)
|
yubikit = YubiKitManager(this)
|
||||||
|
|
||||||
setupYubiKitLogger()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -117,7 +116,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
|
|
||||||
private fun startNfcDiscovery() =
|
private fun startNfcDiscovery() =
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Starting nfc discovery")
|
logger.debug("Starting nfc discovery")
|
||||||
yubikit.startNfcDiscovery(
|
yubikit.startNfcDiscovery(
|
||||||
nfcConfiguration.disableNfcDiscoverySound(appPreferences.silenceNfcSounds),
|
nfcConfiguration.disableNfcDiscoverySound(appPreferences.silenceNfcSounds),
|
||||||
this,
|
this,
|
||||||
@ -131,16 +130,16 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
private fun stopNfcDiscovery() {
|
private fun stopNfcDiscovery() {
|
||||||
if (hasNfc) {
|
if (hasNfc) {
|
||||||
yubikit.stopNfcDiscovery(this)
|
yubikit.stopNfcDiscovery(this)
|
||||||
Log.d(TAG, "Stopped nfc discovery")
|
logger.debug("Stopped nfc discovery")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startUsbDiscovery() {
|
private fun startUsbDiscovery() {
|
||||||
Log.d(TAG, "Starting usb discovery")
|
logger.debug("Starting usb discovery")
|
||||||
val usbConfiguration = UsbConfiguration().handlePermissions(true)
|
val usbConfiguration = UsbConfiguration().handlePermissions(true)
|
||||||
yubikit.startUsbDiscovery(usbConfiguration) { device ->
|
yubikit.startUsbDiscovery(usbConfiguration) { device ->
|
||||||
viewModel.setConnectedYubiKey(device) {
|
viewModel.setConnectedYubiKey(device) {
|
||||||
Log.d(TAG, "YubiKey was disconnected, stopping usb discovery")
|
logger.debug("YubiKey was disconnected, stopping usb discovery")
|
||||||
stopUsbDiscovery()
|
stopUsbDiscovery()
|
||||||
}
|
}
|
||||||
processYubiKey(device)
|
processYubiKey(device)
|
||||||
@ -149,22 +148,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
|
|
||||||
private fun stopUsbDiscovery() {
|
private fun stopUsbDiscovery() {
|
||||||
yubikit.stopUsbDiscovery()
|
yubikit.stopUsbDiscovery()
|
||||||
Log.d(TAG, "Stopped usb discovery")
|
logger.debug("Stopped usb discovery")
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupYubiKitLogger() {
|
|
||||||
Logger.setLogger(object : Logger() {
|
|
||||||
private val TAG = "yubikit"
|
|
||||||
|
|
||||||
override fun logDebug(message: String) {
|
|
||||||
// redirect yubikit debug logs to traffic
|
|
||||||
Log.t(TAG, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun logError(message: String, throwable: Throwable) {
|
|
||||||
Log.e(TAG, message, throwable.message ?: throwable.toString())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("WrongConstant")
|
@SuppressLint("WrongConstant")
|
||||||
@ -229,7 +213,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
startNfcDiscovery()
|
startNfcDiscovery()
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "Error processing YubiKey in AppContextManager", e.toString())
|
logger.error("Error processing YubiKey in AppContextManager", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -279,7 +263,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
try {
|
try {
|
||||||
it.processYubiKey(device)
|
it.processYubiKey(device)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "Error processing YubiKey in AppContextManager", e.toString())
|
logger.error("Error processing YubiKey in AppContextManager", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -335,7 +319,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "MainActivity"
|
|
||||||
const val YUBICO_VENDOR_ID = 4176
|
const val YUBICO_VENDOR_ID = 4176
|
||||||
const val FLAG_SECURE = WindowManager.LayoutParams.FLAG_SECURE
|
const val FLAG_SECURE = WindowManager.LayoutParams.FLAG_SECURE
|
||||||
}
|
}
|
||||||
@ -363,6 +346,9 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class NfcAdapterStateChangedBR : BroadcastReceiver() {
|
class NfcAdapterStateChangedBR : BroadcastReceiver() {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(NfcAdapterStateChangedBR::class.java)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val intentFilter = IntentFilter("android.nfc.action.ADAPTER_STATE_CHANGED")
|
val intentFilter = IntentFilter("android.nfc.action.ADAPTER_STATE_CHANGED")
|
||||||
}
|
}
|
||||||
@ -370,7 +356,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
intent?.let {
|
intent?.let {
|
||||||
val state = it.getIntExtra("android.nfc.extra.ADAPTER_STATE", 0)
|
val state = it.getIntExtra("android.nfc.extra.ADAPTER_STATE", 0)
|
||||||
Log.d(TAG, "NfcAdapter state changed to $state")
|
logger.debug("NfcAdapter state changed to {}", state)
|
||||||
if (state == STATE_ON || state == STATE_TURNING_OFF) {
|
if (state == STATE_ON || state == STATE_TURNING_OFF) {
|
||||||
(context as? MainActivity)?.appMethodChannel?.nfcAdapterStateChanged(state == STATE_ON)
|
(context as? MainActivity)?.appMethodChannel?.nfcAdapterStateChanged(state == STATE_ON)
|
||||||
}
|
}
|
||||||
@ -430,7 +416,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
startActivity(Intent(ACTION_NFC_SETTINGS))
|
startActivity(Intent(ACTION_NFC_SETTINGS))
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
else -> Log.w(TAG, "Unknown app method: ${methodCall.method}")
|
else -> logger.warn("Unknown app method: {}", methodCall.method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -446,10 +432,10 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
private fun allowScreenshots(value: Boolean): Boolean {
|
private fun allowScreenshots(value: Boolean): Boolean {
|
||||||
// Note that FLAG_SECURE is the inverse of allowScreenshots
|
// Note that FLAG_SECURE is the inverse of allowScreenshots
|
||||||
if (value) {
|
if (value) {
|
||||||
Log.d(TAG, "Clearing FLAG_SECURE (allow screenshots)")
|
logger.debug("Clearing FLAG_SECURE (allow screenshots)")
|
||||||
window.clearFlags(FLAG_SECURE)
|
window.clearFlags(FLAG_SECURE)
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Setting FLAG_SECURE (disallow screenshots)")
|
logger.debug("Setting FLAG_SECURE (disallow screenshots)")
|
||||||
window.setFlags(FLAG_SECURE, FLAG_SECURE)
|
window.setFlags(FLAG_SECURE, FLAG_SECURE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,9 +24,12 @@ import android.nfc.Tag
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.yubico.authenticator.logging.Log
|
|
||||||
import com.yubico.authenticator.ndef.KeyboardLayout
|
import com.yubico.authenticator.ndef.KeyboardLayout
|
||||||
import com.yubico.yubikit.core.util.NdefUtils
|
import com.yubico.yubikit.core.util.NdefUtils
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
typealias ResourceId = Int
|
typealias ResourceId = Int
|
||||||
@ -34,6 +37,8 @@ typealias ResourceId = Int
|
|||||||
class NdefActivity : Activity() {
|
class NdefActivity : Activity() {
|
||||||
private lateinit var appPreferences: AppPreferences
|
private lateinit var appPreferences: AppPreferences
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(NdefActivity::class.java)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
appPreferences = AppPreferences(this)
|
appPreferences = AppPreferences(this)
|
||||||
@ -68,10 +73,9 @@ class NdefActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (illegalArgumentException: IllegalArgumentException) {
|
} catch (illegalArgumentException: IllegalArgumentException) {
|
||||||
Log.e(
|
logger.error(
|
||||||
TAG,
|
|
||||||
illegalArgumentException.message ?: "Failure when handling YubiKey OTP",
|
illegalArgumentException.message ?: "Failure when handling YubiKey OTP",
|
||||||
illegalArgumentException.stackTraceToString()
|
illegalArgumentException
|
||||||
)
|
)
|
||||||
showToast(R.string.otp_parse_failure, Toast.LENGTH_LONG)
|
showToast(R.string.otp_parse_failure, Toast.LENGTH_LONG)
|
||||||
} catch (_: UnsupportedOperationException) {
|
} catch (_: UnsupportedOperationException) {
|
||||||
@ -111,10 +115,6 @@ class NdefActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "YubicoAuthenticatorOTPActivity"
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class OtpType {
|
enum class OtpType {
|
||||||
Otp, Password
|
Otp, Password
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2022 Yubico.
|
* Copyright (C) 2022-2023 Yubico.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -16,11 +16,16 @@
|
|||||||
|
|
||||||
package com.yubico.authenticator.logging
|
package com.yubico.authenticator.logging
|
||||||
|
|
||||||
import android.util.Log
|
import ch.qos.logback.classic.Level
|
||||||
import com.yubico.authenticator.BuildConfig
|
import com.yubico.authenticator.BuildConfig
|
||||||
|
|
||||||
|
import org.slf4j.Logger
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
object Log {
|
object Log {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger("com.yubico.authenticator")
|
||||||
|
|
||||||
enum class LogLevel {
|
enum class LogLevel {
|
||||||
TRAFFIC,
|
TRAFFIC,
|
||||||
DEBUG,
|
DEBUG,
|
||||||
@ -42,34 +47,10 @@ object Log {
|
|||||||
LogLevel.INFO
|
LogLevel.INFO
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "yubico-authenticator"
|
init {
|
||||||
|
setLevel(level)
|
||||||
@Suppress("unused")
|
|
||||||
fun t(tag: String, message: String, error: String? = null) {
|
|
||||||
log(LogLevel.TRAFFIC, tag, message, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
fun d(tag: String, message: String, error: String? = null) {
|
|
||||||
log(LogLevel.DEBUG, tag, message, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
fun i(tag: String, message: String, error: String? = null) {
|
|
||||||
log(LogLevel.INFO, tag, message, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
fun w(tag: String, message: String, error: String? = null) {
|
|
||||||
log(LogLevel.WARNING, tag, message, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
fun e(tag: String, message: String, error: String? = null) {
|
|
||||||
log(LogLevel.ERROR, tag, message, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
fun log(level: LogLevel, loggerName: String, message: String, error: String?) {
|
fun log(level: LogLevel, loggerName: String, message: String, error: String?) {
|
||||||
if (level < this.level) {
|
if (level < this.level) {
|
||||||
return
|
return
|
||||||
@ -79,27 +60,33 @@ object Log {
|
|||||||
buffer.removeAt(0)
|
buffer.removeAt(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
val logMessage = "[$loggerName] ${level.name}: $message".also {
|
val logMessage = (if (error == null)
|
||||||
|
"[$loggerName] ${level.name}: $message"
|
||||||
|
else
|
||||||
|
"[$loggerName] ${level.name}: $message (err: $error)"
|
||||||
|
).also {
|
||||||
buffer.add(it)
|
buffer.add(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (level) {
|
when (level) {
|
||||||
LogLevel.TRAFFIC -> Log.v(TAG, logMessage)
|
LogLevel.TRAFFIC -> logger.trace(logMessage)
|
||||||
LogLevel.DEBUG -> Log.d(TAG, logMessage)
|
LogLevel.DEBUG -> logger.debug(logMessage)
|
||||||
LogLevel.INFO -> Log.i(TAG, logMessage)
|
LogLevel.INFO -> logger.info(logMessage)
|
||||||
LogLevel.WARNING -> Log.w(TAG, logMessage)
|
LogLevel.WARNING -> logger.warn(logMessage)
|
||||||
LogLevel.ERROR -> Log.e(TAG, logMessage)
|
LogLevel.ERROR -> logger.error(logMessage)
|
||||||
}
|
|
||||||
|
|
||||||
error?.let {
|
|
||||||
Log.e(TAG, "[$loggerName] ${level.name}(details): $error".also {
|
|
||||||
buffer.add(it)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
fun setLevel(newLevel: LogLevel) {
|
fun setLevel(newLevel: LogLevel) {
|
||||||
level = newLevel
|
level = newLevel
|
||||||
|
|
||||||
|
val root = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as ch.qos.logback.classic.Logger
|
||||||
|
root.level = when (newLevel) {
|
||||||
|
LogLevel.TRAFFIC -> Level.TRACE
|
||||||
|
LogLevel.DEBUG -> Level.DEBUG
|
||||||
|
LogLevel.INFO -> Level.INFO
|
||||||
|
LogLevel.WARNING -> Level.WARN
|
||||||
|
LogLevel.ERROR -> Level.ERROR
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2022 Yubico.
|
* Copyright (C) 2022-2023 Yubico.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -17,25 +17,26 @@
|
|||||||
package com.yubico.authenticator.oath
|
package com.yubico.authenticator.oath
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
||||||
import androidx.annotation.UiThread
|
import androidx.annotation.UiThread
|
||||||
import com.yubico.authenticator.logging.Log
|
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
class AppLinkMethodChannel(messenger: BinaryMessenger) {
|
class AppLinkMethodChannel(messenger: BinaryMessenger) {
|
||||||
private val methodChannel = MethodChannel(messenger, "app.link.methods")
|
private val methodChannel = MethodChannel(messenger, "app.link.methods")
|
||||||
|
private val logger = LoggerFactory.getLogger(AppLinkMethodChannel::class.java)
|
||||||
|
|
||||||
@UiThread
|
@UiThread
|
||||||
fun handleUri(uri: Uri) {
|
fun handleUri(uri: Uri) {
|
||||||
Log.t(TAG, "Handling URI: $uri")
|
logger.trace("Handling URI: {}", uri)
|
||||||
methodChannel.invokeMethod(
|
methodChannel.invokeMethod(
|
||||||
"handleOtpAuthLink",
|
"handleOtpAuthLink",
|
||||||
JSONObject(mapOf("link" to uri.toString())).toString()
|
JSONObject(mapOf("link" to uri.toString())).toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "AppLinkMethodChannel"
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -61,6 +61,7 @@ import io.flutter.plugin.common.BinaryMessenger
|
|||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
@ -77,7 +78,6 @@ class OathManager(
|
|||||||
private val appPreferences: AppPreferences,
|
private val appPreferences: AppPreferences,
|
||||||
) : AppContextManager {
|
) : AppContextManager {
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "OathManager"
|
|
||||||
const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s
|
const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s
|
||||||
val OTP_AID = byteArrayOf(0xa0.toByte(), 0x00, 0x00, 0x05, 0x27, 0x20, 0x01, 0x01)
|
val OTP_AID = byteArrayOf(0xa0.toByte(), 0x00, 0x00, 0x05, 0x27, 0x20, 0x01, 0x01)
|
||||||
}
|
}
|
||||||
@ -98,6 +98,8 @@ class OathManager(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(OathManager::class.java)
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.M)
|
@TargetApi(Build.VERSION_CODES.M)
|
||||||
private fun createKeyStoreProviderM(): KeyProvider = KeyStoreProvider()
|
private fun createKeyStoreProviderM(): KeyProvider = KeyStoreProvider()
|
||||||
|
|
||||||
@ -117,7 +119,7 @@ class OathManager(
|
|||||||
// cancel any pending actions, except for addToAny
|
// cancel any pending actions, except for addToAny
|
||||||
if (!addToAny) {
|
if (!addToAny) {
|
||||||
pendingAction?.let {
|
pendingAction?.let {
|
||||||
Log.d(TAG, "Cancelling pending action/closing nfc dialog.")
|
logger.debug("Cancelling pending action/closing nfc dialog.")
|
||||||
it.invoke(Result.failure(CancellationException()))
|
it.invoke(Result.failure(CancellationException()))
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
dialogManager.closeDialog()
|
dialogManager.closeDialog()
|
||||||
@ -134,7 +136,7 @@ class OathManager(
|
|||||||
if (canInvoke) {
|
if (canInvoke) {
|
||||||
if (appViewModel.connectedYubiKey.value == null) {
|
if (appViewModel.connectedYubiKey.value == null) {
|
||||||
// no USB YubiKey is connected, reset known data on resume
|
// no USB YubiKey is connected, reset known data on resume
|
||||||
Log.d(TAG, "Removing NFC data after resume.")
|
logger.debug("Removing NFC data after resume.")
|
||||||
appViewModel.setDeviceInfo(null)
|
appViewModel.setDeviceInfo(null)
|
||||||
oathViewModel.setSessionState(null)
|
oathViewModel.setSessionState(null)
|
||||||
}
|
}
|
||||||
@ -169,7 +171,7 @@ class OathManager(
|
|||||||
|
|
||||||
refreshJob = coroutineScope.launch {
|
refreshJob = coroutineScope.launch {
|
||||||
val delayMs = earliest - now
|
val delayMs = earliest - now
|
||||||
Log.d(TAG, "Will execute refresh in ${delayMs}ms")
|
logger.debug("Will execute refresh in {}ms", delayMs)
|
||||||
if (delayMs > 0) {
|
if (delayMs > 0) {
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
}
|
}
|
||||||
@ -177,9 +179,9 @@ class OathManager(
|
|||||||
if (currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
if (currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||||
requestRefresh()
|
requestRefresh()
|
||||||
} else {
|
} else {
|
||||||
Log.d(
|
logger.debug(
|
||||||
TAG,
|
"Cannot run credential refresh in current lifecycle state: {}",
|
||||||
"Cannot run credential refresh in current lifecycle state: $currentState"
|
currentState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -257,7 +259,7 @@ class OathManager(
|
|||||||
try {
|
try {
|
||||||
oathViewModel.updateCredentials(calculateOathCodes(session))
|
oathViewModel.updateCredentials(calculateOathCodes(session))
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.e(TAG, "Failed to refresh codes", error.toString())
|
logger.error("Failed to refresh codes", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -296,7 +298,7 @@ class OathManager(
|
|||||||
try {
|
try {
|
||||||
SmartCardProtocol(connection).select(OTP_AID)
|
SmartCardProtocol(connection).select(OTP_AID)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to recognize this OATH device.")
|
logger.error("Failed to recognize this OATH device.")
|
||||||
// we know this is NFC device and it supports OATH
|
// we know this is NFC device and it supports OATH
|
||||||
val oathCapabilities = Capabilities(nfc = 0x20)
|
val oathCapabilities = Capabilities(nfc = 0x20)
|
||||||
appViewModel.setDeviceInfo(
|
appViewModel.setDeviceInfo(
|
||||||
@ -325,25 +327,24 @@ class OathManager(
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d(
|
logger.debug(
|
||||||
TAG,
|
|
||||||
"Successfully read Oath session info (and credentials if unlocked) from connected key"
|
"Successfully read Oath session info (and credentials if unlocked) from connected key"
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// OATH not enabled/supported, try to get DeviceInfo over other USB interfaces
|
// OATH not enabled/supported, try to get DeviceInfo over other USB interfaces
|
||||||
Log.e(TAG, "Failed to connect to CCID", e.toString())
|
logger.error("Failed to connect to CCID", e)
|
||||||
if (device.transport == Transport.USB || e is ApplicationNotAvailableException) {
|
if (device.transport == Transport.USB || e is ApplicationNotAvailableException) {
|
||||||
val deviceInfo = try {
|
val deviceInfo = try {
|
||||||
getDeviceInfo(device)
|
getDeviceInfo(device)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Log.d(TAG, "Device was not recognized")
|
logger.debug("Device was not recognized")
|
||||||
UnknownDevice.copy(isNfc = device.transport == Transport.NFC)
|
UnknownDevice.copy(isNfc = device.transport == Transport.NFC)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "Failure getting device info: ${e.message}")
|
logger.error("Failure getting device info", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Setting device info: $deviceInfo")
|
logger.debug("Setting device info: {}", deviceInfo)
|
||||||
appViewModel.setDeviceInfo(deviceInfo)
|
appViewModel.setDeviceInfo(deviceInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,7 +378,7 @@ class OathManager(
|
|||||||
Code.from(code)
|
Code.from(code)
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "Added cred $credential")
|
logger.debug("Added cred {}", credential)
|
||||||
jsonSerializer.encodeToString(addedCred)
|
jsonSerializer.encodeToString(addedCred)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -431,7 +432,7 @@ class OathManager(
|
|||||||
session.setAccessKey(accessKey)
|
session.setAccessKey(accessKey)
|
||||||
keyManager.addKey(session.deviceId, accessKey, false)
|
keyManager.addKey(session.deviceId, accessKey, false)
|
||||||
oathViewModel.setSessionState(Session(session, false))
|
oathViewModel.setSessionState(Session(session, false))
|
||||||
Log.d(TAG, "Successfully set password")
|
logger.debug("Successfully set password")
|
||||||
NULL
|
NULL
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -443,7 +444,7 @@ class OathManager(
|
|||||||
session.deleteAccessKey()
|
session.deleteAccessKey()
|
||||||
keyManager.removeKey(session.deviceId)
|
keyManager.removeKey(session.deviceId)
|
||||||
oathViewModel.setSessionState(Session(session, false))
|
oathViewModel.setSessionState(Session(session, false))
|
||||||
Log.d(TAG, "Successfully unset password")
|
logger.debug("Successfully unset password")
|
||||||
return@useOathSession NULL
|
return@useOathSession NULL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -452,7 +453,7 @@ class OathManager(
|
|||||||
|
|
||||||
private suspend fun forgetPassword(): String {
|
private suspend fun forgetPassword(): String {
|
||||||
keyManager.clearAll()
|
keyManager.clearAll()
|
||||||
Log.d(TAG, "Cleared all keys.")
|
logger.debug("Cleared all keys.")
|
||||||
oathViewModel.sessionState.value?.let {
|
oathViewModel.sessionState.value?.let {
|
||||||
oathViewModel.setSessionState(
|
oathViewModel.setSessionState(
|
||||||
it.copy(
|
it.copy(
|
||||||
@ -516,7 +517,7 @@ class OathManager(
|
|||||||
oathViewModel.updateCredentials(calculateOathCodes(session))
|
oathViewModel.updateCredentials(calculateOathCodes(session))
|
||||||
} catch (apduException: ApduException) {
|
} catch (apduException: ApduException) {
|
||||||
if (apduException.sw == SW.SECURITY_CONDITION_NOT_SATISFIED) {
|
if (apduException.sw == SW.SECURITY_CONDITION_NOT_SATISFIED) {
|
||||||
Log.d(TAG, "Handled oath credential refresh on locked session.")
|
logger.debug("Handled oath credential refresh on locked session.")
|
||||||
oathViewModel.setSessionState(
|
oathViewModel.setSessionState(
|
||||||
Session(
|
Session(
|
||||||
session,
|
session,
|
||||||
@ -524,10 +525,9 @@ class OathManager(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Log.e(
|
logger.error(
|
||||||
TAG,
|
|
||||||
"Unexpected sw when refreshing oath credentials",
|
"Unexpected sw when refreshing oath credentials",
|
||||||
apduException.message
|
apduException
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -543,7 +543,7 @@ class OathManager(
|
|||||||
Credential(credential, session.deviceId),
|
Credential(credential, session.deviceId),
|
||||||
code
|
code
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Code calculated $code")
|
logger.debug("Code calculated {}", code)
|
||||||
|
|
||||||
jsonSerializer.encodeToString(code)
|
jsonSerializer.encodeToString(code)
|
||||||
}
|
}
|
||||||
@ -679,7 +679,7 @@ class OathManager(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
dialogManager.showDialog(Icon.NFC, "Tap your key", title) {
|
dialogManager.showDialog(Icon.NFC, "Tap your key", title) {
|
||||||
Log.d(TAG, "Cancelled Dialog $title")
|
logger.debug("Cancelled Dialog {}", title)
|
||||||
pendingAction?.invoke(Result.failure(CancellationException()))
|
pendingAction?.invoke(Result.failure(CancellationException()))
|
||||||
pendingAction = null
|
pendingAction = null
|
||||||
}
|
}
|
||||||
|
@ -17,8 +17,6 @@
|
|||||||
package com.yubico.authenticator.yubikit
|
package com.yubico.authenticator.yubikit
|
||||||
|
|
||||||
import com.yubico.authenticator.device.Info
|
import com.yubico.authenticator.device.Info
|
||||||
import com.yubico.authenticator.logging.Log
|
|
||||||
import com.yubico.authenticator.oath.OathManager
|
|
||||||
import com.yubico.authenticator.compatUtil
|
import com.yubico.authenticator.compatUtil
|
||||||
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
|
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
|
||||||
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
|
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
|
||||||
@ -29,22 +27,25 @@ import com.yubico.yubikit.core.smartcard.SmartCardConnection
|
|||||||
import com.yubico.yubikit.management.DeviceInfo
|
import com.yubico.yubikit.management.DeviceInfo
|
||||||
import com.yubico.yubikit.support.DeviceUtil
|
import com.yubico.yubikit.support.DeviceUtil
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
suspend fun getDeviceInfo(device: YubiKeyDevice): Info {
|
suspend fun getDeviceInfo(device: YubiKeyDevice): Info {
|
||||||
val pid = (device as? UsbYubiKeyDevice)?.pid
|
val pid = (device as? UsbYubiKeyDevice)?.pid
|
||||||
|
val logger = LoggerFactory.getLogger("getDeviceInfo")
|
||||||
|
|
||||||
val deviceInfo = runCatching {
|
val deviceInfo = runCatching {
|
||||||
device.withConnection<SmartCardConnection, DeviceInfo> { DeviceUtil.readInfo(it, pid) }
|
device.withConnection<SmartCardConnection, DeviceInfo> { DeviceUtil.readInfo(it, pid) }
|
||||||
}.recoverCatching { t ->
|
}.recoverCatching { t ->
|
||||||
Log.d(OathManager.TAG, "Smart card connection not available: ${t.message}")
|
logger.debug("Smart card connection not available: {}", t.message)
|
||||||
device.withConnection<OtpConnection, DeviceInfo> { DeviceUtil.readInfo(it, pid) }
|
device.withConnection<OtpConnection, DeviceInfo> { DeviceUtil.readInfo(it, pid) }
|
||||||
}.recoverCatching { t ->
|
}.recoverCatching { t ->
|
||||||
Log.d(OathManager.TAG, "OTP connection not available: ${t.message}")
|
logger.debug("OTP connection not available: {}", t.message)
|
||||||
device.withConnection<FidoConnection, DeviceInfo> { DeviceUtil.readInfo(it, pid) }
|
device.withConnection<FidoConnection, DeviceInfo> { DeviceUtil.readInfo(it, pid) }
|
||||||
}.recoverCatching { t ->
|
}.recoverCatching { t ->
|
||||||
Log.d(OathManager.TAG, "FIDO connection not available: ${t.message}")
|
logger.debug("FIDO connection not available: {}", t.message)
|
||||||
return SkyHelper(compatUtil).getDeviceInfo(device)
|
return SkyHelper(compatUtil).getDeviceInfo(device)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
Log.e(OathManager.TAG, "Failed to recognize device: ${it.message}")
|
logger.debug("Failed to recognize device: {}", it.message)
|
||||||
throw it
|
throw it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,9 +24,9 @@ allprojects {
|
|||||||
targetSdkVersion = 33
|
targetSdkVersion = 33
|
||||||
compileSdkVersion = 33
|
compileSdkVersion = 33
|
||||||
|
|
||||||
yubiKitVersion = "2.2.0"
|
yubiKitVersion = "2.3.0"
|
||||||
junitVersion = "4.13.2"
|
junitVersion = "4.13.2"
|
||||||
mockitoVersion = "5.3.1"
|
mockitoVersion = "5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
def camerax_version = "1.2.2"
|
def camerax_version = "1.2.3"
|
||||||
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
||||||
implementation "androidx.camera:camera-view:${camerax_version}"
|
implementation "androidx.camera:camera-view:${camerax_version}"
|
||||||
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
||||||
|
@ -93,7 +93,7 @@ if len(sys.argv) != 2:
|
|||||||
|
|
||||||
|
|
||||||
target = sys.argv[1]
|
target = sys.argv[1]
|
||||||
with open(target) as f:
|
with open(target, encoding='utf-8') as f:
|
||||||
values = json.load(f, object_pairs_hook=check_duplicate_keys)
|
values = json.load(f, object_pairs_hook=check_duplicate_keys)
|
||||||
|
|
||||||
strings = {k: v for k, v in values.items() if not k.startswith("@")}
|
strings = {k: v for k, v in values.items() if not k.startswith("@")}
|
||||||
|
@ -24,6 +24,7 @@ from .oath import OathNode
|
|||||||
from .fido import Ctap2Node
|
from .fido import Ctap2Node
|
||||||
from .yubiotp import YubiOtpNode
|
from .yubiotp import YubiOtpNode
|
||||||
from .management import ManagementNode
|
from .management import ManagementNode
|
||||||
|
from .piv import PivNode
|
||||||
from .qr import scan_qr
|
from .qr import scan_qr
|
||||||
from ykman import __version__ as ykman_version
|
from ykman import __version__ as ykman_version
|
||||||
from ykman.base import PID
|
from ykman.base import PID
|
||||||
@ -391,6 +392,13 @@ class ConnectionNode(RpcNode):
|
|||||||
def oath(self):
|
def oath(self):
|
||||||
return OathNode(self._connection)
|
return OathNode(self._connection)
|
||||||
|
|
||||||
|
@child(
|
||||||
|
condition=lambda self: isinstance(self._connection, SmartCardConnection)
|
||||||
|
and CAPABILITY.PIV in self.capabilities
|
||||||
|
)
|
||||||
|
def piv(self):
|
||||||
|
return PivNode(self._connection)
|
||||||
|
|
||||||
@child(
|
@child(
|
||||||
condition=lambda self: isinstance(self._connection, FidoConnection)
|
condition=lambda self: isinstance(self._connection, FidoConnection)
|
||||||
and CAPABILITY.FIDO2 in self.capabilities
|
and CAPABILITY.FIDO2 in self.capabilities
|
||||||
|
457
helper/helper/piv.py
Normal file
457
helper/helper/piv.py
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
RpcNode,
|
||||||
|
action,
|
||||||
|
child,
|
||||||
|
RpcException,
|
||||||
|
ChildResetException,
|
||||||
|
TimeoutException,
|
||||||
|
AuthRequiredException,
|
||||||
|
)
|
||||||
|
from yubikit.core import NotSupportedError, BadResponseError
|
||||||
|
from yubikit.core.smartcard import ApduError, SW
|
||||||
|
from yubikit.piv import (
|
||||||
|
PivSession,
|
||||||
|
OBJECT_ID,
|
||||||
|
MANAGEMENT_KEY_TYPE,
|
||||||
|
InvalidPinError,
|
||||||
|
SLOT,
|
||||||
|
require_version,
|
||||||
|
KEY_TYPE,
|
||||||
|
PIN_POLICY,
|
||||||
|
TOUCH_POLICY,
|
||||||
|
)
|
||||||
|
from ykman.piv import (
|
||||||
|
get_pivman_data,
|
||||||
|
get_pivman_protected_data,
|
||||||
|
derive_management_key,
|
||||||
|
pivman_set_mgm_key,
|
||||||
|
pivman_change_pin,
|
||||||
|
generate_self_signed_certificate,
|
||||||
|
generate_csr,
|
||||||
|
generate_chuid,
|
||||||
|
)
|
||||||
|
from ykman.util import (
|
||||||
|
parse_certificates,
|
||||||
|
parse_private_key,
|
||||||
|
get_leaf_certificates,
|
||||||
|
InvalidPasswordError,
|
||||||
|
)
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from dataclasses import asdict
|
||||||
|
from enum import Enum, unique
|
||||||
|
from threading import Timer
|
||||||
|
from time import time
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_date_format = "%Y-%m-%d"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPinException(RpcException):
|
||||||
|
def __init__(self, cause):
|
||||||
|
super().__init__(
|
||||||
|
"invalid-pin",
|
||||||
|
"Wrong PIN",
|
||||||
|
dict(attempts_remaining=cause.attempts_remaining),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@unique
|
||||||
|
class GENERATE_TYPE(str, Enum):
|
||||||
|
CSR = "csr"
|
||||||
|
CERTIFICATE = "certificate"
|
||||||
|
|
||||||
|
|
||||||
|
class PivNode(RpcNode):
|
||||||
|
def __init__(self, connection):
|
||||||
|
super().__init__()
|
||||||
|
self.session = PivSession(connection)
|
||||||
|
self._pivman_data = get_pivman_data(self.session)
|
||||||
|
self._authenticated = False
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return super().__call__(*args, **kwargs)
|
||||||
|
except ApduError as e:
|
||||||
|
if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED:
|
||||||
|
raise AuthRequiredException()
|
||||||
|
# TODO: This should probably be in a baseclass of all "AppNodes".
|
||||||
|
raise ChildResetException(f"SW: {e.sw:x}")
|
||||||
|
except InvalidPinError as e:
|
||||||
|
raise InvalidPinException(cause=e)
|
||||||
|
|
||||||
|
def _get_object(self, object_id):
|
||||||
|
try:
|
||||||
|
return self.session.get_object(object_id)
|
||||||
|
except ApduError as e:
|
||||||
|
if e.sw == SW.FILE_NOT_FOUND:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
except BadResponseError:
|
||||||
|
logger.warning(f"Couldn't read data object {object_id}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
try:
|
||||||
|
pin_md = self.session.get_pin_metadata()
|
||||||
|
puk_md = self.session.get_puk_metadata()
|
||||||
|
mgm_md = self.session.get_management_key_metadata()
|
||||||
|
pin_attempts = pin_md.attempts_remaining
|
||||||
|
metadata = dict(
|
||||||
|
pin_metadata=asdict(pin_md),
|
||||||
|
puk_metadata=asdict(puk_md),
|
||||||
|
management_key_metadata=asdict(mgm_md),
|
||||||
|
)
|
||||||
|
except NotSupportedError:
|
||||||
|
pin_attempts = self.session.get_pin_attempts()
|
||||||
|
metadata = None
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
version=self.session.version,
|
||||||
|
authenticated=self._authenticated,
|
||||||
|
derived_key=self._pivman_data.has_derived_key,
|
||||||
|
stored_key=self._pivman_data.has_stored_key,
|
||||||
|
chuid=self._get_object(OBJECT_ID.CHUID),
|
||||||
|
ccc=self._get_object(OBJECT_ID.CAPABILITY),
|
||||||
|
pin_attempts=pin_attempts,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _authenticate(self, key, signal):
|
||||||
|
try:
|
||||||
|
metadata = self.session.get_management_key_metadata()
|
||||||
|
key_type = metadata.key_type
|
||||||
|
if metadata.touch_policy != TOUCH_POLICY.NEVER:
|
||||||
|
signal("touch")
|
||||||
|
timer = None
|
||||||
|
except NotSupportedError:
|
||||||
|
key_type = MANAGEMENT_KEY_TYPE.TDES
|
||||||
|
timer = Timer(0.5, lambda: signal("touch"))
|
||||||
|
timer.start()
|
||||||
|
try:
|
||||||
|
# TODO: Check if this is needed, maybe SW is enough
|
||||||
|
start = time()
|
||||||
|
self.session.authenticate(key_type, key)
|
||||||
|
except ApduError as e:
|
||||||
|
if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED and time() - start > 5:
|
||||||
|
raise TimeoutException()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if timer:
|
||||||
|
timer.cancel()
|
||||||
|
self._authenticated = True
|
||||||
|
|
||||||
|
@action
|
||||||
|
def verify_pin(self, params, event, signal):
|
||||||
|
pin = params.pop("pin")
|
||||||
|
|
||||||
|
self.session.verify_pin(pin)
|
||||||
|
key = None
|
||||||
|
|
||||||
|
if self._pivman_data.has_derived_key:
|
||||||
|
key = derive_management_key(pin, self._pivman_data.salt)
|
||||||
|
elif self._pivman_data.has_stored_key:
|
||||||
|
pivman_prot = get_pivman_protected_data(self.session)
|
||||||
|
key = pivman_prot.key
|
||||||
|
if key:
|
||||||
|
try:
|
||||||
|
self._authenticate(key, signal)
|
||||||
|
except ApduError as e:
|
||||||
|
if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED:
|
||||||
|
pass # Authenticate failed, bad derived key?
|
||||||
|
|
||||||
|
# Ensure verify was the last thing we did
|
||||||
|
self.session.verify_pin(pin)
|
||||||
|
|
||||||
|
return dict(status=True, authenticated=self._authenticated)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def authenticate(self, params, event, signal):
|
||||||
|
key = bytes.fromhex(params.pop("key"))
|
||||||
|
try:
|
||||||
|
self._authenticate(key, signal)
|
||||||
|
return dict(status=True)
|
||||||
|
except ApduError as e:
|
||||||
|
if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED:
|
||||||
|
return dict(status=False)
|
||||||
|
raise
|
||||||
|
|
||||||
|
@action(condition=lambda self: self._authenticated)
|
||||||
|
def set_key(self, params, event, signal):
|
||||||
|
key_type = MANAGEMENT_KEY_TYPE(params.pop("key_type", MANAGEMENT_KEY_TYPE.TDES))
|
||||||
|
key = bytes.fromhex(params.pop("key"))
|
||||||
|
store_key = params.pop("store_key", False)
|
||||||
|
pivman_set_mgm_key(self.session, key, key_type, False, store_key)
|
||||||
|
self._pivman_data = get_pivman_data(self.session)
|
||||||
|
return dict()
|
||||||
|
|
||||||
|
@action
|
||||||
|
def change_pin(self, params, event, signal):
|
||||||
|
old_pin = params.pop("pin")
|
||||||
|
new_pin = params.pop("new_pin")
|
||||||
|
pivman_change_pin(self.session, old_pin, new_pin)
|
||||||
|
return dict()
|
||||||
|
|
||||||
|
@action
|
||||||
|
def change_puk(self, params, event, signal):
|
||||||
|
old_puk = params.pop("puk")
|
||||||
|
new_puk = params.pop("new_puk")
|
||||||
|
self.session.change_puk(old_puk, new_puk)
|
||||||
|
return dict()
|
||||||
|
|
||||||
|
@action
|
||||||
|
def unblock_pin(self, params, event, signal):
|
||||||
|
puk = params.pop("puk")
|
||||||
|
new_pin = params.pop("new_pin")
|
||||||
|
self.session.unblock_pin(puk, new_pin)
|
||||||
|
return dict()
|
||||||
|
|
||||||
|
@action
|
||||||
|
def reset(self, params, event, signal):
|
||||||
|
self.session.reset()
|
||||||
|
self._authenticated = False
|
||||||
|
self._pivman_data = get_pivman_data(self.session)
|
||||||
|
return dict()
|
||||||
|
|
||||||
|
@child
|
||||||
|
def slots(self):
|
||||||
|
return SlotsNode(self.session)
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_for(name):
|
||||||
|
return SLOT(int(name, base=16))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_file(data, password=None):
|
||||||
|
if password:
|
||||||
|
password = password.encode()
|
||||||
|
try:
|
||||||
|
certs = parse_certificates(data, password)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
certs = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
private_key = parse_private_key(data, password)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
private_key = None
|
||||||
|
|
||||||
|
return private_key, certs
|
||||||
|
|
||||||
|
|
||||||
|
class SlotsNode(RpcNode):
|
||||||
|
def __init__(self, session):
|
||||||
|
super().__init__()
|
||||||
|
self.session = session
|
||||||
|
try:
|
||||||
|
require_version(session.version, (5, 3, 0))
|
||||||
|
self._has_metadata = True
|
||||||
|
except NotSupportedError:
|
||||||
|
self._has_metadata = False
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self._slots = {}
|
||||||
|
for slot in set(SLOT) - {SLOT.ATTESTATION}:
|
||||||
|
metadata = None
|
||||||
|
if self._has_metadata:
|
||||||
|
try:
|
||||||
|
metadata = self.session.get_slot_metadata(slot)
|
||||||
|
except (ApduError, BadResponseError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
certificate = self.session.get_certificate(slot)
|
||||||
|
except (ApduError, BadResponseError):
|
||||||
|
# TODO: Differentiate between none and malformed
|
||||||
|
certificate = None
|
||||||
|
self._slots[slot] = (metadata, certificate)
|
||||||
|
if self._child and _slot_for(self._child_name) not in self._slots:
|
||||||
|
self._close_child()
|
||||||
|
|
||||||
|
def list_children(self):
|
||||||
|
return {
|
||||||
|
f"{int(slot):02x}": dict(
|
||||||
|
slot=int(slot),
|
||||||
|
name=slot.name,
|
||||||
|
has_key=metadata is not None if self._has_metadata else None,
|
||||||
|
cert_info=dict(
|
||||||
|
subject=cert.subject.rfc4514_string(),
|
||||||
|
issuer=cert.issuer.rfc4514_string(),
|
||||||
|
serial=hex(cert.serial_number)[2:],
|
||||||
|
not_valid_before=cert.not_valid_before.isoformat(),
|
||||||
|
not_valid_after=cert.not_valid_after.isoformat(),
|
||||||
|
fingerprint=cert.fingerprint(hashes.SHA256()),
|
||||||
|
)
|
||||||
|
if cert
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
for slot, (metadata, cert) in self._slots.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_child(self, name):
|
||||||
|
slot = _slot_for(name)
|
||||||
|
if slot in self._slots:
|
||||||
|
metadata, certificate = self._slots[slot]
|
||||||
|
return SlotNode(self.session, slot, metadata, certificate, self.refresh)
|
||||||
|
return super().create_child(name)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def examine_file(self, params, event, signal):
|
||||||
|
data = bytes.fromhex(params.pop("data"))
|
||||||
|
password = params.pop("password", None)
|
||||||
|
try:
|
||||||
|
private_key, certs = _parse_file(data, password)
|
||||||
|
return dict(
|
||||||
|
status=True,
|
||||||
|
password=password is not None,
|
||||||
|
private_key=bool(private_key),
|
||||||
|
certificates=len(certs),
|
||||||
|
)
|
||||||
|
except InvalidPasswordError:
|
||||||
|
logger.debug("Invalid or missing password", exc_info=True)
|
||||||
|
return dict(status=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SlotNode(RpcNode):
|
||||||
|
def __init__(self, session, slot, metadata, certificate, refresh):
|
||||||
|
super().__init__()
|
||||||
|
self.session = session
|
||||||
|
self.slot = slot
|
||||||
|
self.metadata = metadata
|
||||||
|
self.certificate = certificate
|
||||||
|
self._refresh = refresh
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
return dict(
|
||||||
|
id=f"{int(self.slot):02x}",
|
||||||
|
name=self.slot.name,
|
||||||
|
metadata=asdict(self.metadata) if self.metadata else None,
|
||||||
|
certificate=self.certificate.public_bytes(encoding=Encoding.PEM).decode()
|
||||||
|
if self.certificate
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(condition=lambda self: self.certificate)
|
||||||
|
def delete(self, params, event, signal):
|
||||||
|
self.session.delete_certificate(self.slot)
|
||||||
|
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
|
||||||
|
self._refresh()
|
||||||
|
self.certificate = None
|
||||||
|
return dict()
|
||||||
|
|
||||||
|
@action
|
||||||
|
def import_file(self, params, event, signal):
|
||||||
|
data = bytes.fromhex(params.pop("data"))
|
||||||
|
password = params.pop("password", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
private_key, certs = _parse_file(data, password)
|
||||||
|
except InvalidPasswordError:
|
||||||
|
logger.debug("Invalid or missing password", exc_info=True)
|
||||||
|
raise ValueError("Wrong/Missing password")
|
||||||
|
|
||||||
|
# Exception?
|
||||||
|
if not certs and not private_key:
|
||||||
|
raise ValueError("Failed to parse")
|
||||||
|
|
||||||
|
metadata = None
|
||||||
|
if private_key:
|
||||||
|
pin_policy = PIN_POLICY(params.pop("pin_policy", PIN_POLICY.DEFAULT))
|
||||||
|
touch_policy = TOUCH_POLICY(
|
||||||
|
params.pop("touch_policy", TOUCH_POLICY.DEFAULT)
|
||||||
|
)
|
||||||
|
self.session.put_key(self.slot, private_key, pin_policy, touch_policy)
|
||||||
|
try:
|
||||||
|
metadata = self.session.get_slot_metadata(self.slot)
|
||||||
|
except (ApduError, BadResponseError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if certs:
|
||||||
|
if len(certs) > 1:
|
||||||
|
leafs = get_leaf_certificates(certs)
|
||||||
|
certificate = leafs[0]
|
||||||
|
else:
|
||||||
|
certificate = certs[0]
|
||||||
|
self.session.put_certificate(self.slot, certificate)
|
||||||
|
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
|
||||||
|
self.certificate = certificate
|
||||||
|
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
metadata=asdict(metadata) if metadata else None,
|
||||||
|
public_key=private_key.public_key()
|
||||||
|
.public_bytes(
|
||||||
|
encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo
|
||||||
|
)
|
||||||
|
.decode()
|
||||||
|
if private_key
|
||||||
|
else None,
|
||||||
|
certificate=self.certificate.public_bytes(encoding=Encoding.PEM).decode()
|
||||||
|
if certs
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def generate(self, params, event, signal):
|
||||||
|
key_type = KEY_TYPE(params.pop("key_type"))
|
||||||
|
pin_policy = PIN_POLICY(params.pop("pin_policy", PIN_POLICY.DEFAULT))
|
||||||
|
touch_policy = TOUCH_POLICY(params.pop("touch_policy", TOUCH_POLICY.DEFAULT))
|
||||||
|
subject = params.pop("subject")
|
||||||
|
generate_type = GENERATE_TYPE(params.pop("generate_type", GENERATE_TYPE.CERTIFICATE))
|
||||||
|
public_key = self.session.generate_key(
|
||||||
|
self.slot, key_type, pin_policy, touch_policy
|
||||||
|
)
|
||||||
|
|
||||||
|
if pin_policy != PIN_POLICY.NEVER:
|
||||||
|
# TODO: Check if verified?
|
||||||
|
pin = params.pop("pin")
|
||||||
|
self.session.verify_pin(pin)
|
||||||
|
|
||||||
|
if touch_policy in (TOUCH_POLICY.ALWAYS, TOUCH_POLICY.CACHED):
|
||||||
|
signal("touch")
|
||||||
|
|
||||||
|
if generate_type == GENERATE_TYPE.CSR:
|
||||||
|
result = generate_csr(self.session, self.slot, public_key, subject)
|
||||||
|
elif generate_type == GENERATE_TYPE.CERTIFICATE:
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
then = now + datetime.timedelta(days=365)
|
||||||
|
valid_from = params.pop("valid_from", now.strftime(_date_format))
|
||||||
|
valid_to = params.pop("valid_to", then.strftime(_date_format))
|
||||||
|
result = generate_self_signed_certificate(
|
||||||
|
self.session,
|
||||||
|
self.slot,
|
||||||
|
public_key,
|
||||||
|
subject,
|
||||||
|
datetime.datetime.strptime(valid_from, _date_format),
|
||||||
|
datetime.datetime.strptime(valid_to, _date_format),
|
||||||
|
)
|
||||||
|
self.session.put_certificate(self.slot, result)
|
||||||
|
self.session.put_object(OBJECT_ID.CHUID, generate_chuid())
|
||||||
|
else:
|
||||||
|
raise ValueError("Unsupported GENERATE_TYPE")
|
||||||
|
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
public_key=public_key.public_bytes(
|
||||||
|
encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo
|
||||||
|
).decode(),
|
||||||
|
result=result.public_bytes(encoding=Encoding.PEM).decode(),
|
||||||
|
)
|
306
helper/poetry.lock
generated
306
helper/poetry.lock
generated
@ -1,4 +1,4 @@
|
|||||||
# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 1.4.1 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "altgraph"
|
name = "altgraph"
|
||||||
@ -91,14 +91,14 @@ pycparser = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.1.3"
|
version = "8.1.6"
|
||||||
description = "Composable command line interface toolkit"
|
description = "Composable command line interface toolkit"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
|
{file = "click-8.1.6-py3-none-any.whl", hash = "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5"},
|
||||||
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
|
{file = "click-8.1.6.tar.gz", hash = "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -118,31 +118,35 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "40.0.2"
|
version = "41.0.2"
|
||||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:8f79b5ff5ad9d3218afb1e7e20ea74da5f76943ee5edb7f76e56ec5161ec782b"},
|
{file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:01f1d9e537f9a15b037d5d9ee442b8c22e3ae11ce65ea1f3316a41c78756b711"},
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:05dc219433b14046c476f6f09d7636b92a1c3e5808b9a6536adf4932b3b2c440"},
|
{file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:079347de771f9282fbfe0e0236c716686950c19dee1b76240ab09ce1624d76d7"},
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4df2af28d7bedc84fe45bd49bc35d710aede676e2a4cb7fc6d103a2adc8afe4d"},
|
{file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439c3cc4c0d42fa999b83ded80a9a1fb54d53c58d6e59234cfe97f241e6c781d"},
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dcca15d3a19a66e63662dc8d30f8036b07be851a8680eda92d079868f106288"},
|
{file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14ad275364c8b4e525d018f6716537ae7b6d369c094805cae45300847e0894f"},
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a04386fb7bc85fab9cd51b6308633a3c271e3d0d3eae917eebab2fac6219b6d2"},
|
{file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:84609ade00a6ec59a89729e87a503c6e36af98ddcd566d5f3be52e29ba993182"},
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:adc0d980fd2760c9e5de537c28935cc32b9353baaf28e0814df417619c6c8c3b"},
|
{file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:49c3222bb8f8e800aead2e376cbef687bc9e3cb9b58b29a261210456a7783d83"},
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d5a1bd0e9e2031465761dfa920c16b0065ad77321d8a8c1f5ee331021fda65e9"},
|
{file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d73f419a56d74fef257955f51b18d046f3506270a5fd2ac5febbfa259d6c0fa5"},
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a95f4802d49faa6a674242e25bfeea6fc2acd915b5e5e29ac90a32b1139cae1c"},
|
{file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:2a034bf7d9ca894720f2ec1d8b7b5832d7e363571828037f9e0c4f18c1b58a58"},
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-win32.whl", hash = "sha256:aecbb1592b0188e030cb01f82d12556cf72e218280f621deed7d806afd2113f9"},
|
{file = "cryptography-41.0.2-cp37-abi3-win32.whl", hash = "sha256:d124682c7a23c9764e54ca9ab5b308b14b18eba02722b8659fb238546de83a76"},
|
||||||
{file = "cryptography-40.0.2-cp36-abi3-win_amd64.whl", hash = "sha256:b12794f01d4cacfbd3177b9042198f3af1c856eedd0a98f10f141385c809a14b"},
|
{file = "cryptography-41.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:9c3fe6534d59d071ee82081ca3d71eed3210f76ebd0361798c74abc2bcf347d4"},
|
||||||
{file = "cryptography-40.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:142bae539ef28a1c76794cca7f49729e7c54423f615cfd9b0b1fa90ebe53244b"},
|
{file = "cryptography-41.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a719399b99377b218dac6cf547b6ec54e6ef20207b6165126a280b0ce97e0d2a"},
|
||||||
{file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:956ba8701b4ffe91ba59665ed170a2ebbdc6fc0e40de5f6059195d9f2b33ca0e"},
|
{file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:182be4171f9332b6741ee818ec27daff9fb00349f706629f5cbf417bd50e66fd"},
|
||||||
{file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f01c9863da784558165f5d4d916093737a75203a5c5286fde60e503e4276c7a"},
|
{file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7a9a3bced53b7f09da251685224d6a260c3cb291768f54954e28f03ef14e3766"},
|
||||||
{file = "cryptography-40.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3daf9b114213f8ba460b829a02896789751626a2a4e7a43a28ee77c04b5e4958"},
|
{file = "cryptography-41.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f0dc40e6f7aa37af01aba07277d3d64d5a03dc66d682097541ec4da03cc140ee"},
|
||||||
{file = "cryptography-40.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48f388d0d153350f378c7f7b41497a54ff1513c816bcbbcafe5b829e59b9ce5b"},
|
{file = "cryptography-41.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:674b669d5daa64206c38e507808aae49904c988fa0a71c935e7006a3e1e83831"},
|
||||||
{file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c0764e72b36a3dc065c155e5b22f93df465da9c39af65516fe04ed3c68c92636"},
|
{file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7af244b012711a26196450d34f483357e42aeddb04128885d95a69bd8b14b69b"},
|
||||||
{file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cbaba590180cba88cb99a5f76f90808a624f18b169b90a4abb40c1fd8c19420e"},
|
{file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9b6d717393dbae53d4e52684ef4f022444fc1cce3c48c38cb74fca29e1f08eaa"},
|
||||||
{file = "cryptography-40.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7a38250f433cd41df7fcb763caa3ee9362777fdb4dc642b9a349721d2bf47404"},
|
{file = "cryptography-41.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:192255f539d7a89f2102d07d7375b1e0a81f7478925b3bc2e0549ebf739dae0e"},
|
||||||
{file = "cryptography-40.0.2.tar.gz", hash = "sha256:c33c0d32b8594fa647d2e01dbccc303478e16fdd7cf98652d5b3ed11aa5e5c99"},
|
{file = "cryptography-41.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f772610fe364372de33d76edcd313636a25684edb94cee53fd790195f5989d14"},
|
||||||
|
{file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b332cba64d99a70c1e0836902720887fb4529ea49ea7f5462cf6640e095e11d2"},
|
||||||
|
{file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9a6673c1828db6270b76b22cc696f40cde9043eb90373da5c2f8f2158957f42f"},
|
||||||
|
{file = "cryptography-41.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:342f3767e25876751e14f8459ad85e77e660537ca0a066e10e75df9c9e9099f0"},
|
||||||
|
{file = "cryptography-41.0.2.tar.gz", hash = "sha256:7d230bf856164de164ecb615ccc14c7fc6de6906ddd5b491f3af90d3514c925c"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -151,23 +155,23 @@ cffi = ">=1.12"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
|
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
|
||||||
docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
|
docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
|
||||||
pep8test = ["black", "check-manifest", "mypy", "ruff"]
|
nox = ["nox"]
|
||||||
sdist = ["setuptools-rust (>=0.11.4)"]
|
pep8test = ["black", "check-sdist", "mypy", "ruff"]
|
||||||
|
sdist = ["build"]
|
||||||
ssh = ["bcrypt (>=3.1.5)"]
|
ssh = ["bcrypt (>=3.1.5)"]
|
||||||
test = ["iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist"]
|
test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
|
||||||
test-randomorder = ["pytest-randomly"]
|
test-randomorder = ["pytest-randomly"]
|
||||||
tox = ["tox"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exceptiongroup"
|
name = "exceptiongroup"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
description = "Backport of PEP 654 (exception groups)"
|
description = "Backport of PEP 654 (exception groups)"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"},
|
{file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"},
|
||||||
{file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"},
|
{file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
@ -175,32 +179,32 @@ test = ["pytest (>=6)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fido2"
|
name = "fido2"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
description = "FIDO2/WebAuthn library for implementing clients and servers."
|
description = "FIDO2/WebAuthn library for implementing clients and servers."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7,<4.0"
|
python-versions = ">=3.7,<4.0"
|
||||||
files = [
|
files = [
|
||||||
{file = "fido2-1.1.1-py3-none-any.whl", hash = "sha256:54017b69522b1581e4222443a0b3fff5eb2626f8e773a4a7b955f3e55fb3b4fc"},
|
{file = "fido2-1.1.2-py3-none-any.whl", hash = "sha256:a3b7d7d233dec3a4fa0d6178fc34d1cce17b820005a824f6ab96917a1e3be8d8"},
|
||||||
{file = "fido2-1.1.1.tar.gz", hash = "sha256:5dc495ca8c59c1c337383b4b8c314d46b92d5c6fc650e71984c6d7f954079fc3"},
|
{file = "fido2-1.1.2.tar.gz", hash = "sha256:6110d913106f76199201b32d262b2857562cc46ba1d0b9c51fbce30dc936c573"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
cryptography = ">=2.6,<35 || >35,<43"
|
cryptography = ">=2.6,<35 || >35,<44"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
pcsc = ["pyscard (>=1.9,<3)"]
|
pcsc = ["pyscard (>=1.9,<3)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "importlib-metadata"
|
name = "importlib-metadata"
|
||||||
version = "6.4.1"
|
version = "6.8.0"
|
||||||
description = "Read metadata from Python packages"
|
description = "Read metadata from Python packages"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "importlib_metadata-6.4.1-py3-none-any.whl", hash = "sha256:63ace321e24167d12fbb176b6015f4dbe06868c54a2af4f15849586afb9027fd"},
|
{file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"},
|
||||||
{file = "importlib_metadata-6.4.1.tar.gz", hash = "sha256:eb1a7933041f0f85c94cd130258df3fb0dec060ad8c1c9318892ef4192c47ce1"},
|
{file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -209,26 +213,26 @@ zipp = ">=0.5"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
||||||
perf = ["ipython"]
|
perf = ["ipython"]
|
||||||
testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
|
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "importlib-resources"
|
name = "importlib-resources"
|
||||||
version = "5.12.0"
|
version = "6.0.0"
|
||||||
description = "Read resources from Python packages"
|
description = "Read resources from Python packages"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"},
|
{file = "importlib_resources-6.0.0-py3-none-any.whl", hash = "sha256:d952faee11004c045f785bb5636e8f885bed30dc3c940d5d42798a2a4541c185"},
|
||||||
{file = "importlib_resources-5.12.0.tar.gz", hash = "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6"},
|
{file = "importlib_resources-6.0.0.tar.gz", hash = "sha256:4cf94875a8368bd89531a756df9a9ebe1f150e0f885030b461237bc7f2d905f2"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
|
zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
||||||
testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
|
testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
@ -244,22 +248,22 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jaraco-classes"
|
name = "jaraco-classes"
|
||||||
version = "3.2.3"
|
version = "3.3.0"
|
||||||
description = "Utility functions for Python class constructs"
|
description = "Utility functions for Python class constructs"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "jaraco.classes-3.2.3-py3-none-any.whl", hash = "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158"},
|
{file = "jaraco.classes-3.3.0-py3-none-any.whl", hash = "sha256:10afa92b6743f25c0cf5f37c6bb6e18e2c5bb84a16527ccfc0040ea377e7aaeb"},
|
||||||
{file = "jaraco.classes-3.2.3.tar.gz", hash = "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a"},
|
{file = "jaraco.classes-3.3.0.tar.gz", hash = "sha256:c063dd08e89217cee02c8d5e5ec560f2c8ce6cdc2fcdc2e68f7b2e5547ed3621"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
more-itertools = "*"
|
more-itertools = "*"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
|
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
||||||
testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
|
testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jeepney"
|
name = "jeepney"
|
||||||
@ -319,64 +323,64 @@ altgraph = ">=0.17"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "more-itertools"
|
name = "more-itertools"
|
||||||
version = "9.1.0"
|
version = "10.0.0"
|
||||||
description = "More routines for operating on iterables, beyond itertools"
|
description = "More routines for operating on iterables, beyond itertools"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "more-itertools-9.1.0.tar.gz", hash = "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d"},
|
{file = "more-itertools-10.0.0.tar.gz", hash = "sha256:cd65437d7c4b615ab81c0640c0480bc29a550ea032891977681efd28344d51e1"},
|
||||||
{file = "more_itertools-9.1.0-py3-none-any.whl", hash = "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3"},
|
{file = "more_itertools-10.0.0-py3-none-any.whl", hash = "sha256:928d514ffd22b5b0a8fce326d57f423a55d2ff783b093bab217eda71e732330f"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mss"
|
name = "mss"
|
||||||
version = "8.0.3"
|
version = "9.0.1"
|
||||||
description = "An ultra fast cross-platform multiple screenshots module in pure python using ctypes."
|
description = "An ultra fast cross-platform multiple screenshots module in pure python using ctypes."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "mss-8.0.3-py3-none-any.whl", hash = "sha256:87c1eda213dab83431013ca98ee7217e536439f28446b979bb38d8f7af5c7d34"},
|
{file = "mss-9.0.1-py3-none-any.whl", hash = "sha256:7ee44db7ab14cbea6a3eb63813c57d677a109ca5979d3b76046e4bddd3ca1a0b"},
|
||||||
{file = "mss-8.0.3.tar.gz", hash = "sha256:07dc0602e325434e867621f257a8ec6ea14bdffd00bfa554a69bef554af7f524"},
|
{file = "mss-9.0.1.tar.gz", hash = "sha256:6eb7b9008cf27428811fa33aeb35f3334db81e3f7cc2dd49ec7c6e5a94b39f12"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "1.24.2"
|
version = "1.24.4"
|
||||||
description = "Fundamental package for array computing in Python"
|
description = "Fundamental package for array computing in Python"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "numpy-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eef70b4fc1e872ebddc38cddacc87c19a3709c0e3e5d20bf3954c147b1dd941d"},
|
{file = "numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64"},
|
||||||
{file = "numpy-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8d2859428712785e8a8b7d2b3ef0a1d1565892367b32f915c4a4df44d0e64f5"},
|
{file = "numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1"},
|
||||||
{file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6524630f71631be2dabe0c541e7675db82651eb998496bbe16bc4f77f0772253"},
|
{file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4"},
|
||||||
{file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a51725a815a6188c662fb66fb32077709a9ca38053f0274640293a14fdd22978"},
|
{file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6"},
|
||||||
{file = "numpy-1.24.2-cp310-cp310-win32.whl", hash = "sha256:2620e8592136e073bd12ee4536149380695fbe9ebeae845b81237f986479ffc9"},
|
{file = "numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc"},
|
||||||
{file = "numpy-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:97cf27e51fa078078c649a51d7ade3c92d9e709ba2bfb97493007103c741f1d0"},
|
{file = "numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e"},
|
||||||
{file = "numpy-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7de8fdde0003f4294655aa5d5f0a89c26b9f22c0a58790c38fae1ed392d44a5a"},
|
{file = "numpy-1.24.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f136bab9c2cfd8da131132c2cf6cc27331dd6fae65f95f69dcd4ae3c3639c810"},
|
||||||
{file = "numpy-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4173bde9fa2a005c2c6e2ea8ac1618e2ed2c1c6ec8a7657237854d42094123a0"},
|
{file = "numpy-1.24.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2926dac25b313635e4d6cf4dc4e51c8c0ebfed60b801c799ffc4c32bf3d1254"},
|
||||||
{file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cecaed30dc14123020f77b03601559fff3e6cd0c048f8b5289f4eeabb0eb281"},
|
{file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:222e40d0e2548690405b0b3c7b21d1169117391c2e82c378467ef9ab4c8f0da7"},
|
||||||
{file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a23f8440561a633204a67fb44617ce2a299beecf3295f0d13c495518908e910"},
|
{file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7215847ce88a85ce39baf9e89070cb860c98fdddacbaa6c0da3ffb31b3350bd5"},
|
||||||
{file = "numpy-1.24.2-cp311-cp311-win32.whl", hash = "sha256:e428c4fbfa085f947b536706a2fc349245d7baa8334f0c5723c56a10595f9b95"},
|
{file = "numpy-1.24.4-cp311-cp311-win32.whl", hash = "sha256:4979217d7de511a8d57f4b4b5b2b965f707768440c17cb70fbf254c4b225238d"},
|
||||||
{file = "numpy-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:557d42778a6869c2162deb40ad82612645e21d79e11c1dc62c6e82a2220ffb04"},
|
{file = "numpy-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b7b1fc9864d7d39e28f41d089bfd6353cb5f27ecd9905348c24187a768c79694"},
|
||||||
{file = "numpy-1.24.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d0a2db9d20117bf523dde15858398e7c0858aadca7c0f088ac0d6edd360e9ad2"},
|
{file = "numpy-1.24.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1452241c290f3e2a312c137a9999cdbf63f78864d63c79039bda65ee86943f61"},
|
||||||
{file = "numpy-1.24.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c72a6b2f4af1adfe193f7beb91ddf708ff867a3f977ef2ec53c0ffb8283ab9f5"},
|
{file = "numpy-1.24.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f"},
|
||||||
{file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29e6bd0ec49a44d7690ecb623a8eac5ab8a923bce0bea6293953992edf3a76a"},
|
{file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5425b114831d1e77e4b5d812b69d11d962e104095a5b9c3b641a218abcc050e"},
|
||||||
{file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eabd64ddb96a1239791da78fa5f4e1693ae2dadc82a76bc76a14cbb2b966e96"},
|
{file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd80e219fd4c71fc3699fc1dadac5dcf4fd882bfc6f7ec53d30fa197b8ee22dc"},
|
||||||
{file = "numpy-1.24.2-cp38-cp38-win32.whl", hash = "sha256:e3ab5d32784e843fc0dd3ab6dcafc67ef806e6b6828dc6af2f689be0eb4d781d"},
|
{file = "numpy-1.24.4-cp38-cp38-win32.whl", hash = "sha256:4602244f345453db537be5314d3983dbf5834a9701b7723ec28923e2889e0bb2"},
|
||||||
{file = "numpy-1.24.2-cp38-cp38-win_amd64.whl", hash = "sha256:76807b4063f0002c8532cfeac47a3068a69561e9c8715efdad3c642eb27c0756"},
|
{file = "numpy-1.24.4-cp38-cp38-win_amd64.whl", hash = "sha256:692f2e0f55794943c5bfff12b3f56f99af76f902fc47487bdfe97856de51a706"},
|
||||||
{file = "numpy-1.24.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4199e7cfc307a778f72d293372736223e39ec9ac096ff0a2e64853b866a8e18a"},
|
{file = "numpy-1.24.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2541312fbf09977f3b3ad449c4e5f4bb55d0dbf79226d7724211acc905049400"},
|
||||||
{file = "numpy-1.24.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:adbdce121896fd3a17a77ab0b0b5eedf05a9834a18699db6829a64e1dfccca7f"},
|
{file = "numpy-1.24.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9667575fb6d13c95f1b36aca12c5ee3356bf001b714fc354eb5465ce1609e62f"},
|
||||||
{file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:889b2cc88b837d86eda1b17008ebeb679d82875022200c6e8e4ce6cf549b7acb"},
|
{file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a86ed21e4f87050382c7bc96571755193c4c1392490744ac73d660e8f564a9"},
|
||||||
{file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f64bb98ac59b3ea3bf74b02f13836eb2e24e48e0ab0145bbda646295769bd780"},
|
{file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d11efb4dbecbdf22508d55e48d9c8384db795e1b7b51ea735289ff96613ff74d"},
|
||||||
{file = "numpy-1.24.2-cp39-cp39-win32.whl", hash = "sha256:63e45511ee4d9d976637d11e6c9864eae50e12dc9598f531c035265991910468"},
|
{file = "numpy-1.24.4-cp39-cp39-win32.whl", hash = "sha256:6620c0acd41dbcb368610bb2f4d83145674040025e5536954782467100aa8835"},
|
||||||
{file = "numpy-1.24.2-cp39-cp39-win_amd64.whl", hash = "sha256:a77d3e1163a7770164404607b7ba3967fb49b24782a6ef85d9b5f54126cc39e5"},
|
{file = "numpy-1.24.4-cp39-cp39-win_amd64.whl", hash = "sha256:befe2bf740fd8373cf56149a5c23a0f601e82869598d41f8e188a0e9869926f8"},
|
||||||
{file = "numpy-1.24.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:92011118955724465fb6853def593cf397b4a1367495e0b59a7e69d40c4eb71d"},
|
{file = "numpy-1.24.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31f13e25b4e304632a4619d0e0777662c2ffea99fcae2029556b17d8ff958aef"},
|
||||||
{file = "numpy-1.24.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9006288bcf4895917d02583cf3411f98631275bc67cce355a7f39f8c14338fa"},
|
{file = "numpy-1.24.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95f7ac6540e95bc440ad77f56e520da5bf877f87dca58bd095288dce8940532a"},
|
||||||
{file = "numpy-1.24.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:150947adbdfeceec4e5926d956a06865c1c690f2fd902efede4ca6fe2e657c3f"},
|
{file = "numpy-1.24.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e98f220aa76ca2a977fe435f5b04d7b3470c0a2e6312907b37ba6068f26787f2"},
|
||||||
{file = "numpy-1.24.2.tar.gz", hash = "sha256:003a9f530e880cb2cd177cba1af7220b9aa42def9c4afc2a2fc3ee6be7eb2b22"},
|
{file = "numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -485,14 +489,14 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.0.0"
|
version = "1.2.0"
|
||||||
description = "plugin and hook calling mechanisms for python"
|
description = "plugin and hook calling mechanisms for python"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
|
{file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"},
|
||||||
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
|
{file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
@ -513,24 +517,24 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyinstaller"
|
name = "pyinstaller"
|
||||||
version = "5.10.1"
|
version = "5.13.0"
|
||||||
description = "PyInstaller bundles a Python application and all its dependencies into a single package."
|
description = "PyInstaller bundles a Python application and all its dependencies into a single package."
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "<3.12,>=3.7"
|
python-versions = "<3.13,>=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "pyinstaller-5.10.1-py3-none-macosx_10_13_universal2.whl", hash = "sha256:247b99c52dc3cf69eba905da30dbca0a8ea309e1058cab44658ac838d9b8f2f0"},
|
{file = "pyinstaller-5.13.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:7fdd319828de679f9c5e381eff998ee9b4164bf4457e7fca56946701cf002c3f"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:2d16641a495593d174504263b038a6d3d46b3b15a381ccb216cf6cce67723512"},
|
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0df43697c4914285ecd333be968d2cd042ab9b2670124879ee87931d2344eaf5"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_i686.whl", hash = "sha256:df97aaf1103a1c485aa3c9947792a86675e370f5ce9b436b4a84e34a4180c8d2"},
|
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_i686.whl", hash = "sha256:28d9742c37e9fb518444b12f8c8ab3cb4ba212d752693c34475c08009aa21ccf"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:333b4ffda38d9c0a561c38429dd9848d37aa78f3b8ea8a6f2b2e69a60d523c02"},
|
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e5fb17de6c325d3b2b4ceaeb55130ad7100a79096490e4c5b890224406fa42f4"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:6afc7aa4885ffd3e6121a8cf2138830099f874c18cb5869bed8c1a42db82d060"},
|
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:78975043edeb628e23a73fb3ef0a273cda50e765f1716f75212ea3e91b09dede"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:85e39e36d03355423636907a26a9bfa06fdc93cb1086441b19d2d0ca448479fa"},
|
{file = "pyinstaller-5.13.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:cd7d5c06f2847195a23d72ede17c60857d6f495d6f0727dc6c9bc1235f2eb79c"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:7a1db833bb0302b66ae3ae337fbd5487699658ce869ca4d538b5359b8179e83a"},
|
{file = "pyinstaller-5.13.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:24009eba63cfdbcde6d2634e9c87f545eb67249ddf3b514e0cd3b2cdaa595828"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:bb7de35cd209a0a0358aec761a273ae951d2161c03728f15d9a640d06a88e472"},
|
{file = "pyinstaller-5.13.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:1fde4381155f21d6354dc450dcaa338cd8a40aaacf6bd22b987b0f3e1f96f3ee"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-win32.whl", hash = "sha256:9e9a38f41f8280c8e29b294716992852281b41fbe64ba330ebab671efe27b26d"},
|
{file = "pyinstaller-5.13.0-py3-none-win32.whl", hash = "sha256:2d03419904d1c25c8968b0ad21da0e0f33d8d65716e29481b5bd83f7f342b0c5"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-win_amd64.whl", hash = "sha256:915a502802c751bafd92d568ac57468ec6cdf252b8308aa9a167bbc2c565ad2d"},
|
{file = "pyinstaller-5.13.0-py3-none-win_amd64.whl", hash = "sha256:9fc27c5a853b14a90d39c252707673c7a0efec921cd817169aff3af0fca8c127"},
|
||||||
{file = "pyinstaller-5.10.1-py3-none-win_arm64.whl", hash = "sha256:f677fbc151db1eb00ada94e86ed128e7b359cbd6bf3f6ea815afdde687692d46"},
|
{file = "pyinstaller-5.13.0-py3-none-win_arm64.whl", hash = "sha256:3a331951f9744bc2379ea5d65d36f3c828eaefe2785f15039592cdc08560b262"},
|
||||||
{file = "pyinstaller-5.10.1.tar.gz", hash = "sha256:6ecc464bf56919bf2d6bff275f38d85ff08ae747b8ead3a0c26cf85573b3c723"},
|
{file = "pyinstaller-5.13.0.tar.gz", hash = "sha256:5e446df41255e815017d96318e39f65a3eb807e74a796c7e7ff7f13b6366a2e9"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -538,7 +542,7 @@ altgraph = "*"
|
|||||||
macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
|
macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
|
||||||
pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
|
pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
|
||||||
pyinstaller-hooks-contrib = ">=2021.4"
|
pyinstaller-hooks-contrib = ">=2021.4"
|
||||||
pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
|
pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""}
|
||||||
setuptools = ">=42.0.0"
|
setuptools = ">=42.0.0"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
@ -547,14 +551,14 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyinstaller-hooks-contrib"
|
name = "pyinstaller-hooks-contrib"
|
||||||
version = "2023.2"
|
version = "2023.6"
|
||||||
description = "Community maintained hooks for PyInstaller"
|
description = "Community maintained hooks for PyInstaller"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "pyinstaller-hooks-contrib-2023.2.tar.gz", hash = "sha256:7fb856a81fd06a717188a3175caa77e902035cc067b00b583c6409c62497b23f"},
|
{file = "pyinstaller-hooks-contrib-2023.6.tar.gz", hash = "sha256:596a72009d8692b043e0acbf5e1b476d93149900142ba01845dded91a0770cb5"},
|
||||||
{file = "pyinstaller_hooks_contrib-2023.2-py2.py3-none-any.whl", hash = "sha256:e02c5f0ee3d4f5814588c2128caf5036c058ba764aaf24d957bb5311ad8690ad"},
|
{file = "pyinstaller_hooks_contrib-2023.6-py2.py3-none-any.whl", hash = "sha256:aa6d7d038814df6aa7bec7bdbebc7cb4c693d3398df858f6062957f0797d397b"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -580,14 +584,14 @@ pyro = ["Pyro"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "7.3.1"
|
version = "7.4.0"
|
||||||
description = "pytest: simple powerful testing with Python"
|
description = "pytest: simple powerful testing with Python"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"},
|
{file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"},
|
||||||
{file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"},
|
{file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -599,7 +603,7 @@ pluggy = ">=0.12,<2.0"
|
|||||||
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
|
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
|
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pywin32"
|
name = "pywin32"
|
||||||
@ -627,14 +631,14 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pywin32-ctypes"
|
name = "pywin32-ctypes"
|
||||||
version = "0.2.0"
|
version = "0.2.2"
|
||||||
description = ""
|
description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = ">=3.6"
|
||||||
files = [
|
files = [
|
||||||
{file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"},
|
{file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"},
|
||||||
{file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"},
|
{file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -655,19 +659,19 @@ jeepney = ">=0.6"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "setuptools"
|
name = "setuptools"
|
||||||
version = "67.6.1"
|
version = "68.0.0"
|
||||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "setuptools-67.6.1-py3-none-any.whl", hash = "sha256:e728ca814a823bf7bf60162daf9db95b93d532948c4c0bea762ce62f60189078"},
|
{file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"},
|
||||||
{file = "setuptools-67.6.1.tar.gz", hash = "sha256:257de92a9d50a60b8e22abfcbb771571fde0dbf3ec234463212027a4eeecbe9a"},
|
{file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
|
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
|
||||||
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
|
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
|
||||||
testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
|
testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -684,14 +688,14 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yubikey-manager"
|
name = "yubikey-manager"
|
||||||
version = "5.1.0"
|
version = "5.1.1"
|
||||||
description = "Tool for managing your YubiKey configuration."
|
description = "Tool for managing your YubiKey configuration."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7,<4.0"
|
python-versions = ">=3.7,<4.0"
|
||||||
files = [
|
files = [
|
||||||
{file = "yubikey_manager-5.1.0-py3-none-any.whl", hash = "sha256:72ac412319ee9c9db13173a68326de11478f1e8b3ed13b25bb3d33157b3f958e"},
|
{file = "yubikey_manager-5.1.1-py3-none-any.whl", hash = "sha256:67291f1d9396d99845b710eabfb4b5ba41b5fa6cc0011104267f91914c1867e3"},
|
||||||
{file = "yubikey_manager-5.1.0.tar.gz", hash = "sha256:d33efc9f82e511fd4d7c9397f6c40b37c7260221ca06fac93daeb4a46b1eb173"},
|
{file = "yubikey_manager-5.1.1.tar.gz", hash = "sha256:684102affd4a0d29611756da263c22f8e67226e80f65c5460c8c5608f9c0d58d"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -704,44 +708,44 @@ pywin32 = {version = ">=223", markers = "sys_platform == \"win32\""}
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zipp"
|
name = "zipp"
|
||||||
version = "3.15.0"
|
version = "3.16.2"
|
||||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
description = "Backport of pathlib-compatible object wrapper for zip files"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"},
|
{file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"},
|
||||||
{file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"},
|
{file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
||||||
testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
|
testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zxing-cpp"
|
name = "zxing-cpp"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
description = "Python bindings for the zxing-cpp barcode library"
|
description = "Python bindings for the zxing-cpp barcode library"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
files = [
|
files = [
|
||||||
{file = "zxing-cpp-2.0.0.tar.gz", hash = "sha256:1b67b221aae15aad9b5609d99c38d57875bc0a4fef864142d7ca37e9ee7880b0"},
|
{file = "zxing-cpp-2.1.0.tar.gz", hash = "sha256:7a8a468b420bf391707431d5a0dd881cb41033ae15f87820d93d5707c7bc55bc"},
|
||||||
{file = "zxing_cpp-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:54282d0e5c573754049113a0cdbf14cc1c6b986432a367d8a788112afa92a1d5"},
|
{file = "zxing_cpp-2.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:26d27f61d627c06cc3e91b1ce816bd780c9227fd10b7ca961264f67bfb3bdf66"},
|
||||||
{file = "zxing_cpp-2.0.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76caafb8fc1e12c2e5ec33ce4f340a0e15e9a2aabfbfeaec170e8a2b405b8a77"},
|
{file = "zxing_cpp-2.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4d9655c7d682ce252fe5c25f22c6fafe4c5ac493830fa8a2c062c85d061ce3b4"},
|
||||||
{file = "zxing_cpp-2.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95dd06dc559f53c1ca0eb59dbaebd802ebc839937baaf2f8d2b3def3e814c07f"},
|
{file = "zxing_cpp-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:313bac052bd38bd2cedaa2610d880b3d62254dd6d8be01795559b73872c54ed0"},
|
||||||
{file = "zxing_cpp-2.0.0-cp310-cp310-win32.whl", hash = "sha256:ea54fd242f93eea7bf039a68287e5e57fdf77d78e3bd5b4cbb2d289bb3380d63"},
|
{file = "zxing_cpp-2.1.0-cp310-cp310-win32.whl", hash = "sha256:0a178683b66422ac01ae35f749d58c50b271f9ab18def1c286f5fc61bcf81fa7"},
|
||||||
{file = "zxing_cpp-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:8da9c912cca5829eedb2800ce3eaa1b1e52742f536aa9e798be69bf09639f399"},
|
{file = "zxing_cpp-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:650d8f6731f11c04f4662a48f1efa9dc26c97bbdfa4f9b14b4683f43b7ccde4d"},
|
||||||
{file = "zxing_cpp-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f70eefa5dc1fd9238087c024ef22f3d99ba79cb932a2c5bc5b0f1e152037722e"},
|
{file = "zxing_cpp-2.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4163d72975191d40c879bc130d5e8aa1eef5d5e6bfe820d94b5c9a2cb10d664e"},
|
||||||
{file = "zxing_cpp-2.0.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97919f07c62edf1c8e0722fd64893057ce636b7067cf47bd593e98cc7e404d74"},
|
{file = "zxing_cpp-2.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:843f72a1f2a8c397b4d92f757488b03d8597031e907442382d5662fd96b0fd21"},
|
||||||
{file = "zxing_cpp-2.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd89065f620d6b78281308c6abfb760d95760a1c9b88eb7ac612b52b331bd41"},
|
{file = "zxing_cpp-2.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66d01d40bacc7e5b40e9fa474dab64f2e75a091c6e7c9d4a6b539b5a724127e3"},
|
||||||
{file = "zxing_cpp-2.0.0-cp311-cp311-win32.whl", hash = "sha256:631a0c783ad233c85295e0cf4cd7740f1fe2853124c61b1ef6bcf7eb5d2fa5e6"},
|
{file = "zxing_cpp-2.1.0-cp311-cp311-win32.whl", hash = "sha256:8397ce7e1a7a92cd8f0045a4c64e4fcd97f4aaa51441d27bcb76eeda0a1917bc"},
|
||||||
{file = "zxing_cpp-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f0c2c03f5df470ef71a7590be5042161e7590da767d4260a6d0d61a3fa80b88"},
|
{file = "zxing_cpp-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:a54cd56c0898cb63a08517b7d630484690a9bad4da1e443aebe64b7077444d90"},
|
||||||
{file = "zxing_cpp-2.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5ce391f21763f00d5be3431e16d075e263e4b9205c2cf55d708625cb234b1f15"},
|
{file = "zxing_cpp-2.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab8fff5791e1d858390e45325500f6a17d5d3b6ac0237ae84ceda6f5b7a3685a"},
|
||||||
{file = "zxing_cpp-2.0.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0eefdfad91e15e3f5b7ed16d83806a36f96ca482f4b042baa6297784a58b0b3"},
|
{file = "zxing_cpp-2.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba91ba2af0cc75c9e53bf95963f409c6fa26aa7df38469e2cdcb5b38a6c7c1c7"},
|
||||||
{file = "zxing_cpp-2.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d665c45029346c70ae3df5dbc36f6335ffe4f275e98dc43772fa32a65844196"},
|
{file = "zxing_cpp-2.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7ba898e4f5ee9cd426d4271ff8b26911e3346b1cb4262f06fdc917e42b7c123"},
|
||||||
{file = "zxing_cpp-2.0.0-cp39-cp39-win32.whl", hash = "sha256:214a6a0e49b92fda8d2761c74f5bfd24a677b9bf1d0ef0e083412486af97faa9"},
|
{file = "zxing_cpp-2.1.0-cp39-cp39-win32.whl", hash = "sha256:da081b763032b05326ddc53d3ad28a8b7603d662ccce2ff29fd204d587d3cac9"},
|
||||||
{file = "zxing_cpp-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:a788551ddf3a6ba1152ff9a0b81d57018a3cc586544087c39d881428745faf1f"},
|
{file = "zxing_cpp-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7245e551fc30e9708c0fd0f4d0d15f29c0b85075d20c18ddc53b87956a469544"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -750,4 +754,4 @@ numpy = "*"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.8"
|
python-versions = "^3.8"
|
||||||
content-hash = "f0fc2e7d5ef423dc8b247ab6b968a63c331e78bd74bd72020b634f6823a74e3d"
|
content-hash = "0ada1b4785281f6f18fb483a1d84504be84adc84aec8c8c7812cc92ea44656d8"
|
||||||
|
@ -10,14 +10,14 @@ packages = [
|
|||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.8"
|
python = "^3.8"
|
||||||
yubikey-manager = "5.1.0"
|
yubikey-manager = "5.1.1"
|
||||||
mss = "^8.0.3"
|
mss = "^9.0.1"
|
||||||
zxing-cpp = "^2.0.0"
|
zxing-cpp = "^2.0.0"
|
||||||
Pillow = "^9.5.0"
|
Pillow = "^9.5.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pyinstaller = {version = "^5.10.1", python = "<3.12"}
|
pyinstaller = {version = "^5.12.0", python = "<3.12"}
|
||||||
pytest = "^7.3.1"
|
pytest = "^7.3.2"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
@ -15,4 +15,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/// list of YubiKey serial numbers which are approved to be used with integration tests
|
/// list of YubiKey serial numbers which are approved to be used with integration tests
|
||||||
var approvedYubiKeys = <String>[];
|
var approvedYubiKeys = <String>[
|
||||||
|
'',
|
||||||
|
];
|
@ -14,14 +14,14 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
|
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
|
||||||
import 'package:yubico_authenticator/management/views/keys.dart'
|
import 'package:yubico_authenticator/management/views/keys.dart'
|
||||||
as management_keys;
|
as management_keys;
|
||||||
|
|
||||||
import 'test_util.dart';
|
import 'utils/test_util.dart';
|
||||||
|
|
||||||
Key _getCapabilityWidgetKey(bool isUsb, String name) =>
|
Key _getCapabilityWidgetKey(bool isUsb, String name) =>
|
||||||
Key('management.keys.capability.${isUsb ? 'usb' : 'nfc'}.$name');
|
Key('management.keys.capability.${isUsb ? 'usb' : 'nfc'}.$name');
|
||||||
@ -37,7 +37,8 @@ void main() {
|
|||||||
group('Management UI tests', () {
|
group('Management UI tests', () {
|
||||||
appTest('Drawer items exist', (WidgetTester tester) async {
|
appTest('Drawer items exist', (WidgetTester tester) async {
|
||||||
await tester.openDrawer();
|
await tester.openDrawer();
|
||||||
expect(find.byKey(app_keys.managementAppDrawer), findsOneWidget);
|
expect(find.byKey(app_keys.managementAppDrawer).hitTestable(),
|
||||||
|
findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -19,8 +19,8 @@ import 'package:integration_test/integration_test.dart';
|
|||||||
import 'package:yubico_authenticator/core/state.dart';
|
import 'package:yubico_authenticator/core/state.dart';
|
||||||
import 'package:yubico_authenticator/oath/keys.dart' as keys;
|
import 'package:yubico_authenticator/oath/keys.dart' as keys;
|
||||||
|
|
||||||
import 'oath_test_util.dart';
|
import 'utils/oath_test_util.dart';
|
||||||
import 'test_util.dart';
|
import 'utils/test_util.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
@ -30,9 +30,10 @@ Future<void> startUp(WidgetTester tester,
|
|||||||
// only wait for yubikey connection when needed
|
// only wait for yubikey connection when needed
|
||||||
// needs_yubikey defaults to true
|
// needs_yubikey defaults to true
|
||||||
if (startUpParams['needs_yubikey'] != false) {
|
if (startUpParams['needs_yubikey'] != false) {
|
||||||
|
await tester.openDrawer();
|
||||||
// wait for a YubiKey connection
|
// wait for a YubiKey connection
|
||||||
await tester.waitForFinder(find.descendant(
|
await tester.waitForFinder(find.descendant(
|
||||||
of: tester.findDeviceButton(),
|
of: find.byKey(app_keys.deviceInfoListTile),
|
||||||
matching: find.byWidgetPredicate((widget) =>
|
matching: find.byWidgetPredicate((widget) =>
|
||||||
widget is DeviceAvatar && widget.key != app_keys.noDeviceAvatar)));
|
widget is DeviceAvatar && widget.key != app_keys.noDeviceAvatar)));
|
||||||
}
|
}
|
@ -17,12 +17,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:yubico_authenticator/core/state.dart';
|
import 'package:yubico_authenticator/core/state.dart';
|
||||||
|
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
|
||||||
import 'package:yubico_authenticator/oath/keys.dart' as keys;
|
import 'package:yubico_authenticator/oath/keys.dart' as keys;
|
||||||
import 'package:yubico_authenticator/oath/views/account_list.dart';
|
import 'package:yubico_authenticator/oath/views/account_list.dart';
|
||||||
import 'package:yubico_authenticator/oath/views/account_view.dart';
|
import 'package:yubico_authenticator/oath/views/account_view.dart';
|
||||||
|
|
||||||
import 'android/util.dart';
|
import 'android/util.dart';
|
||||||
import 'test_util.dart';
|
import '../utils/test_util.dart';
|
||||||
|
|
||||||
class Account {
|
class Account {
|
||||||
final String? issuer;
|
final String? issuer;
|
||||||
@ -235,8 +236,12 @@ extension OathFunctions on WidgetTester {
|
|||||||
/// now the account dialog is shown
|
/// now the account dialog is shown
|
||||||
/// TODO verify it shows correct issuer and name
|
/// TODO verify it shows correct issuer and name
|
||||||
|
|
||||||
/// close the account dialog by tapping out of it
|
/// close the account dialog by tapping the close button
|
||||||
await tapAt(const Offset(10, 10));
|
var closeButton = find.byKey(app_keys.closeButton).hitTestable();
|
||||||
|
// Wait for toast to clear
|
||||||
|
await waitForFinder(closeButton);
|
||||||
|
|
||||||
|
await tap(closeButton);
|
||||||
await longWait();
|
await longWait();
|
||||||
|
|
||||||
/// verify accounts in the list
|
/// verify accounts in the list
|
@ -17,18 +17,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:yubico_authenticator/app/views/device_button.dart';
|
|
||||||
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
|
import 'package:yubico_authenticator/app/views/keys.dart' as app_keys;
|
||||||
import 'package:yubico_authenticator/app/views/keys.dart';
|
import 'package:yubico_authenticator/app/views/keys.dart';
|
||||||
import 'package:yubico_authenticator/core/state.dart';
|
import 'package:yubico_authenticator/core/state.dart';
|
||||||
import 'package:yubico_authenticator/management/views/keys.dart';
|
import 'package:yubico_authenticator/management/views/keys.dart';
|
||||||
|
|
||||||
import 'android/util.dart' as android_test_util;
|
import 'android/util.dart' as android_test_util;
|
||||||
import 'approved_yubikeys.dart';
|
import '../_approved_yubikeys.dart';
|
||||||
import 'desktop/util.dart' as desktop_test_util;
|
import 'desktop/util.dart' as desktop_test_util;
|
||||||
|
|
||||||
const shortWaitMs = 10;
|
const shortWaitMs = 500;
|
||||||
const longWaitMs = 50;
|
const longWaitMs = 500;
|
||||||
|
|
||||||
/// information about YubiKey as seen by the app
|
/// information about YubiKey as seen by the app
|
||||||
String? yubiKeyName;
|
String? yubiKeyName;
|
||||||
@ -65,16 +64,6 @@ extension AppWidgetTester on WidgetTester {
|
|||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
Finder findDeviceButton() {
|
|
||||||
return find.byType(DeviceButton).hitTestable();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Taps the device button
|
|
||||||
Future<void> tapDeviceButton() async {
|
|
||||||
await tap(findDeviceButton());
|
|
||||||
await pump(const Duration(milliseconds: 500));
|
|
||||||
}
|
|
||||||
|
|
||||||
Finder findActionIconButton() {
|
Finder findActionIconButton() {
|
||||||
return find.byKey(actionsIconButtonKey).hitTestable();
|
return find.byKey(actionsIconButtonKey).hitTestable();
|
||||||
}
|
}
|
||||||
@ -119,7 +108,7 @@ extension AppWidgetTester on WidgetTester {
|
|||||||
await openDrawer();
|
await openDrawer();
|
||||||
}
|
}
|
||||||
|
|
||||||
await tap(find.byKey(managementAppDrawer));
|
await tap(find.byKey(managementAppDrawer).hitTestable());
|
||||||
await pump(const Duration(milliseconds: 500));
|
await pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
expect(find.byKey(screenKey), findsOneWidget);
|
expect(find.byKey(screenKey), findsOneWidget);
|
||||||
@ -153,17 +142,22 @@ extension AppWidgetTester on WidgetTester {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await tapDeviceButton();
|
await openDrawer();
|
||||||
|
|
||||||
var deviceInfo = find.byKey(app_keys.deviceInfoListTile);
|
var deviceInfo = find.byKey(app_keys.deviceInfoListTile);
|
||||||
if (deviceInfo.evaluate().isNotEmpty) {
|
if (deviceInfo.evaluate().isNotEmpty) {
|
||||||
ListTile lt = deviceInfo.evaluate().single.widget as ListTile;
|
ListTile lt = find
|
||||||
|
.descendant(of: deviceInfo, matching: find.byType(ListTile))
|
||||||
|
.evaluate()
|
||||||
|
.single
|
||||||
|
.widget as ListTile;
|
||||||
|
//ListTile lt = deviceInfo.evaluate().single.widget as ListTile;
|
||||||
yubiKeyName = (lt.title as Text).data;
|
yubiKeyName = (lt.title as Text).data;
|
||||||
var subtitle = (lt.subtitle as Text?)?.data;
|
var subtitle = (lt.subtitle as Text?)?.data;
|
||||||
|
|
||||||
if (subtitle != null) {
|
if (subtitle != null) {
|
||||||
RegExpMatch? match = RegExp(r'S/N: (\d.*) F/W: (\d\.\d\.\d)')
|
RegExpMatch? match =
|
||||||
.firstMatch(subtitle);
|
RegExp(r'S/N: (\d.*) F/W: (\d\.\d\.\d)').firstMatch(subtitle);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
yubiKeySerialNumber = match.group(1);
|
yubiKeySerialNumber = match.group(1);
|
||||||
yubiKeyFirmware = match.group(2);
|
yubiKeyFirmware = match.group(2);
|
||||||
@ -177,7 +171,7 @@ extension AppWidgetTester on WidgetTester {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// close the opened menu
|
// close the opened menu
|
||||||
await tapTopLeftCorner();
|
await closeDrawer();
|
||||||
|
|
||||||
testLog(false,
|
testLog(false,
|
||||||
'Connected YubiKey: $yubiKeySerialNumber/$yubiKeyFirmware - $yubiKeyName');
|
'Connected YubiKey: $yubiKeySerialNumber/$yubiKeyFirmware - $yubiKeyName');
|
@ -23,22 +23,19 @@ import '../../app/models.dart';
|
|||||||
import '../../app/state.dart';
|
import '../../app/state.dart';
|
||||||
import '../../management/state.dart';
|
import '../../management/state.dart';
|
||||||
|
|
||||||
final androidManagementState = StateNotifierProvider.autoDispose
|
final androidManagementState = AsyncNotifierProvider.autoDispose
|
||||||
.family<ManagementStateNotifier, AsyncValue<DeviceInfo>, DevicePath>(
|
.family<ManagementStateNotifier, DeviceInfo, DevicePath>(
|
||||||
(ref, devicePath) {
|
_AndroidManagementStateNotifier.new,
|
||||||
// Make sure to rebuild if currentDevice changes (as on reboot)
|
|
||||||
ref.watch(currentDeviceProvider);
|
|
||||||
final notifier = _AndroidManagementStateNotifier(ref);
|
|
||||||
return notifier..refresh();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
class _AndroidManagementStateNotifier extends ManagementStateNotifier {
|
class _AndroidManagementStateNotifier extends ManagementStateNotifier {
|
||||||
final Ref _ref;
|
@override
|
||||||
|
FutureOr<DeviceInfo> build(DevicePath devicePath) {
|
||||||
|
// Make sure to rebuild if currentDevice changes (as on reboot)
|
||||||
|
ref.watch(currentDeviceProvider);
|
||||||
|
|
||||||
_AndroidManagementStateNotifier(this._ref) : super();
|
return Completer<DeviceInfo>().future;
|
||||||
|
}
|
||||||
void refresh() async {}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setMode(
|
Future<void> setMode(
|
||||||
@ -55,6 +52,6 @@ class _AndroidManagementStateNotifier extends ManagementStateNotifier {
|
|||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
}
|
}
|
||||||
|
|
||||||
_ref.read(attachedDevicesProvider.notifier).refresh();
|
ref.read(attachedDevicesProvider.notifier).refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,33 +36,31 @@ final _log = Logger('android.oath.state');
|
|||||||
|
|
||||||
const _methods = MethodChannel('android.oath.methods');
|
const _methods = MethodChannel('android.oath.methods');
|
||||||
|
|
||||||
final androidOathStateProvider = StateNotifierProvider.autoDispose
|
final androidOathStateProvider = AsyncNotifierProvider.autoDispose
|
||||||
.family<OathStateNotifier, AsyncValue<OathState>, DevicePath>(
|
.family<OathStateNotifier, OathState, DevicePath>(
|
||||||
(ref, devicePath) => _AndroidOathStateNotifier());
|
_AndroidOathStateNotifier.new);
|
||||||
|
|
||||||
class _AndroidOathStateNotifier extends OathStateNotifier {
|
class _AndroidOathStateNotifier extends OathStateNotifier {
|
||||||
final _events = const EventChannel('android.oath.sessionState');
|
final _events = const EventChannel('android.oath.sessionState');
|
||||||
late StreamSubscription _sub;
|
late StreamSubscription _sub;
|
||||||
_AndroidOathStateNotifier() : super() {
|
|
||||||
|
@override
|
||||||
|
FutureOr<OathState> build(DevicePath arg) {
|
||||||
_sub = _events.receiveBroadcastStream().listen((event) {
|
_sub = _events.receiveBroadcastStream().listen((event) {
|
||||||
final json = jsonDecode(event);
|
final json = jsonDecode(event);
|
||||||
if (mounted) {
|
|
||||||
if (json == null) {
|
if (json == null) {
|
||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
} else {
|
} else {
|
||||||
final oathState = OathState.fromJson(json);
|
final oathState = OathState.fromJson(json);
|
||||||
state = AsyncValue.data(oathState);
|
state = AsyncValue.data(oathState);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, onError: (err, stackTrace) {
|
}, onError: (err, stackTrace) {
|
||||||
state = AsyncValue.error(err, stackTrace);
|
state = AsyncValue.error(err, stackTrace);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
ref.onDispose(_sub.cancel);
|
||||||
void dispose() {
|
|
||||||
_sub.cancel();
|
return Completer<OathState>().future;
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -20,7 +20,6 @@ import 'dart:ui';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../widgets/toast.dart';
|
import '../widgets/toast.dart';
|
||||||
import 'models.dart';
|
|
||||||
|
|
||||||
void Function() showMessage(
|
void Function() showMessage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
@ -29,42 +28,12 @@ void Function() showMessage(
|
|||||||
}) =>
|
}) =>
|
||||||
showToast(context, message, duration: duration);
|
showToast(context, message, duration: duration);
|
||||||
|
|
||||||
Future<void> showBottomMenu(
|
|
||||||
BuildContext context, List<MenuAction> actions) async {
|
|
||||||
await showBlurDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: const Text('Options'),
|
|
||||||
contentPadding: const EdgeInsets.only(bottom: 24, top: 4),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: actions
|
|
||||||
.map((a) => ListTile(
|
|
||||||
leading: a.icon,
|
|
||||||
title: Text(a.text),
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
enabled: a.intent != null,
|
|
||||||
onTap: a.intent == null
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
Actions.invoke(context, a.intent!);
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<T?> showBlurDialog<T>({
|
Future<T?> showBlurDialog<T>({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required Widget Function(BuildContext) builder,
|
required Widget Function(BuildContext) builder,
|
||||||
RouteSettings? routeSettings,
|
RouteSettings? routeSettings,
|
||||||
}) =>
|
}) async =>
|
||||||
showGeneralDialog(
|
await showGeneralDialog<T>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||||||
|
@ -53,6 +53,7 @@ enum Application {
|
|||||||
String getDisplayName(AppLocalizations l10n) => switch (this) {
|
String getDisplayName(AppLocalizations l10n) => switch (this) {
|
||||||
Application.oath => l10n.s_authenticator,
|
Application.oath => l10n.s_authenticator,
|
||||||
Application.fido => l10n.s_webauthn,
|
Application.fido => l10n.s_webauthn,
|
||||||
|
Application.piv => l10n.s_piv,
|
||||||
_ => name.substring(0, 1).toUpperCase() + name.substring(1),
|
_ => name.substring(0, 1).toUpperCase() + name.substring(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -115,14 +116,20 @@ class DeviceNode with _$DeviceNode {
|
|||||||
map(usbYubiKey: (_) => Transport.usb, nfcReader: (_) => Transport.nfc);
|
map(usbYubiKey: (_) => Transport.usb, nfcReader: (_) => Transport.nfc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ActionStyle { normal, primary, error }
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class MenuAction with _$MenuAction {
|
class ActionItem with _$ActionItem {
|
||||||
factory MenuAction({
|
factory ActionItem({
|
||||||
required String text,
|
|
||||||
required Widget icon,
|
required Widget icon,
|
||||||
String? trailing,
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
String? shortcut,
|
||||||
|
Widget? trailing,
|
||||||
Intent? intent,
|
Intent? intent,
|
||||||
}) = _MenuAction;
|
ActionStyle? actionStyle,
|
||||||
|
Key? key,
|
||||||
|
}) = _ActionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
@ -624,30 +624,42 @@ abstract class NfcReaderNode extends DeviceNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$MenuAction {
|
mixin _$ActionItem {
|
||||||
String get text => throw _privateConstructorUsedError;
|
|
||||||
Widget get icon => throw _privateConstructorUsedError;
|
Widget get icon => throw _privateConstructorUsedError;
|
||||||
String? get trailing => throw _privateConstructorUsedError;
|
String get title => throw _privateConstructorUsedError;
|
||||||
|
String? get subtitle => throw _privateConstructorUsedError;
|
||||||
|
String? get shortcut => throw _privateConstructorUsedError;
|
||||||
|
Widget? get trailing => throw _privateConstructorUsedError;
|
||||||
Intent? get intent => throw _privateConstructorUsedError;
|
Intent? get intent => throw _privateConstructorUsedError;
|
||||||
|
ActionStyle? get actionStyle => throw _privateConstructorUsedError;
|
||||||
|
Key? get key => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(ignore: true)
|
||||||
$MenuActionCopyWith<MenuAction> get copyWith =>
|
$ActionItemCopyWith<ActionItem> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
abstract class $MenuActionCopyWith<$Res> {
|
abstract class $ActionItemCopyWith<$Res> {
|
||||||
factory $MenuActionCopyWith(
|
factory $ActionItemCopyWith(
|
||||||
MenuAction value, $Res Function(MenuAction) then) =
|
ActionItem value, $Res Function(ActionItem) then) =
|
||||||
_$MenuActionCopyWithImpl<$Res, MenuAction>;
|
_$ActionItemCopyWithImpl<$Res, ActionItem>;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({String text, Widget icon, String? trailing, Intent? intent});
|
$Res call(
|
||||||
|
{Widget icon,
|
||||||
|
String title,
|
||||||
|
String? subtitle,
|
||||||
|
String? shortcut,
|
||||||
|
Widget? trailing,
|
||||||
|
Intent? intent,
|
||||||
|
ActionStyle? actionStyle,
|
||||||
|
Key? key});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
class _$MenuActionCopyWithImpl<$Res, $Val extends MenuAction>
|
class _$ActionItemCopyWithImpl<$Res, $Val extends ActionItem>
|
||||||
implements $MenuActionCopyWith<$Res> {
|
implements $ActionItemCopyWith<$Res> {
|
||||||
_$MenuActionCopyWithImpl(this._value, this._then);
|
_$ActionItemCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
// ignore: unused_field
|
// ignore: unused_field
|
||||||
final $Val _value;
|
final $Val _value;
|
||||||
@ -657,140 +669,223 @@ class _$MenuActionCopyWithImpl<$Res, $Val extends MenuAction>
|
|||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
Object? text = null,
|
|
||||||
Object? icon = null,
|
Object? icon = null,
|
||||||
|
Object? title = null,
|
||||||
|
Object? subtitle = freezed,
|
||||||
|
Object? shortcut = freezed,
|
||||||
Object? trailing = freezed,
|
Object? trailing = freezed,
|
||||||
Object? intent = freezed,
|
Object? intent = freezed,
|
||||||
|
Object? actionStyle = freezed,
|
||||||
|
Object? key = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(_value.copyWith(
|
return _then(_value.copyWith(
|
||||||
text: null == text
|
|
||||||
? _value.text
|
|
||||||
: text // ignore: cast_nullable_to_non_nullable
|
|
||||||
as String,
|
|
||||||
icon: null == icon
|
icon: null == icon
|
||||||
? _value.icon
|
? _value.icon
|
||||||
: icon // ignore: cast_nullable_to_non_nullable
|
: icon // ignore: cast_nullable_to_non_nullable
|
||||||
as Widget,
|
as Widget,
|
||||||
|
title: null == title
|
||||||
|
? _value.title
|
||||||
|
: title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
subtitle: freezed == subtitle
|
||||||
|
? _value.subtitle
|
||||||
|
: subtitle // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
shortcut: freezed == shortcut
|
||||||
|
? _value.shortcut
|
||||||
|
: shortcut // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
trailing: freezed == trailing
|
trailing: freezed == trailing
|
||||||
? _value.trailing
|
? _value.trailing
|
||||||
: trailing // ignore: cast_nullable_to_non_nullable
|
: trailing // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as Widget?,
|
||||||
intent: freezed == intent
|
intent: freezed == intent
|
||||||
? _value.intent
|
? _value.intent
|
||||||
: intent // ignore: cast_nullable_to_non_nullable
|
: intent // ignore: cast_nullable_to_non_nullable
|
||||||
as Intent?,
|
as Intent?,
|
||||||
|
actionStyle: freezed == actionStyle
|
||||||
|
? _value.actionStyle
|
||||||
|
: actionStyle // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ActionStyle?,
|
||||||
|
key: freezed == key
|
||||||
|
? _value.key
|
||||||
|
: key // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Key?,
|
||||||
) as $Val);
|
) as $Val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
abstract class _$$_MenuActionCopyWith<$Res>
|
abstract class _$$_ActionItemCopyWith<$Res>
|
||||||
implements $MenuActionCopyWith<$Res> {
|
implements $ActionItemCopyWith<$Res> {
|
||||||
factory _$$_MenuActionCopyWith(
|
factory _$$_ActionItemCopyWith(
|
||||||
_$_MenuAction value, $Res Function(_$_MenuAction) then) =
|
_$_ActionItem value, $Res Function(_$_ActionItem) then) =
|
||||||
__$$_MenuActionCopyWithImpl<$Res>;
|
__$$_ActionItemCopyWithImpl<$Res>;
|
||||||
@override
|
@override
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({String text, Widget icon, String? trailing, Intent? intent});
|
$Res call(
|
||||||
|
{Widget icon,
|
||||||
|
String title,
|
||||||
|
String? subtitle,
|
||||||
|
String? shortcut,
|
||||||
|
Widget? trailing,
|
||||||
|
Intent? intent,
|
||||||
|
ActionStyle? actionStyle,
|
||||||
|
Key? key});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
class __$$_MenuActionCopyWithImpl<$Res>
|
class __$$_ActionItemCopyWithImpl<$Res>
|
||||||
extends _$MenuActionCopyWithImpl<$Res, _$_MenuAction>
|
extends _$ActionItemCopyWithImpl<$Res, _$_ActionItem>
|
||||||
implements _$$_MenuActionCopyWith<$Res> {
|
implements _$$_ActionItemCopyWith<$Res> {
|
||||||
__$$_MenuActionCopyWithImpl(
|
__$$_ActionItemCopyWithImpl(
|
||||||
_$_MenuAction _value, $Res Function(_$_MenuAction) _then)
|
_$_ActionItem _value, $Res Function(_$_ActionItem) _then)
|
||||||
: super(_value, _then);
|
: super(_value, _then);
|
||||||
|
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
Object? text = null,
|
|
||||||
Object? icon = null,
|
Object? icon = null,
|
||||||
|
Object? title = null,
|
||||||
|
Object? subtitle = freezed,
|
||||||
|
Object? shortcut = freezed,
|
||||||
Object? trailing = freezed,
|
Object? trailing = freezed,
|
||||||
Object? intent = freezed,
|
Object? intent = freezed,
|
||||||
|
Object? actionStyle = freezed,
|
||||||
|
Object? key = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(_$_MenuAction(
|
return _then(_$_ActionItem(
|
||||||
text: null == text
|
|
||||||
? _value.text
|
|
||||||
: text // ignore: cast_nullable_to_non_nullable
|
|
||||||
as String,
|
|
||||||
icon: null == icon
|
icon: null == icon
|
||||||
? _value.icon
|
? _value.icon
|
||||||
: icon // ignore: cast_nullable_to_non_nullable
|
: icon // ignore: cast_nullable_to_non_nullable
|
||||||
as Widget,
|
as Widget,
|
||||||
|
title: null == title
|
||||||
|
? _value.title
|
||||||
|
: title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
subtitle: freezed == subtitle
|
||||||
|
? _value.subtitle
|
||||||
|
: subtitle // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
shortcut: freezed == shortcut
|
||||||
|
? _value.shortcut
|
||||||
|
: shortcut // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
trailing: freezed == trailing
|
trailing: freezed == trailing
|
||||||
? _value.trailing
|
? _value.trailing
|
||||||
: trailing // ignore: cast_nullable_to_non_nullable
|
: trailing // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as Widget?,
|
||||||
intent: freezed == intent
|
intent: freezed == intent
|
||||||
? _value.intent
|
? _value.intent
|
||||||
: intent // ignore: cast_nullable_to_non_nullable
|
: intent // ignore: cast_nullable_to_non_nullable
|
||||||
as Intent?,
|
as Intent?,
|
||||||
|
actionStyle: freezed == actionStyle
|
||||||
|
? _value.actionStyle
|
||||||
|
: actionStyle // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ActionStyle?,
|
||||||
|
key: freezed == key
|
||||||
|
? _value.key
|
||||||
|
: key // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Key?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
|
|
||||||
class _$_MenuAction implements _MenuAction {
|
class _$_ActionItem implements _ActionItem {
|
||||||
_$_MenuAction(
|
_$_ActionItem(
|
||||||
{required this.text, required this.icon, this.trailing, this.intent});
|
{required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.shortcut,
|
||||||
|
this.trailing,
|
||||||
|
this.intent,
|
||||||
|
this.actionStyle,
|
||||||
|
this.key});
|
||||||
|
|
||||||
@override
|
|
||||||
final String text;
|
|
||||||
@override
|
@override
|
||||||
final Widget icon;
|
final Widget icon;
|
||||||
@override
|
@override
|
||||||
final String? trailing;
|
final String title;
|
||||||
|
@override
|
||||||
|
final String? subtitle;
|
||||||
|
@override
|
||||||
|
final String? shortcut;
|
||||||
|
@override
|
||||||
|
final Widget? trailing;
|
||||||
@override
|
@override
|
||||||
final Intent? intent;
|
final Intent? intent;
|
||||||
|
@override
|
||||||
|
final ActionStyle? actionStyle;
|
||||||
|
@override
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'MenuAction(text: $text, icon: $icon, trailing: $trailing, intent: $intent)';
|
return 'ActionItem(icon: $icon, title: $title, subtitle: $subtitle, shortcut: $shortcut, trailing: $trailing, intent: $intent, actionStyle: $actionStyle, key: $key)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(dynamic other) {
|
bool operator ==(dynamic other) {
|
||||||
return identical(this, other) ||
|
return identical(this, other) ||
|
||||||
(other.runtimeType == runtimeType &&
|
(other.runtimeType == runtimeType &&
|
||||||
other is _$_MenuAction &&
|
other is _$_ActionItem &&
|
||||||
(identical(other.text, text) || other.text == text) &&
|
|
||||||
(identical(other.icon, icon) || other.icon == icon) &&
|
(identical(other.icon, icon) || other.icon == icon) &&
|
||||||
|
(identical(other.title, title) || other.title == title) &&
|
||||||
|
(identical(other.subtitle, subtitle) ||
|
||||||
|
other.subtitle == subtitle) &&
|
||||||
|
(identical(other.shortcut, shortcut) ||
|
||||||
|
other.shortcut == shortcut) &&
|
||||||
(identical(other.trailing, trailing) ||
|
(identical(other.trailing, trailing) ||
|
||||||
other.trailing == trailing) &&
|
other.trailing == trailing) &&
|
||||||
(identical(other.intent, intent) || other.intent == intent));
|
(identical(other.intent, intent) || other.intent == intent) &&
|
||||||
|
(identical(other.actionStyle, actionStyle) ||
|
||||||
|
other.actionStyle == actionStyle) &&
|
||||||
|
(identical(other.key, key) || other.key == key));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType, text, icon, trailing, intent);
|
int get hashCode => Object.hash(runtimeType, icon, title, subtitle, shortcut,
|
||||||
|
trailing, intent, actionStyle, key);
|
||||||
|
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(ignore: true)
|
||||||
@override
|
@override
|
||||||
@pragma('vm:prefer-inline')
|
@pragma('vm:prefer-inline')
|
||||||
_$$_MenuActionCopyWith<_$_MenuAction> get copyWith =>
|
_$$_ActionItemCopyWith<_$_ActionItem> get copyWith =>
|
||||||
__$$_MenuActionCopyWithImpl<_$_MenuAction>(this, _$identity);
|
__$$_ActionItemCopyWithImpl<_$_ActionItem>(this, _$identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class _MenuAction implements MenuAction {
|
abstract class _ActionItem implements ActionItem {
|
||||||
factory _MenuAction(
|
factory _ActionItem(
|
||||||
{required final String text,
|
{required final Widget icon,
|
||||||
required final Widget icon,
|
required final String title,
|
||||||
final String? trailing,
|
final String? subtitle,
|
||||||
final Intent? intent}) = _$_MenuAction;
|
final String? shortcut,
|
||||||
|
final Widget? trailing,
|
||||||
|
final Intent? intent,
|
||||||
|
final ActionStyle? actionStyle,
|
||||||
|
final Key? key}) = _$_ActionItem;
|
||||||
|
|
||||||
@override
|
|
||||||
String get text;
|
|
||||||
@override
|
@override
|
||||||
Widget get icon;
|
Widget get icon;
|
||||||
@override
|
@override
|
||||||
String? get trailing;
|
String get title;
|
||||||
|
@override
|
||||||
|
String? get subtitle;
|
||||||
|
@override
|
||||||
|
String? get shortcut;
|
||||||
|
@override
|
||||||
|
Widget? get trailing;
|
||||||
@override
|
@override
|
||||||
Intent? get intent;
|
Intent? get intent;
|
||||||
@override
|
@override
|
||||||
|
ActionStyle? get actionStyle;
|
||||||
|
@override
|
||||||
|
Key? get key;
|
||||||
|
@override
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(ignore: true)
|
||||||
_$$_MenuActionCopyWith<_$_MenuAction> get copyWith =>
|
_$$_ActionItemCopyWith<_$_ActionItem> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,6 +28,7 @@ import '../oath/keys.dart';
|
|||||||
import 'message.dart';
|
import 'message.dart';
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
import 'state.dart';
|
import 'state.dart';
|
||||||
|
import 'views/keys.dart';
|
||||||
import 'views/settings_page.dart';
|
import 'views/settings_page.dart';
|
||||||
|
|
||||||
class OpenIntent extends Intent {
|
class OpenIntent extends Intent {
|
||||||
@ -100,7 +101,10 @@ Widget registerGlobalShortcuts(
|
|||||||
}),
|
}),
|
||||||
NextDeviceIntent: CallbackAction<NextDeviceIntent>(onInvoke: (_) {
|
NextDeviceIntent: CallbackAction<NextDeviceIntent>(onInvoke: (_) {
|
||||||
ref.read(withContextProvider)((context) async {
|
ref.read(withContextProvider)((context) async {
|
||||||
if (!Navigator.of(context).canPop()) {
|
// Only allow switching keys if no other views are open,
|
||||||
|
// with the exception of the drawer.
|
||||||
|
if (!Navigator.of(context).canPop() ||
|
||||||
|
scaffoldGlobalKey.currentState?.isDrawerOpen == true) {
|
||||||
final attached = ref
|
final attached = ref
|
||||||
.read(attachedDevicesProvider)
|
.read(attachedDevicesProvider)
|
||||||
.whereType<UsbYubiKeyNode>()
|
.whereType<UsbYubiKeyNode>()
|
||||||
|
109
lib/app/views/action_list.dart
Normal file
109
lib/app/views/action_list.dart
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* 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 '../../widgets/list_title.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
|
||||||
|
class ActionListItem extends StatelessWidget {
|
||||||
|
final Widget icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final Widget? trailing;
|
||||||
|
final void Function(BuildContext context)? onTap;
|
||||||
|
final ActionStyle actionStyle;
|
||||||
|
|
||||||
|
const ActionListItem({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.trailing,
|
||||||
|
this.onTap,
|
||||||
|
this.actionStyle = ActionStyle.normal,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme =
|
||||||
|
ButtonTheme.of(context).colorScheme ?? Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final (foreground, background) = switch (actionStyle) {
|
||||||
|
ActionStyle.normal => (theme.onSecondary, theme.secondary),
|
||||||
|
ActionStyle.primary => (theme.onPrimary, theme.primary),
|
||||||
|
ActionStyle.error => (theme.onError, theme.error),
|
||||||
|
};
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||||
|
leading: Opacity(
|
||||||
|
opacity: onTap != null ? 1.0 : 0.4,
|
||||||
|
child: CircleAvatar(
|
||||||
|
foregroundColor: foreground,
|
||||||
|
backgroundColor: background,
|
||||||
|
child: icon,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: trailing,
|
||||||
|
onTap: onTap != null ? () => onTap?.call(context) : null,
|
||||||
|
enabled: onTap != null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActionListSection extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<ActionListItem> children;
|
||||||
|
|
||||||
|
const ActionListSection(this.title, {super.key, required this.children});
|
||||||
|
|
||||||
|
factory ActionListSection.fromMenuActions(BuildContext context, String title,
|
||||||
|
{Key? key, required List<ActionItem> actions}) {
|
||||||
|
return ActionListSection(
|
||||||
|
key: key,
|
||||||
|
title,
|
||||||
|
children: actions.map((action) {
|
||||||
|
final intent = action.intent;
|
||||||
|
return ActionListItem(
|
||||||
|
key: action.key,
|
||||||
|
actionStyle: action.actionStyle ?? ActionStyle.normal,
|
||||||
|
icon: action.icon,
|
||||||
|
title: action.title,
|
||||||
|
subtitle: action.subtitle,
|
||||||
|
onTap: intent != null
|
||||||
|
? (context) => Actions.invoke(context, intent)
|
||||||
|
: null,
|
||||||
|
trailing: action.trailing,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
width: 360,
|
||||||
|
child: Column(children: [
|
||||||
|
ListTitle(
|
||||||
|
title,
|
||||||
|
textStyle: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
...children,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
66
lib/app/views/action_popup_menu.dart
Normal file
66
lib/app/views/action_popup_menu.dart
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022-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 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models.dart';
|
||||||
|
|
||||||
|
Future showPopupMenu(BuildContext context, Offset globalPosition,
|
||||||
|
List<ActionItem> actions) =>
|
||||||
|
showMenu(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(
|
||||||
|
globalPosition.dx,
|
||||||
|
globalPosition.dy,
|
||||||
|
globalPosition.dx,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
items: actions.map((e) => _buildMenuItem(context, e)).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
PopupMenuItem _buildMenuItem(BuildContext context, ActionItem actionItem) {
|
||||||
|
final intent = actionItem.intent;
|
||||||
|
final enabled = intent != null;
|
||||||
|
final shortcut = actionItem.shortcut;
|
||||||
|
return PopupMenuItem(
|
||||||
|
enabled: enabled,
|
||||||
|
onTap: enabled
|
||||||
|
? () {
|
||||||
|
// Wait for popup menu to close before running action.
|
||||||
|
Timer.run(() {
|
||||||
|
Actions.invoke(context, intent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: ListTile(
|
||||||
|
key: actionItem.key,
|
||||||
|
enabled: enabled,
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
minLeadingWidth: 0,
|
||||||
|
title: Text(actionItem.title),
|
||||||
|
leading: actionItem.icon,
|
||||||
|
trailing: shortcut != null
|
||||||
|
? Opacity(
|
||||||
|
opacity: 0.5,
|
||||||
|
child: Text(shortcut, textScaleFactor: 0.7),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
137
lib/app/views/app_list_item.dart
Normal file
137
lib/app/views/app_list_item.dart
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* 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/services.dart';
|
||||||
|
|
||||||
|
import '../../core/state.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../shortcuts.dart';
|
||||||
|
import 'action_popup_menu.dart';
|
||||||
|
|
||||||
|
class AppListItem extends StatefulWidget {
|
||||||
|
final Widget? leading;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final Widget? trailing;
|
||||||
|
final List<ActionItem> Function(BuildContext context)? buildPopupActions;
|
||||||
|
final Intent? activationIntent;
|
||||||
|
|
||||||
|
const AppListItem({
|
||||||
|
super.key,
|
||||||
|
this.leading,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.trailing,
|
||||||
|
this.buildPopupActions,
|
||||||
|
this.activationIntent,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _AppListItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppListItemState extends State<AppListItem> {
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
int _lastTap = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final subtitle = widget.subtitle;
|
||||||
|
final buildPopupActions = widget.buildPopupActions;
|
||||||
|
final activationIntent = widget.activationIntent;
|
||||||
|
final trailing = widget.trailing;
|
||||||
|
|
||||||
|
return Shortcuts(
|
||||||
|
shortcuts: {
|
||||||
|
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
|
||||||
|
LogicalKeySet(LogicalKeyboardKey.space): const OpenIntent(),
|
||||||
|
},
|
||||||
|
child: InkWell(
|
||||||
|
focusNode: _focusNode,
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
onSecondaryTapDown: buildPopupActions == null
|
||||||
|
? null
|
||||||
|
: (details) {
|
||||||
|
showPopupMenu(
|
||||||
|
context,
|
||||||
|
details.globalPosition,
|
||||||
|
buildPopupActions(context),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onTap: () {
|
||||||
|
if (isDesktop) {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (now - _lastTap < 500) {
|
||||||
|
setState(() {
|
||||||
|
_lastTap = 0;
|
||||||
|
});
|
||||||
|
Actions.invoke(context, activationIntent ?? const OpenIntent());
|
||||||
|
} else {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
setState(() {
|
||||||
|
_lastTap = now;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Actions.invoke<OpenIntent>(context, const OpenIntent());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: activationIntent == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Actions.invoke(context, activationIntent);
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
alignment: AlignmentDirectional.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 64),
|
||||||
|
ListTile(
|
||||||
|
leading: widget.leading,
|
||||||
|
title: Text(
|
||||||
|
widget.title,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
),
|
||||||
|
subtitle: subtitle != null
|
||||||
|
? Text(
|
||||||
|
subtitle,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: trailing == null
|
||||||
|
? null
|
||||||
|
: Focus(
|
||||||
|
skipTraversal: true,
|
||||||
|
descendantsAreTraversable: false,
|
||||||
|
child: trailing,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -16,18 +16,23 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:yubico_authenticator/core/state.dart';
|
||||||
|
|
||||||
import '../../widgets/delayed_visibility.dart';
|
import '../../widgets/delayed_visibility.dart';
|
||||||
import '../message.dart';
|
import '../message.dart';
|
||||||
import 'device_button.dart';
|
|
||||||
import 'keys.dart';
|
import 'keys.dart';
|
||||||
import 'main_drawer.dart';
|
import 'navigation.dart';
|
||||||
|
|
||||||
|
// We use global keys here to maintain the NavigatorContent between AppPages.
|
||||||
|
final _navKey = GlobalKey();
|
||||||
|
final _navExpandedKey = GlobalKey();
|
||||||
|
|
||||||
class AppPage extends StatelessWidget {
|
class AppPage extends StatelessWidget {
|
||||||
final Widget? title;
|
final Widget? title;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final List<Widget> actions;
|
final List<Widget> actions;
|
||||||
final Widget Function(BuildContext context)? keyActionsBuilder;
|
final Widget Function(BuildContext context)? keyActionsBuilder;
|
||||||
|
final bool keyActionsBadge;
|
||||||
final bool centered;
|
final bool centered;
|
||||||
final bool delayedContent;
|
final bool delayedContent;
|
||||||
final Widget Function(BuildContext context)? actionButtonBuilder;
|
final Widget Function(BuildContext context)? actionButtonBuilder;
|
||||||
@ -40,31 +45,49 @@ class AppPage extends StatelessWidget {
|
|||||||
this.keyActionsBuilder,
|
this.keyActionsBuilder,
|
||||||
this.actionButtonBuilder,
|
this.actionButtonBuilder,
|
||||||
this.delayedContent = false,
|
this.delayedContent = false,
|
||||||
|
this.keyActionsBadge = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => LayoutBuilder(
|
Widget build(BuildContext context) => LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
if (constraints.maxWidth < 540) {
|
final bool singleColumn;
|
||||||
// Single column layout
|
final bool hasRail;
|
||||||
return _buildScaffold(context, true);
|
if (isAndroid) {
|
||||||
|
final isPortrait = constraints.maxWidth < constraints.maxHeight;
|
||||||
|
singleColumn = isPortrait || constraints.maxWidth < 600;
|
||||||
|
hasRail = constraints.maxWidth > 600;
|
||||||
} else {
|
} else {
|
||||||
// Two-column layout
|
singleColumn = constraints.maxWidth < 600;
|
||||||
|
hasRail = constraints.maxWidth > 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (singleColumn) {
|
||||||
|
// Single column layout, maybe with rail
|
||||||
|
return _buildScaffold(context, true, hasRail);
|
||||||
|
} else {
|
||||||
|
// Fully expanded layout
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Row(
|
body: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 280,
|
width: 280,
|
||||||
child: DrawerTheme(
|
child: SingleChildScrollView(
|
||||||
data: DrawerTheme.of(context).copyWith(
|
child: Column(
|
||||||
// Don't color the drawer differently
|
children: [
|
||||||
surfaceTintColor: Colors.transparent,
|
_buildLogo(context),
|
||||||
|
NavigationContent(
|
||||||
|
key: _navExpandedKey,
|
||||||
|
shouldPop: false,
|
||||||
|
extended: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: const MainPageDrawer(shouldPop: false),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildScaffold(context, false),
|
child: _buildScaffold(context, false, false),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -73,7 +96,49 @@ class AppPage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildScrollView() {
|
Widget _buildLogo(BuildContext context) {
|
||||||
|
final color =
|
||||||
|
Theme.of(context).brightness == Brightness.dark ? 'white' : 'green';
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16, bottom: 12),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/graphics/yubico-$color.png',
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
height: 28,
|
||||||
|
filterQuality: FilterQuality.medium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDrawer(BuildContext context) {
|
||||||
|
return Drawer(
|
||||||
|
child: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16),
|
||||||
|
child: DrawerButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildLogo(context),
|
||||||
|
const SizedBox(width: 48),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
NavigationContent(key: _navExpandedKey, extended: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMainContent() {
|
||||||
final content = Column(
|
final content = Column(
|
||||||
children: [
|
children: [
|
||||||
child,
|
child,
|
||||||
@ -81,8 +146,7 @@ class AppPage extends StatelessWidget {
|
|||||||
Align(
|
Align(
|
||||||
alignment: centered ? Alignment.center : Alignment.centerLeft,
|
alignment: centered ? Alignment.center : Alignment.centerLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 18),
|
||||||
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 18.0),
|
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
@ -93,6 +157,7 @@ class AppPage extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
primary: false,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@ -110,7 +175,27 @@ class AppPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold _buildScaffold(BuildContext context, bool hasDrawer) {
|
Scaffold _buildScaffold(BuildContext context, bool hasDrawer, bool hasRail) {
|
||||||
|
var body =
|
||||||
|
centered ? Center(child: _buildMainContent()) : _buildMainContent();
|
||||||
|
if (hasRail) {
|
||||||
|
body = Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 72,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: NavigationContent(
|
||||||
|
key: _navKey,
|
||||||
|
shouldPop: false,
|
||||||
|
extended: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: body),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
key: scaffoldGlobalKey,
|
key: scaffoldGlobalKey,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@ -118,6 +203,20 @@ class AppPage extends StatelessWidget {
|
|||||||
titleSpacing: hasDrawer ? 2 : 8,
|
titleSpacing: hasDrawer ? 2 : 8,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
titleTextStyle: Theme.of(context).textTheme.titleLarge,
|
titleTextStyle: Theme.of(context).textTheme.titleLarge,
|
||||||
|
leadingWidth: hasRail ? 84 : null,
|
||||||
|
leading: hasRail
|
||||||
|
? const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: DrawerButton(),
|
||||||
|
)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
actions: [
|
actions: [
|
||||||
if (actionButtonBuilder == null && keyActionsBuilder != null)
|
if (actionButtonBuilder == null && keyActionsBuilder != null)
|
||||||
Padding(
|
Padding(
|
||||||
@ -127,20 +226,25 @@ class AppPage extends StatelessWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
showBlurDialog(context: context, builder: keyActionsBuilder!);
|
showBlurDialog(context: context, builder: keyActionsBuilder!);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.tune),
|
icon: keyActionsBadge
|
||||||
|
? const Badge(
|
||||||
|
child: Icon(Icons.tune),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.tune),
|
||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
tooltip: AppLocalizations.of(context)!.s_configure_yk,
|
tooltip: AppLocalizations.of(context)!.s_configure_yk,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (actionButtonBuilder != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: actionButtonBuilder?.call(context) ?? const DeviceButton(),
|
child: actionButtonBuilder!.call(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
drawer: hasDrawer ? const MainPageDrawer() : null,
|
drawer: hasDrawer ? _buildDrawer(context) : null,
|
||||||
body: centered ? Center(child: _buildScrollView()) : _buildScrollView(),
|
body: body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2022 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_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
import '../../core/state.dart';
|
|
||||||
import '../message.dart';
|
|
||||||
import 'device_avatar.dart';
|
|
||||||
import 'device_picker_dialog.dart';
|
|
||||||
|
|
||||||
class _CircledDeviceAvatar extends ConsumerWidget {
|
|
||||||
final double radius;
|
|
||||||
const _CircledDeviceAvatar(this.radius);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) => CircleAvatar(
|
|
||||||
radius: radius,
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
|
||||||
child: IconTheme(
|
|
||||||
// Force the standard icon theme
|
|
||||||
data: IconTheme.of(context),
|
|
||||||
child: DeviceAvatar.currentDevice(ref, radius: radius - 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeviceButton extends ConsumerWidget {
|
|
||||||
final double radius;
|
|
||||||
const DeviceButton({super.key, this.radius = 16});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return IconButton(
|
|
||||||
tooltip: isAndroid
|
|
||||||
? AppLocalizations.of(context)!.s_yk_information
|
|
||||||
: AppLocalizations.of(context)!.s_select_yk,
|
|
||||||
icon: _CircledDeviceAvatar(radius),
|
|
||||||
onPressed: () async {
|
|
||||||
await showBlurDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => const DevicePickerDialog(),
|
|
||||||
routeSettings: const RouteSettings(name: 'device_picker'),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
405
lib/app/views/device_picker.dart
Normal file
405
lib/app/views/device_picker.dart
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022-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_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
import '../../core/state.dart';
|
||||||
|
import '../../management/models.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import 'device_avatar.dart';
|
||||||
|
import 'keys.dart' as keys;
|
||||||
|
|
||||||
|
final _hiddenDevicesProvider =
|
||||||
|
StateNotifierProvider<_HiddenDevicesNotifier, List<String>>(
|
||||||
|
(ref) => _HiddenDevicesNotifier(ref.watch(prefProvider)));
|
||||||
|
|
||||||
|
class _HiddenDevicesNotifier extends StateNotifier<List<String>> {
|
||||||
|
static const String _key = 'DEVICE_PICKER_HIDDEN';
|
||||||
|
final SharedPreferences _prefs;
|
||||||
|
_HiddenDevicesNotifier(this._prefs) : super(_prefs.getStringList(_key) ?? []);
|
||||||
|
|
||||||
|
void showAll() {
|
||||||
|
state = [];
|
||||||
|
_prefs.setStringList(_key, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
void hideDevice(DevicePath devicePath) {
|
||||||
|
state = [...state, devicePath.key];
|
||||||
|
_prefs.setStringList(_key, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(Widget, bool)> buildDeviceList(
|
||||||
|
BuildContext context, WidgetRef ref, bool extended) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final hidden = ref.watch(_hiddenDevicesProvider);
|
||||||
|
final devices = ref
|
||||||
|
.watch(attachedDevicesProvider)
|
||||||
|
.where((e) => !hidden.contains(e.path.key))
|
||||||
|
.toList();
|
||||||
|
final currentNode = ref.watch(currentDeviceProvider);
|
||||||
|
|
||||||
|
final showUsb = isDesktop && devices.whereType<UsbYubiKeyNode>().isEmpty;
|
||||||
|
|
||||||
|
return [
|
||||||
|
if (showUsb)
|
||||||
|
(
|
||||||
|
_DeviceRow(
|
||||||
|
leading: const DeviceAvatar(child: Icon(Icons.usb)),
|
||||||
|
title: l10n.s_usb,
|
||||||
|
subtitle: l10n.l_no_yk_present,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(currentDeviceProvider.notifier).setCurrentDevice(null);
|
||||||
|
},
|
||||||
|
selected: currentNode == null,
|
||||||
|
extended: extended,
|
||||||
|
),
|
||||||
|
currentNode == null
|
||||||
|
),
|
||||||
|
...devices.map(
|
||||||
|
(e) => e.path == currentNode?.path
|
||||||
|
? (
|
||||||
|
_buildCurrentDeviceRow(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
e,
|
||||||
|
ref.watch(currentDeviceDataProvider),
|
||||||
|
extended,
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
e.map(
|
||||||
|
usbYubiKey: (node) => _buildDeviceRow(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
node,
|
||||||
|
node.info,
|
||||||
|
extended,
|
||||||
|
),
|
||||||
|
nfcReader: (node) => _NfcDeviceRow(node, extended: extended),
|
||||||
|
),
|
||||||
|
false
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class DevicePickerContent extends ConsumerWidget {
|
||||||
|
final bool extended;
|
||||||
|
const DevicePickerContent({super.key, this.extended = true});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final hidden = ref.watch(_hiddenDevicesProvider);
|
||||||
|
final devices = ref
|
||||||
|
.watch(attachedDevicesProvider)
|
||||||
|
.where((e) => !hidden.contains(e.path.key))
|
||||||
|
.toList();
|
||||||
|
final currentNode = ref.watch(currentDeviceProvider);
|
||||||
|
|
||||||
|
final showUsb = isDesktop && devices.whereType<UsbYubiKeyNode>().isEmpty;
|
||||||
|
|
||||||
|
List<Widget> children = [
|
||||||
|
if (showUsb)
|
||||||
|
_DeviceRow(
|
||||||
|
leading: const DeviceAvatar(child: Icon(Icons.usb)),
|
||||||
|
title: l10n.s_usb,
|
||||||
|
subtitle: l10n.l_no_yk_present,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(currentDeviceProvider.notifier).setCurrentDevice(null);
|
||||||
|
},
|
||||||
|
selected: currentNode == null,
|
||||||
|
extended: extended,
|
||||||
|
),
|
||||||
|
...devices.map(
|
||||||
|
(e) => e.path == currentNode?.path
|
||||||
|
? _buildCurrentDeviceRow(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
e,
|
||||||
|
ref.watch(currentDeviceDataProvider),
|
||||||
|
extended,
|
||||||
|
)
|
||||||
|
: e.map(
|
||||||
|
usbYubiKey: (node) => _buildDeviceRow(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
node,
|
||||||
|
node.info,
|
||||||
|
extended,
|
||||||
|
),
|
||||||
|
nfcReader: (node) => _NfcDeviceRow(node, extended: extended),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onSecondaryTapDown: hidden.isEmpty
|
||||||
|
? null
|
||||||
|
: (details) {
|
||||||
|
showMenu(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(
|
||||||
|
details.globalPosition.dx,
|
||||||
|
details.globalPosition.dy,
|
||||||
|
details.globalPosition.dx,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () {
|
||||||
|
ref.read(_hiddenDevicesProvider.notifier).showAll();
|
||||||
|
},
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(l10n.s_show_hidden_devices),
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getDeviceInfoString(BuildContext context, DeviceInfo info) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final serial = info.serial;
|
||||||
|
return [
|
||||||
|
if (serial != null) l10n.s_sn_serial(serial),
|
||||||
|
if (info.version.isAtLeast(1))
|
||||||
|
l10n.s_fw_version(info.version)
|
||||||
|
else
|
||||||
|
l10n.s_unknown_type,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _getDeviceStrings(
|
||||||
|
BuildContext context, DeviceNode node, AsyncValue<YubiKeyData> data) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final messages = data.whenOrNull(
|
||||||
|
data: (data) => [data.name, _getDeviceInfoString(context, data.info)],
|
||||||
|
error: (error, _) => switch (error) {
|
||||||
|
'device-inaccessible' => [node.name, l10n.s_yk_inaccessible],
|
||||||
|
'unknown-device' => [l10n.s_unknown_device],
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
) ??
|
||||||
|
[l10n.l_no_yk_present];
|
||||||
|
|
||||||
|
// Add the NFC reader name, unless it's already included (as device name, like on Android)
|
||||||
|
if (node is NfcReaderNode && !messages.contains(node.name)) {
|
||||||
|
messages.add(node.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceRow extends StatelessWidget {
|
||||||
|
final Widget leading;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final bool extended;
|
||||||
|
final bool selected;
|
||||||
|
final void Function() onTap;
|
||||||
|
|
||||||
|
const _DeviceRow({
|
||||||
|
super.key,
|
||||||
|
required this.leading,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.extended,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tooltip = '$title\n$subtitle';
|
||||||
|
if (extended) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: ListTile(
|
||||||
|
shape:
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||||
|
horizontalTitleGap: 8,
|
||||||
|
leading: IconTheme(
|
||||||
|
// Force the standard icon theme
|
||||||
|
data: IconTheme.of(context),
|
||||||
|
child: leading,
|
||||||
|
),
|
||||||
|
title: Text(title, overflow: TextOverflow.fade, softWrap: false),
|
||||||
|
subtitle:
|
||||||
|
Text(subtitle, overflow: TextOverflow.fade, softWrap: false),
|
||||||
|
dense: true,
|
||||||
|
tileColor: selected ? colorScheme.primary : null,
|
||||||
|
textColor: selected ? colorScheme.onPrimary : null,
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6.5),
|
||||||
|
child: selected
|
||||||
|
? IconButton.filled(
|
||||||
|
tooltip: tooltip,
|
||||||
|
icon: IconTheme(
|
||||||
|
// Force the standard icon theme
|
||||||
|
data: IconTheme.of(context),
|
||||||
|
child: leading,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
onPressed: onTap,
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
tooltip: tooltip,
|
||||||
|
icon: IconTheme(
|
||||||
|
// Force the standard icon theme
|
||||||
|
data: IconTheme.of(context),
|
||||||
|
child: leading,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
onPressed: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_DeviceRow _buildDeviceRow(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
DeviceNode node,
|
||||||
|
DeviceInfo? info,
|
||||||
|
bool extended,
|
||||||
|
) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final subtitle = node.when(
|
||||||
|
usbYubiKey: (_, __, ___, info) => info == null
|
||||||
|
? l10n.s_yk_inaccessible
|
||||||
|
: _getDeviceInfoString(context, info),
|
||||||
|
nfcReader: (_, __) => l10n.s_select_to_scan,
|
||||||
|
);
|
||||||
|
return _DeviceRow(
|
||||||
|
key: ValueKey(node.path.key),
|
||||||
|
leading: IconTheme(
|
||||||
|
// Force the standard icon theme
|
||||||
|
data: IconTheme.of(context),
|
||||||
|
child: DeviceAvatar.deviceNode(node),
|
||||||
|
),
|
||||||
|
title: node.name,
|
||||||
|
subtitle: subtitle,
|
||||||
|
extended: extended,
|
||||||
|
selected: false,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(currentDeviceProvider.notifier).setCurrentDevice(node);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_DeviceRow _buildCurrentDeviceRow(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
DeviceNode node,
|
||||||
|
AsyncValue<YubiKeyData> data,
|
||||||
|
bool extended,
|
||||||
|
) {
|
||||||
|
final messages = _getDeviceStrings(context, node, data);
|
||||||
|
if (messages.length > 2) {
|
||||||
|
// Don't show readername
|
||||||
|
messages.removeLast();
|
||||||
|
}
|
||||||
|
final title = messages.removeAt(0);
|
||||||
|
final subtitle = messages.join('\n');
|
||||||
|
|
||||||
|
return _DeviceRow(
|
||||||
|
key: keys.deviceInfoListTile,
|
||||||
|
leading: data.maybeWhen(
|
||||||
|
data: (data) =>
|
||||||
|
DeviceAvatar.yubiKeyData(data, radius: extended ? null : 16),
|
||||||
|
orElse: () => DeviceAvatar.deviceNode(node, radius: extended ? null : 16),
|
||||||
|
),
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
extended: extended,
|
||||||
|
selected: true,
|
||||||
|
onTap: () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NfcDeviceRow extends ConsumerWidget {
|
||||||
|
final DeviceNode node;
|
||||||
|
final bool extended;
|
||||||
|
|
||||||
|
const _NfcDeviceRow(this.node, {required this.extended});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final hidden = ref.watch(_hiddenDevicesProvider);
|
||||||
|
return GestureDetector(
|
||||||
|
onSecondaryTapDown: (details) {
|
||||||
|
showMenu(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(
|
||||||
|
details.globalPosition.dx,
|
||||||
|
details.globalPosition.dy,
|
||||||
|
details.globalPosition.dx,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
PopupMenuItem(
|
||||||
|
enabled: hidden.isNotEmpty,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(_hiddenDevicesProvider.notifier).showAll();
|
||||||
|
},
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(l10n.s_show_hidden_devices),
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
enabled: hidden.isNotEmpty,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () {
|
||||||
|
ref.read(_hiddenDevicesProvider.notifier).hideDevice(node.path);
|
||||||
|
},
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(l10n.s_hide_device),
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _buildDeviceRow(context, ref, node, null, extended),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,362 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2022 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_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
import '../../core/state.dart';
|
|
||||||
import '../../management/models.dart';
|
|
||||||
import '../models.dart';
|
|
||||||
import '../state.dart';
|
|
||||||
import 'device_avatar.dart';
|
|
||||||
import 'keys.dart';
|
|
||||||
|
|
||||||
final _hiddenDevicesProvider =
|
|
||||||
StateNotifierProvider<_HiddenDevicesNotifier, List<String>>(
|
|
||||||
(ref) => _HiddenDevicesNotifier(ref.watch(prefProvider)));
|
|
||||||
|
|
||||||
class _HiddenDevicesNotifier extends StateNotifier<List<String>> {
|
|
||||||
static const String _key = 'DEVICE_PICKER_HIDDEN';
|
|
||||||
final SharedPreferences _prefs;
|
|
||||||
_HiddenDevicesNotifier(this._prefs) : super(_prefs.getStringList(_key) ?? []);
|
|
||||||
|
|
||||||
void showAll() {
|
|
||||||
state = [];
|
|
||||||
_prefs.setStringList(_key, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
void hideDevice(DevicePath devicePath) {
|
|
||||||
state = [...state, devicePath.key];
|
|
||||||
_prefs.setStringList(_key, state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DevicePickerDialog extends StatefulWidget {
|
|
||||||
const DevicePickerDialog({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<StatefulWidget> createState() => _DevicePickerDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DevicePickerDialogState extends State<DevicePickerDialog> {
|
|
||||||
late FocusScopeNode _focus;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_focus = FocusScopeNode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_focus.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// This keeps the focus in the dialog, even if the underlying page
|
|
||||||
// changes as it does when a new device is selected.
|
|
||||||
return FocusScope(
|
|
||||||
node: _focus,
|
|
||||||
autofocus: true,
|
|
||||||
onFocusChange: (focused) {
|
|
||||||
if (!focused) {
|
|
||||||
_focus.requestFocus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const _DevicePickerContent(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DevicePickerContent extends ConsumerWidget {
|
|
||||||
const _DevicePickerContent();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final hidden = ref.watch(_hiddenDevicesProvider);
|
|
||||||
final devices = ref
|
|
||||||
.watch(attachedDevicesProvider)
|
|
||||||
.where((e) => !hidden.contains(e.path.key))
|
|
||||||
.toList();
|
|
||||||
final currentNode = ref.watch(currentDeviceProvider);
|
|
||||||
|
|
||||||
final Widget hero;
|
|
||||||
final bool showUsb;
|
|
||||||
if (currentNode != null) {
|
|
||||||
showUsb = isDesktop && devices.whereType<UsbYubiKeyNode>().isEmpty;
|
|
||||||
devices.removeWhere((e) => e.path == currentNode.path);
|
|
||||||
hero = _CurrentDeviceRow(
|
|
||||||
currentNode,
|
|
||||||
ref.watch(currentDeviceDataProvider),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
hero = Column(
|
|
||||||
children: [
|
|
||||||
_HeroAvatar(
|
|
||||||
child: DeviceAvatar(
|
|
||||||
radius: 64,
|
|
||||||
child: Icon(isAndroid ? Icons.no_cell : Icons.usb),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
title: Center(child: Text(l10n.l_no_yk_present)),
|
|
||||||
subtitle: Center(
|
|
||||||
child: Text(isAndroid ? l10n.l_insert_or_tap_yk : l10n.s_usb)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
showUsb = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Widget> others = [
|
|
||||||
if (showUsb)
|
|
||||||
ListTile(
|
|
||||||
leading: const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
|
||||||
child: DeviceAvatar(child: Icon(Icons.usb)),
|
|
||||||
),
|
|
||||||
title: Text(l10n.s_usb),
|
|
||||||
subtitle: Text(l10n.l_no_yk_present),
|
|
||||||
onTap: () {
|
|
||||||
ref.read(currentDeviceProvider.notifier).setCurrentDevice(null);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
...devices.map(
|
|
||||||
(e) => e.map(
|
|
||||||
usbYubiKey: (node) => _DeviceRow(node, info: node.info),
|
|
||||||
nfcReader: (node) => _NfcDeviceRow(node),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onSecondaryTapDown: hidden.isEmpty
|
|
||||||
? null
|
|
||||||
: (details) {
|
|
||||||
showMenu(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromLTRB(
|
|
||||||
details.globalPosition.dx,
|
|
||||||
details.globalPosition.dy,
|
|
||||||
details.globalPosition.dx,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () {
|
|
||||||
ref.read(_hiddenDevicesProvider.notifier).showAll();
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
title: Text(l10n.s_show_hidden_devices),
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: SimpleDialog(
|
|
||||||
children: [
|
|
||||||
hero,
|
|
||||||
if (others.isNotEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
child: Divider(),
|
|
||||||
),
|
|
||||||
...others,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getDeviceInfoString(BuildContext context, DeviceInfo info) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final serial = info.serial;
|
|
||||||
return [
|
|
||||||
if (serial != null) l10n.s_sn_serial(serial),
|
|
||||||
if (info.version.isAtLeast(1))
|
|
||||||
l10n.s_fw_version(info.version)
|
|
||||||
else
|
|
||||||
l10n.s_unknown_type,
|
|
||||||
].join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> _getDeviceStrings(
|
|
||||||
BuildContext context, DeviceNode node, AsyncValue<YubiKeyData> data) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final messages = data.whenOrNull(
|
|
||||||
data: (data) => [data.name, _getDeviceInfoString(context, data.info)],
|
|
||||||
error: (error, _) => switch (error) {
|
|
||||||
'device-inaccessible' => [node.name, l10n.s_yk_inaccessible],
|
|
||||||
'unknown-device' => [l10n.s_unknown_device],
|
|
||||||
_ => null,
|
|
||||||
},
|
|
||||||
) ??
|
|
||||||
[l10n.l_no_yk_present];
|
|
||||||
|
|
||||||
// Add the NFC reader name, unless it's already included (as device name, like on Android)
|
|
||||||
if (node is NfcReaderNode && !messages.contains(node.name)) {
|
|
||||||
messages.add(node.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HeroAvatar extends StatelessWidget {
|
|
||||||
final Widget child;
|
|
||||||
const _HeroAvatar({required this.child});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: RadialGradient(
|
|
||||||
colors: [
|
|
||||||
theme.colorScheme.inverseSurface.withOpacity(0.6),
|
|
||||||
theme.colorScheme.inverseSurface.withOpacity(0.25),
|
|
||||||
(DialogTheme.of(context).backgroundColor ??
|
|
||||||
theme.dialogBackgroundColor)
|
|
||||||
.withOpacity(0),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Theme(
|
|
||||||
// Give the avatar a transparent background
|
|
||||||
data: theme.copyWith(
|
|
||||||
colorScheme:
|
|
||||||
theme.colorScheme.copyWith(surfaceVariant: Colors.transparent)),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CurrentDeviceRow extends StatelessWidget {
|
|
||||||
final DeviceNode node;
|
|
||||||
final AsyncValue<YubiKeyData> data;
|
|
||||||
|
|
||||||
const _CurrentDeviceRow(this.node, this.data);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final hero = data.maybeWhen(
|
|
||||||
data: (data) => DeviceAvatar.yubiKeyData(data, radius: 64),
|
|
||||||
orElse: () => DeviceAvatar.deviceNode(node, radius: 64),
|
|
||||||
);
|
|
||||||
final messages = _getDeviceStrings(context, node, data);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
_HeroAvatar(child: hero),
|
|
||||||
ListTile(
|
|
||||||
key: deviceInfoListTile,
|
|
||||||
title: Text(messages.removeAt(0), textAlign: TextAlign.center),
|
|
||||||
isThreeLine: messages.length > 1,
|
|
||||||
subtitle: Text(messages.join('\n'), textAlign: TextAlign.center),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeviceRow extends ConsumerWidget {
|
|
||||||
final DeviceNode node;
|
|
||||||
final DeviceInfo? info;
|
|
||||||
|
|
||||||
const _DeviceRow(this.node, {this.info});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
return ListTile(
|
|
||||||
leading: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
||||||
child: DeviceAvatar.deviceNode(node),
|
|
||||||
),
|
|
||||||
title: Text(node.name),
|
|
||||||
subtitle: Text(
|
|
||||||
node.when(
|
|
||||||
usbYubiKey: (_, __, ___, info) => info == null
|
|
||||||
? l10n.s_yk_inaccessible
|
|
||||||
: _getDeviceInfoString(context, info),
|
|
||||||
nfcReader: (_, __) => l10n.s_select_to_scan,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
ref.read(currentDeviceProvider.notifier).setCurrentDevice(node);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NfcDeviceRow extends ConsumerWidget {
|
|
||||||
final DeviceNode node;
|
|
||||||
|
|
||||||
const _NfcDeviceRow(this.node);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final hidden = ref.watch(_hiddenDevicesProvider);
|
|
||||||
return GestureDetector(
|
|
||||||
onSecondaryTapDown: (details) {
|
|
||||||
showMenu(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromLTRB(
|
|
||||||
details.globalPosition.dx,
|
|
||||||
details.globalPosition.dy,
|
|
||||||
details.globalPosition.dx,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
PopupMenuItem(
|
|
||||||
enabled: hidden.isNotEmpty,
|
|
||||||
onTap: () {
|
|
||||||
ref.read(_hiddenDevicesProvider.notifier).showAll();
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
title: Text(l10n.s_show_hidden_devices),
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
enabled: hidden.isNotEmpty,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () {
|
|
||||||
ref.read(_hiddenDevicesProvider.notifier).hideDevice(node.path);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
title: Text(l10n.s_hide_device),
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: _DeviceRow(node),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
51
lib/app/views/fs_dialog.dart
Normal file
51
lib/app/views/fs_dialog.dart
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* 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 'keys.dart' as keys;
|
||||||
|
|
||||||
|
class FsDialog extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
const FsDialog({required this.child, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Dialog.fullscreen(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.background.withAlpha(100),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(child: child),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
child: TextButton.icon(
|
||||||
|
key: keys.closeButton,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
label: Text(l10n.s_close),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -30,3 +30,6 @@ const managementAppDrawer = Key('$_prefix.drawer.management');
|
|||||||
// settings page
|
// settings page
|
||||||
const themeModeSetting = Key('$_prefix.settings.theme_mode');
|
const themeModeSetting = Key('$_prefix.settings.theme_mode');
|
||||||
Key themeModeOption(ThemeMode mode) => Key('$_prefix.theme_mode.${mode.name}');
|
Key themeModeOption(ThemeMode mode) => Key('$_prefix.theme_mode.${mode.name}');
|
||||||
|
|
||||||
|
// misc buttons
|
||||||
|
const closeButton = Key('$_prefix.close_button');
|
||||||
|
@ -1,147 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2022 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 '../../management/views/management_screen.dart';
|
|
||||||
import '../message.dart';
|
|
||||||
import '../models.dart';
|
|
||||||
import '../shortcuts.dart';
|
|
||||||
import '../state.dart';
|
|
||||||
import 'keys.dart';
|
|
||||||
|
|
||||||
extension on Application {
|
|
||||||
IconData get _icon => switch (this) {
|
|
||||||
Application.oath => Icons.supervisor_account_outlined,
|
|
||||||
Application.fido => Icons.security_outlined,
|
|
||||||
Application.otp => Icons.password_outlined,
|
|
||||||
Application.piv => Icons.approval_outlined,
|
|
||||||
Application.management => Icons.construction_outlined,
|
|
||||||
Application.openpgp => Icons.key_outlined,
|
|
||||||
Application.hsmauth => Icons.key_outlined,
|
|
||||||
};
|
|
||||||
|
|
||||||
IconData get _filledIcon => switch (this) {
|
|
||||||
Application.oath => Icons.supervisor_account,
|
|
||||||
Application.fido => Icons.security,
|
|
||||||
Application.otp => Icons.password,
|
|
||||||
Application.piv => Icons.approval,
|
|
||||||
Application.management => Icons.construction,
|
|
||||||
Application.openpgp => Icons.key,
|
|
||||||
Application.hsmauth => Icons.key,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class MainPageDrawer extends ConsumerWidget {
|
|
||||||
final bool shouldPop;
|
|
||||||
const MainPageDrawer({this.shouldPop = true, super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final supportedApps = ref.watch(supportedAppsProvider);
|
|
||||||
final data = ref.watch(currentDeviceDataProvider).valueOrNull;
|
|
||||||
final color =
|
|
||||||
Theme.of(context).brightness == Brightness.dark ? 'white' : 'green';
|
|
||||||
|
|
||||||
final availableApps = data != null
|
|
||||||
? supportedApps
|
|
||||||
.where(
|
|
||||||
(app) => app.getAvailability(data) != Availability.unsupported)
|
|
||||||
.toList()
|
|
||||||
: <Application>[];
|
|
||||||
final hasManagement = availableApps.remove(Application.management);
|
|
||||||
|
|
||||||
return NavigationDrawer(
|
|
||||||
selectedIndex: availableApps.indexOf(ref.watch(currentAppProvider)),
|
|
||||||
onDestinationSelected: (index) {
|
|
||||||
if (shouldPop) Navigator.of(context).pop();
|
|
||||||
|
|
||||||
if (index < availableApps.length) {
|
|
||||||
// Switch to selected app
|
|
||||||
final app = availableApps[index];
|
|
||||||
ref.read(currentAppProvider.notifier).setCurrentApp(app);
|
|
||||||
} else {
|
|
||||||
// Handle action
|
|
||||||
index -= availableApps.length;
|
|
||||||
|
|
||||||
if (!hasManagement) {
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (index) {
|
|
||||||
case 0:
|
|
||||||
showBlurDialog(
|
|
||||||
context: context,
|
|
||||||
// data must be non-null when index == 0
|
|
||||||
builder: (context) => ManagementScreen(data!),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
Actions.maybeInvoke(context, const SettingsIntent());
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
Actions.maybeInvoke(context, const AboutIntent());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 19.0, left: 30.0, bottom: 12.0),
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/graphics/yubico-$color.png',
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
height: 28,
|
|
||||||
filterQuality: FilterQuality.medium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(indent: 16.0, endIndent: 28.0),
|
|
||||||
if (data != null) ...[
|
|
||||||
// Normal YubiKey Applications
|
|
||||||
...availableApps.map((app) => NavigationDrawerDestination(
|
|
||||||
label: Text(app.getDisplayName(l10n)),
|
|
||||||
icon: Icon(app._icon),
|
|
||||||
selectedIcon: Icon(app._filledIcon),
|
|
||||||
)),
|
|
||||||
// Management app
|
|
||||||
if (hasManagement) ...[
|
|
||||||
NavigationDrawerDestination(
|
|
||||||
key: managementAppDrawer,
|
|
||||||
label: Text(
|
|
||||||
l10n.s_toggle_applications,
|
|
||||||
),
|
|
||||||
icon: Icon(Application.management._icon),
|
|
||||||
selectedIcon: Icon(Application.management._filledIcon),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const Divider(indent: 16.0, endIndent: 28.0),
|
|
||||||
],
|
|
||||||
// Non-YubiKey pages
|
|
||||||
NavigationDrawerDestination(
|
|
||||||
label: Text(l10n.s_settings),
|
|
||||||
icon: const Icon(Icons.settings_outlined),
|
|
||||||
),
|
|
||||||
NavigationDrawerDestination(
|
|
||||||
label: Text(l10n.s_help_and_about),
|
|
||||||
icon: const Icon(Icons.help_outline),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -26,6 +26,7 @@ import '../../fido/views/fido_screen.dart';
|
|||||||
import '../../oath/models.dart';
|
import '../../oath/models.dart';
|
||||||
import '../../oath/views/add_account_page.dart';
|
import '../../oath/views/add_account_page.dart';
|
||||||
import '../../oath/views/oath_screen.dart';
|
import '../../oath/views/oath_screen.dart';
|
||||||
|
import '../../piv/views/piv_screen.dart';
|
||||||
import '../../widgets/custom_icons.dart';
|
import '../../widgets/custom_icons.dart';
|
||||||
import '../message.dart';
|
import '../message.dart';
|
||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
@ -161,6 +162,7 @@ class MainPage extends ConsumerWidget {
|
|||||||
return switch (app) {
|
return switch (app) {
|
||||||
Application.oath => OathScreen(data.node.path),
|
Application.oath => OathScreen(data.node.path),
|
||||||
Application.fido => FidoScreen(data),
|
Application.fido => FidoScreen(data),
|
||||||
|
Application.piv => PivScreen(data.node.path),
|
||||||
_ => MessagePage(
|
_ => MessagePage(
|
||||||
header: l10n.s_app_not_supported,
|
header: l10n.s_app_not_supported,
|
||||||
message: l10n.l_app_not_supported_desc,
|
message: l10n.l_app_not_supported_desc,
|
||||||
|
@ -27,6 +27,7 @@ class MessagePage extends StatelessWidget {
|
|||||||
final bool delayedContent;
|
final bool delayedContent;
|
||||||
final Widget Function(BuildContext context)? keyActionsBuilder;
|
final Widget Function(BuildContext context)? keyActionsBuilder;
|
||||||
final Widget Function(BuildContext context)? actionButtonBuilder;
|
final Widget Function(BuildContext context)? actionButtonBuilder;
|
||||||
|
final bool keyActionsBadge;
|
||||||
|
|
||||||
const MessagePage({
|
const MessagePage({
|
||||||
super.key,
|
super.key,
|
||||||
@ -38,6 +39,7 @@ class MessagePage extends StatelessWidget {
|
|||||||
this.keyActionsBuilder,
|
this.keyActionsBuilder,
|
||||||
this.actionButtonBuilder,
|
this.actionButtonBuilder,
|
||||||
this.delayedContent = false,
|
this.delayedContent = false,
|
||||||
|
this.keyActionsBadge = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -46,6 +48,7 @@ class MessagePage extends StatelessWidget {
|
|||||||
centered: true,
|
centered: true,
|
||||||
actions: actions,
|
actions: actions,
|
||||||
keyActionsBuilder: keyActionsBuilder,
|
keyActionsBuilder: keyActionsBuilder,
|
||||||
|
keyActionsBadge: keyActionsBadge,
|
||||||
actionButtonBuilder: actionButtonBuilder,
|
actionButtonBuilder: actionButtonBuilder,
|
||||||
delayedContent: delayedContent,
|
delayedContent: delayedContent,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
213
lib/app/views/navigation.dart
Normal file
213
lib/app/views/navigation.dart
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
/*
|
||||||
|
* 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_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
import '../../management/views/management_screen.dart';
|
||||||
|
import '../message.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../shortcuts.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import 'device_picker.dart';
|
||||||
|
import 'keys.dart';
|
||||||
|
|
||||||
|
class NavigationItem extends StatelessWidget {
|
||||||
|
final Widget leading;
|
||||||
|
final String title;
|
||||||
|
final bool collapsed;
|
||||||
|
final bool selected;
|
||||||
|
final void Function() onTap;
|
||||||
|
|
||||||
|
const NavigationItem({
|
||||||
|
super.key,
|
||||||
|
required this.leading,
|
||||||
|
required this.title,
|
||||||
|
this.collapsed = false,
|
||||||
|
this.selected = false,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: selected
|
||||||
|
? Theme(
|
||||||
|
data: theme.copyWith(
|
||||||
|
colorScheme: colorScheme.copyWith(
|
||||||
|
primary: colorScheme.secondaryContainer,
|
||||||
|
onPrimary: colorScheme.onSecondaryContainer)),
|
||||||
|
child: IconButton.filled(
|
||||||
|
icon: leading,
|
||||||
|
tooltip: title,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
onPressed: onTap,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: leading,
|
||||||
|
tooltip: title,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
onPressed: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(48)),
|
||||||
|
leading: leading,
|
||||||
|
title: Text(title),
|
||||||
|
minVerticalPadding: 16,
|
||||||
|
onTap: onTap,
|
||||||
|
tileColor: selected ? colorScheme.secondaryContainer : null,
|
||||||
|
textColor: selected ? colorScheme.onSecondaryContainer : null,
|
||||||
|
iconColor: selected ? colorScheme.onSecondaryContainer : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on Application {
|
||||||
|
IconData get _icon => switch (this) {
|
||||||
|
Application.oath => Icons.supervisor_account_outlined,
|
||||||
|
Application.fido => Icons.security_outlined,
|
||||||
|
Application.otp => Icons.password_outlined,
|
||||||
|
Application.piv => Icons.approval_outlined,
|
||||||
|
Application.management => Icons.construction_outlined,
|
||||||
|
Application.openpgp => Icons.key_outlined,
|
||||||
|
Application.hsmauth => Icons.key_outlined,
|
||||||
|
};
|
||||||
|
|
||||||
|
IconData get _filledIcon => switch (this) {
|
||||||
|
Application.oath => Icons.supervisor_account,
|
||||||
|
Application.fido => Icons.security,
|
||||||
|
Application.otp => Icons.password,
|
||||||
|
Application.piv => Icons.approval,
|
||||||
|
Application.management => Icons.construction,
|
||||||
|
Application.openpgp => Icons.key,
|
||||||
|
Application.hsmauth => Icons.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class NavigationContent extends ConsumerWidget {
|
||||||
|
final bool shouldPop;
|
||||||
|
final bool extended;
|
||||||
|
const NavigationContent(
|
||||||
|
{super.key, this.shouldPop = true, this.extended = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final supportedApps = ref.watch(supportedAppsProvider);
|
||||||
|
final data = ref.watch(currentDeviceDataProvider).valueOrNull;
|
||||||
|
|
||||||
|
final availableApps = data != null
|
||||||
|
? supportedApps
|
||||||
|
.where(
|
||||||
|
(app) => app.getAvailability(data) != Availability.unsupported)
|
||||||
|
.toList()
|
||||||
|
: <Application>[];
|
||||||
|
final hasManagement = availableApps.remove(Application.management);
|
||||||
|
final currentApp = ref.watch(currentAppProvider);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: DevicePickerContent(extended: extended),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (data != null) ...[
|
||||||
|
// Normal YubiKey Applications
|
||||||
|
...availableApps.map((app) => NavigationItem(
|
||||||
|
title: app.getDisplayName(l10n),
|
||||||
|
leading: app == currentApp
|
||||||
|
? Icon(app._filledIcon)
|
||||||
|
: Icon(app._icon),
|
||||||
|
collapsed: !extended,
|
||||||
|
selected: app == currentApp,
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(currentAppProvider.notifier)
|
||||||
|
.setCurrentApp(app);
|
||||||
|
if (shouldPop) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
// Management app
|
||||||
|
if (hasManagement) ...[
|
||||||
|
NavigationItem(
|
||||||
|
key: managementAppDrawer,
|
||||||
|
leading: Icon(Application.management._icon),
|
||||||
|
title: l10n.s_toggle_applications,
|
||||||
|
collapsed: !extended,
|
||||||
|
onTap: () {
|
||||||
|
showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
// data must be non-null when index == 0
|
||||||
|
builder: (context) => ManagementScreen(data),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Non-YubiKey pages
|
||||||
|
NavigationItem(
|
||||||
|
leading: const Icon(Icons.settings_outlined),
|
||||||
|
title: l10n.s_settings,
|
||||||
|
collapsed: !extended,
|
||||||
|
onTap: () {
|
||||||
|
if (shouldPop) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
Actions.maybeInvoke(context, const SettingsIntent());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
NavigationItem(
|
||||||
|
leading: const Icon(Icons.help_outline),
|
||||||
|
title: l10n.s_help_and_about,
|
||||||
|
collapsed: !extended,
|
||||||
|
onTap: () {
|
||||||
|
if (shouldPop) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
Actions.maybeInvoke(context, const AboutIntent());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../management/models.dart';
|
import '../management/models.dart';
|
||||||
|
|
||||||
@ -152,3 +153,5 @@ class Version with _$Version implements Comparable<Version> {
|
|||||||
return a - b;
|
return a - b;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final DateFormat dateFormatter = DateFormat('yyyy-MM-dd');
|
||||||
|
@ -18,6 +18,8 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../app/models.dart';
|
||||||
|
|
||||||
bool get isDesktop {
|
bool get isDesktop {
|
||||||
return const [
|
return const [
|
||||||
TargetPlatform.windows,
|
TargetPlatform.windows,
|
||||||
@ -36,21 +38,16 @@ final prefProvider = Provider<SharedPreferences>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
abstract class ApplicationStateNotifier<T>
|
abstract class ApplicationStateNotifier<T>
|
||||||
extends StateNotifier<AsyncValue<T>> {
|
extends AutoDisposeFamilyAsyncNotifier<T, DevicePath> {
|
||||||
ApplicationStateNotifier() : super(const AsyncValue.loading());
|
ApplicationStateNotifier() : super();
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
Future<void> updateState(Future<T> Function() guarded) async {
|
Future<void> updateState(Future<T> Function() guarded) async {
|
||||||
final result = await AsyncValue.guard(guarded);
|
state = await AsyncValue.guard(guarded);
|
||||||
if (mounted) {
|
|
||||||
state = result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void setData(T value) {
|
void setData(T value) {
|
||||||
if (mounted) {
|
|
||||||
state = AsyncValue.data(value);
|
state = AsyncValue.data(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@ -23,6 +23,7 @@ import 'package:logging/logging.dart';
|
|||||||
import 'package:yubico_authenticator/app/logging.dart';
|
import 'package:yubico_authenticator/app/logging.dart';
|
||||||
|
|
||||||
import '../../app/models.dart';
|
import '../../app/models.dart';
|
||||||
|
import '../../app/state.dart';
|
||||||
import '../../fido/models.dart';
|
import '../../fido/models.dart';
|
||||||
import '../../fido/state.dart';
|
import '../../fido/state.dart';
|
||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
@ -45,47 +46,70 @@ final _sessionProvider =
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final desktopFidoState = StateNotifierProvider.autoDispose
|
final desktopFidoState = AsyncNotifierProvider.autoDispose
|
||||||
.family<FidoStateNotifier, AsyncValue<FidoState>, DevicePath>(
|
.family<FidoStateNotifier, FidoState, DevicePath>(
|
||||||
(ref, devicePath) {
|
_DesktopFidoStateNotifier.new);
|
||||||
final session = ref.watch(_sessionProvider(devicePath));
|
|
||||||
|
class _DesktopFidoStateNotifier extends FidoStateNotifier {
|
||||||
|
late RpcNodeSession _session;
|
||||||
|
late StateController<String?> _pinController;
|
||||||
|
|
||||||
|
FutureOr<FidoState> _build(DevicePath devicePath) async {
|
||||||
|
var result = await _session.command('get');
|
||||||
|
FidoState fidoState = FidoState.fromJson(result['data']);
|
||||||
|
if (fidoState.hasPin && !fidoState.unlocked) {
|
||||||
|
final pin = ref.read(_pinProvider(devicePath));
|
||||||
|
if (pin != null) {
|
||||||
|
await unlock(pin);
|
||||||
|
result = await _session.command('get');
|
||||||
|
fidoState = FidoState.fromJson(result['data']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.debug('application status', jsonEncode(fidoState));
|
||||||
|
return fidoState;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<FidoState> build(DevicePath devicePath) async {
|
||||||
|
_session = ref.watch(_sessionProvider(devicePath));
|
||||||
if (Platform.isWindows) {
|
if (Platform.isWindows) {
|
||||||
// Make sure to rebuild if isAdmin changes
|
// Make sure to rebuild if isAdmin changes
|
||||||
ref.watch(rpcStateProvider.select((state) => state.isAdmin));
|
ref.watch(rpcStateProvider.select((state) => state.isAdmin));
|
||||||
}
|
}
|
||||||
final notifier = _DesktopFidoStateNotifier(
|
|
||||||
session,
|
ref.listen<WindowState>(
|
||||||
ref.watch(_pinProvider(devicePath).notifier),
|
windowStateProvider,
|
||||||
);
|
(prev, next) async {
|
||||||
session.setErrorHandler('state-reset', (_) async {
|
if (prev?.active == false && next.active) {
|
||||||
ref.invalidate(_sessionProvider(devicePath));
|
// Refresh state on active
|
||||||
});
|
final newState = await _build(devicePath);
|
||||||
session.setErrorHandler('auth-required', (_) async {
|
if (state.valueOrNull != newState) {
|
||||||
final pin = ref.read(_pinProvider(devicePath));
|
state = AsyncValue.data(newState);
|
||||||
if (pin != null) {
|
}
|
||||||
await notifier.unlock(pin);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
ref.onDispose(() {
|
|
||||||
session.unsetErrorHandler('auth-required');
|
|
||||||
});
|
|
||||||
ref.onDispose(() {
|
|
||||||
session.unsetErrorHandler('state-reset');
|
|
||||||
});
|
|
||||||
return notifier..refresh();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
class _DesktopFidoStateNotifier extends FidoStateNotifier {
|
_pinController = ref.watch(_pinProvider(devicePath).notifier);
|
||||||
final RpcNodeSession _session;
|
_session.setErrorHandler('state-reset', (_) async {
|
||||||
final StateController<String?> _pinController;
|
ref.invalidate(_sessionProvider(devicePath));
|
||||||
_DesktopFidoStateNotifier(this._session, this._pinController) : super();
|
|
||||||
|
|
||||||
Future<void> refresh() => updateState(() async {
|
|
||||||
final result = await _session.command('get');
|
|
||||||
_log.debug('application status', jsonEncode(result));
|
|
||||||
return FidoState.fromJson(result['data']);
|
|
||||||
});
|
});
|
||||||
|
_session.setErrorHandler('auth-required', (_) async {
|
||||||
|
final pin = ref.read(_pinProvider(devicePath));
|
||||||
|
if (pin != null) {
|
||||||
|
await unlock(pin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ref.onDispose(() {
|
||||||
|
_session.unsetErrorHandler('auth-required');
|
||||||
|
});
|
||||||
|
ref.onDispose(() {
|
||||||
|
_session.unsetErrorHandler('state-reset');
|
||||||
|
});
|
||||||
|
|
||||||
|
return _build(devicePath);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<InteractionEvent> reset() {
|
Stream<InteractionEvent> reset() {
|
||||||
@ -105,8 +129,8 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
|
|||||||
controller.onListen = () async {
|
controller.onListen = () async {
|
||||||
try {
|
try {
|
||||||
await _session.command('reset', signal: signaler);
|
await _session.command('reset', signal: signaler);
|
||||||
await refresh();
|
|
||||||
await controller.sink.close();
|
await controller.sink.close();
|
||||||
|
ref.invalidateSelf();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
controller.sink.addError(e);
|
controller.sink.addError(e);
|
||||||
}
|
}
|
||||||
@ -151,22 +175,38 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final desktopFingerprintProvider = StateNotifierProvider.autoDispose.family<
|
final desktopFingerprintProvider = AsyncNotifierProvider.autoDispose
|
||||||
FidoFingerprintsNotifier, AsyncValue<List<Fingerprint>>, DevicePath>(
|
.family<FidoFingerprintsNotifier, List<Fingerprint>, DevicePath>(
|
||||||
(ref, devicePath) => _DesktopFidoFingerprintsNotifier(
|
_DesktopFidoFingerprintsNotifier.new);
|
||||||
ref.watch(_sessionProvider(devicePath)),
|
|
||||||
));
|
|
||||||
|
|
||||||
class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
|
class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
|
||||||
final RpcNodeSession _session;
|
late RpcNodeSession _session;
|
||||||
|
|
||||||
_DesktopFidoFingerprintsNotifier(this._session) {
|
@override
|
||||||
_refresh();
|
FutureOr<List<Fingerprint>> build(DevicePath devicePath) async {
|
||||||
|
_session = ref.watch(_sessionProvider(devicePath));
|
||||||
|
ref.watch(fidoStateProvider(devicePath));
|
||||||
|
|
||||||
|
// Refresh on active
|
||||||
|
ref.listen<WindowState>(
|
||||||
|
windowStateProvider,
|
||||||
|
(prev, next) async {
|
||||||
|
if (prev?.active == false && next.active) {
|
||||||
|
// Refresh state on active
|
||||||
|
final newState = await _build(devicePath);
|
||||||
|
if (state.valueOrNull != newState) {
|
||||||
|
state = AsyncValue.data(newState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return _build(devicePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
FutureOr<List<Fingerprint>> _build(DevicePath devicePath) async {
|
||||||
final result = await _session.command('fingerprints');
|
final result = await _session.command('fingerprints');
|
||||||
setItems((result['children'] as Map<String, dynamic>)
|
return List.unmodifiable((result['children'] as Map<String, dynamic>)
|
||||||
.entries
|
.entries
|
||||||
.map((e) => Fingerprint(e.key, e.value['name']))
|
.map((e) => Fingerprint(e.key, e.value['name']))
|
||||||
.toList());
|
.toList());
|
||||||
@ -176,7 +216,7 @@ class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
|
|||||||
Future<void> deleteFingerprint(Fingerprint fingerprint) async {
|
Future<void> deleteFingerprint(Fingerprint fingerprint) async {
|
||||||
await _session
|
await _session
|
||||||
.command('delete', target: ['fingerprints', fingerprint.templateId]);
|
.command('delete', target: ['fingerprints', fingerprint.templateId]);
|
||||||
await _refresh();
|
ref.invalidate(fidoStateProvider(_session.devicePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -184,15 +224,11 @@ class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
|
|||||||
final controller = StreamController<FingerprintEvent>();
|
final controller = StreamController<FingerprintEvent>();
|
||||||
final signaler = Signaler();
|
final signaler = Signaler();
|
||||||
signaler.signals.listen((signal) {
|
signaler.signals.listen((signal) {
|
||||||
switch (signal.status) {
|
controller.sink.add(switch (signal.status) {
|
||||||
case 'capture':
|
'capture' => FingerprintEvent.capture(signal.body['remaining']),
|
||||||
controller.sink
|
'capture-error' => FingerprintEvent.error(signal.body['code']),
|
||||||
.add(FingerprintEvent.capture(signal.body['remaining']));
|
final other => throw UnimplementedError(other),
|
||||||
break;
|
});
|
||||||
case 'capture-error':
|
|
||||||
controller.sink.add(FingerprintEvent.error(signal.body['code']));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.onCancel = () {
|
controller.onCancel = () {
|
||||||
@ -210,7 +246,7 @@ class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
|
|||||||
);
|
);
|
||||||
controller.sink
|
controller.sink
|
||||||
.add(FingerprintEvent.complete(Fingerprint.fromJson(result)));
|
.add(FingerprintEvent.complete(Fingerprint.fromJson(result)));
|
||||||
await _refresh();
|
ref.invalidate(fidoStateProvider(_session.devicePath));
|
||||||
await controller.sink.close();
|
await controller.sink.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
controller.sink.addError(e);
|
controller.sink.addError(e);
|
||||||
@ -227,25 +263,41 @@ class _DesktopFidoFingerprintsNotifier extends FidoFingerprintsNotifier {
|
|||||||
target: ['fingerprints', fingerprint.templateId],
|
target: ['fingerprints', fingerprint.templateId],
|
||||||
params: {'name': name});
|
params: {'name': name});
|
||||||
final renamed = fingerprint.copyWith(name: name);
|
final renamed = fingerprint.copyWith(name: name);
|
||||||
await _refresh();
|
ref.invalidate(fidoStateProvider(_session.devicePath));
|
||||||
return renamed;
|
return renamed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final desktopCredentialProvider = StateNotifierProvider.autoDispose.family<
|
final desktopCredentialProvider = AsyncNotifierProvider.autoDispose
|
||||||
FidoCredentialsNotifier, AsyncValue<List<FidoCredential>>, DevicePath>(
|
.family<FidoCredentialsNotifier, List<FidoCredential>, DevicePath>(
|
||||||
(ref, devicePath) => _DesktopFidoCredentialsNotifier(
|
_DesktopFidoCredentialsNotifier.new);
|
||||||
ref.watch(_sessionProvider(devicePath)),
|
|
||||||
));
|
|
||||||
|
|
||||||
class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier {
|
class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier {
|
||||||
final RpcNodeSession _session;
|
late RpcNodeSession _session;
|
||||||
|
|
||||||
_DesktopFidoCredentialsNotifier(this._session) {
|
@override
|
||||||
_refresh();
|
FutureOr<List<FidoCredential>> build(DevicePath devicePath) async {
|
||||||
|
_session = ref.watch(_sessionProvider(devicePath));
|
||||||
|
ref.watch(fidoStateProvider(devicePath));
|
||||||
|
|
||||||
|
// Refresh on active
|
||||||
|
ref.listen<WindowState>(
|
||||||
|
windowStateProvider,
|
||||||
|
(prev, next) async {
|
||||||
|
if (prev?.active == false && next.active) {
|
||||||
|
// Refresh state on active
|
||||||
|
final newState = await _build(devicePath);
|
||||||
|
if (state.valueOrNull != newState) {
|
||||||
|
state = AsyncValue.data(newState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return _build(devicePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
FutureOr<List<FidoCredential>> _build(DevicePath devicePath) async {
|
||||||
final List<FidoCredential> creds = [];
|
final List<FidoCredential> creds = [];
|
||||||
final rps = await _session.command('credentials');
|
final rps = await _session.command('credentials');
|
||||||
for (final rpId in (rps['children'] as Map<String, dynamic>).keys) {
|
for (final rpId in (rps['children'] as Map<String, dynamic>).keys) {
|
||||||
@ -258,7 +310,7 @@ class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier {
|
|||||||
userName: e.value['user_name']));
|
userName: e.value['user_name']));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setItems(creds);
|
return List.unmodifiable(creds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -268,6 +320,6 @@ class _DesktopFidoCredentialsNotifier extends FidoCredentialsNotifier {
|
|||||||
credential.rpId,
|
credential.rpId,
|
||||||
credential.credentialId,
|
credential.credentialId,
|
||||||
]);
|
]);
|
||||||
await _refresh();
|
ref.invalidate(fidoStateProvider(_session.devicePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,11 +41,13 @@ import '../core/state.dart';
|
|||||||
import '../fido/state.dart';
|
import '../fido/state.dart';
|
||||||
import '../management/state.dart';
|
import '../management/state.dart';
|
||||||
import '../oath/state.dart';
|
import '../oath/state.dart';
|
||||||
|
import '../piv/state.dart';
|
||||||
import '../version.dart';
|
import '../version.dart';
|
||||||
import 'devices.dart';
|
import 'devices.dart';
|
||||||
import 'fido/state.dart';
|
import 'fido/state.dart';
|
||||||
import 'management/state.dart';
|
import 'management/state.dart';
|
||||||
import 'oath/state.dart';
|
import 'oath/state.dart';
|
||||||
|
import 'piv/state.dart';
|
||||||
import 'qr_scanner.dart';
|
import 'qr_scanner.dart';
|
||||||
import 'rpc.dart';
|
import 'rpc.dart';
|
||||||
import 'state.dart';
|
import 'state.dart';
|
||||||
@ -177,6 +179,7 @@ Future<Widget> initialize(List<String> argv) async {
|
|||||||
supportedAppsProvider.overrideWithValue([
|
supportedAppsProvider.overrideWithValue([
|
||||||
Application.oath,
|
Application.oath,
|
||||||
Application.fido,
|
Application.fido,
|
||||||
|
Application.piv,
|
||||||
Application.management,
|
Application.management,
|
||||||
]),
|
]),
|
||||||
prefProvider.overrideWithValue(prefs),
|
prefProvider.overrideWithValue(prefs),
|
||||||
@ -184,6 +187,12 @@ Future<Widget> initialize(List<String> argv) async {
|
|||||||
windowStateProvider.overrideWith(
|
windowStateProvider.overrideWith(
|
||||||
(ref) => ref.watch(desktopWindowStateProvider),
|
(ref) => ref.watch(desktopWindowStateProvider),
|
||||||
),
|
),
|
||||||
|
clipboardProvider.overrideWith(
|
||||||
|
(ref) => ref.watch(desktopClipboardProvider),
|
||||||
|
),
|
||||||
|
supportedThemesProvider.overrideWith(
|
||||||
|
(ref) => ref.watch(desktopSupportedThemesProvider),
|
||||||
|
),
|
||||||
attachedDevicesProvider.overrideWith(
|
attachedDevicesProvider.overrideWith(
|
||||||
() => DesktopDevicesNotifier(),
|
() => DesktopDevicesNotifier(),
|
||||||
),
|
),
|
||||||
@ -206,12 +215,9 @@ Future<Widget> initialize(List<String> argv) async {
|
|||||||
fidoStateProvider.overrideWithProvider(desktopFidoState),
|
fidoStateProvider.overrideWithProvider(desktopFidoState),
|
||||||
fingerprintProvider.overrideWithProvider(desktopFingerprintProvider),
|
fingerprintProvider.overrideWithProvider(desktopFingerprintProvider),
|
||||||
credentialProvider.overrideWithProvider(desktopCredentialProvider),
|
credentialProvider.overrideWithProvider(desktopCredentialProvider),
|
||||||
clipboardProvider.overrideWith(
|
// PIV
|
||||||
(ref) => ref.watch(desktopClipboardProvider),
|
pivStateProvider.overrideWithProvider(desktopPivState),
|
||||||
),
|
pivSlotsProvider.overrideWithProvider(desktopPivSlots),
|
||||||
supportedThemesProvider.overrideWith(
|
|
||||||
(ref) => ref.watch(desktopSupportedThemesProvider),
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
child: YubicoAuthenticatorApp(
|
child: YubicoAuthenticatorApp(
|
||||||
page: Consumer(
|
page: Consumer(
|
||||||
|
@ -36,30 +36,28 @@ final _sessionProvider =
|
|||||||
RpcNodeSession(ref.watch(rpcProvider).requireValue, devicePath, []),
|
RpcNodeSession(ref.watch(rpcProvider).requireValue, devicePath, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
final desktopManagementState = StateNotifierProvider.autoDispose
|
final desktopManagementState = AsyncNotifierProvider.autoDispose
|
||||||
.family<ManagementStateNotifier, AsyncValue<DeviceInfo>, DevicePath>(
|
.family<ManagementStateNotifier, DeviceInfo, DevicePath>(
|
||||||
(ref, devicePath) {
|
_DesktopManagementStateNotifier.new);
|
||||||
|
|
||||||
|
class _DesktopManagementStateNotifier extends ManagementStateNotifier {
|
||||||
|
late RpcNodeSession _session;
|
||||||
|
List<String> _subpath = [];
|
||||||
|
_DesktopManagementStateNotifier() : super();
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<DeviceInfo> build(DevicePath devicePath) async {
|
||||||
// Make sure to rebuild if currentDevice changes (as on reboot)
|
// Make sure to rebuild if currentDevice changes (as on reboot)
|
||||||
ref.watch(currentDeviceProvider);
|
ref.watch(currentDeviceProvider);
|
||||||
final session = ref.watch(_sessionProvider(devicePath));
|
|
||||||
final notifier = _DesktopManagementStateNotifier(ref, session);
|
_session = ref.watch(_sessionProvider(devicePath));
|
||||||
session.setErrorHandler('state-reset', (_) async {
|
_session.setErrorHandler('state-reset', (_) async {
|
||||||
ref.invalidate(_sessionProvider(devicePath));
|
ref.invalidate(_sessionProvider(devicePath));
|
||||||
});
|
});
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
session.unsetErrorHandler('state-reset');
|
_session.unsetErrorHandler('state-reset');
|
||||||
});
|
});
|
||||||
return notifier..refresh();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
class _DesktopManagementStateNotifier extends ManagementStateNotifier {
|
|
||||||
final Ref _ref;
|
|
||||||
final RpcNodeSession _session;
|
|
||||||
List<String> _subpath = [];
|
|
||||||
_DesktopManagementStateNotifier(this._ref, this._session) : super();
|
|
||||||
|
|
||||||
Future<void> refresh() => updateState(() async {
|
|
||||||
final result = await _session.command('get');
|
final result = await _session.command('get');
|
||||||
final info = DeviceInfo.fromJson(result['data']['info']);
|
final info = DeviceInfo.fromJson(result['data']['info']);
|
||||||
final interfaces = (result['children'] as Map).keys.toSet();
|
final interfaces = (result['children'] as Map).keys.toSet();
|
||||||
@ -82,7 +80,7 @@ class _DesktopManagementStateNotifier extends ManagementStateNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw 'Failed connection over all interfaces';
|
throw 'Failed connection over all interfaces';
|
||||||
});
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setMode(
|
Future<void> setMode(
|
||||||
@ -94,7 +92,7 @@ class _DesktopManagementStateNotifier extends ManagementStateNotifier {
|
|||||||
'challenge_response_timeout': challengeResponseTimeout,
|
'challenge_response_timeout': challengeResponseTimeout,
|
||||||
'auto_eject_timeout': autoEjectTimeout,
|
'auto_eject_timeout': autoEjectTimeout,
|
||||||
});
|
});
|
||||||
_ref.read(attachedDevicesProvider.notifier).refresh();
|
ref.read(attachedDevicesProvider.notifier).refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -111,6 +109,6 @@ class _DesktopManagementStateNotifier extends ManagementStateNotifier {
|
|||||||
'new_lock_code': newLockCode,
|
'new_lock_code': newLockCode,
|
||||||
'reboot': reboot,
|
'reboot': reboot,
|
||||||
});
|
});
|
||||||
_ref.read(attachedDevicesProvider.notifier).refresh();
|
ref.read(attachedDevicesProvider.notifier).refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -57,56 +57,48 @@ class _LockKeyNotifier extends StateNotifier<String?> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final desktopOathState = StateNotifierProvider.autoDispose
|
final desktopOathState = AsyncNotifierProvider.autoDispose
|
||||||
.family<OathStateNotifier, AsyncValue<OathState>, DevicePath>(
|
.family<OathStateNotifier, OathState, DevicePath>(
|
||||||
(ref, devicePath) {
|
_DesktopOathStateNotifier.new);
|
||||||
final session = ref.watch(_sessionProvider(devicePath));
|
|
||||||
final notifier = _DesktopOathStateNotifier(session, ref);
|
class _DesktopOathStateNotifier extends OathStateNotifier {
|
||||||
session
|
late RpcNodeSession _session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<OathState> build(DevicePath devicePath) async {
|
||||||
|
_session = ref.watch(_sessionProvider(devicePath));
|
||||||
|
_session
|
||||||
..setErrorHandler('state-reset', (_) async {
|
..setErrorHandler('state-reset', (_) async {
|
||||||
ref.invalidate(_sessionProvider(devicePath));
|
ref.invalidate(_sessionProvider(devicePath));
|
||||||
})
|
})
|
||||||
..setErrorHandler('auth-required', (_) async {
|
..setErrorHandler('auth-required', (_) async {
|
||||||
await notifier.refresh();
|
ref.invalidateSelf();
|
||||||
});
|
});
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
session
|
_session
|
||||||
..unsetErrorHandler('state-reset')
|
..unsetErrorHandler('state-reset')
|
||||||
..unsetErrorHandler('auth-required');
|
..unsetErrorHandler('auth-required');
|
||||||
});
|
});
|
||||||
return notifier..refresh();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
class _DesktopOathStateNotifier extends OathStateNotifier {
|
|
||||||
final RpcNodeSession _session;
|
|
||||||
final Ref _ref;
|
|
||||||
_DesktopOathStateNotifier(this._session, this._ref) : super();
|
|
||||||
|
|
||||||
refresh() => updateState(() async {
|
|
||||||
final result = await _session.command('get');
|
final result = await _session.command('get');
|
||||||
_log.debug('application status', jsonEncode(result));
|
_log.debug('application status', jsonEncode(result));
|
||||||
var oathState = OathState.fromJson(result['data']);
|
var oathState = OathState.fromJson(result['data']);
|
||||||
final key = _ref.read(_oathLockKeyProvider(_session.devicePath));
|
final key = ref.read(_oathLockKeyProvider(_session.devicePath));
|
||||||
if (oathState.locked && key != null) {
|
if (oathState.locked && key != null) {
|
||||||
final result =
|
final result = await _session.command('validate', params: {'key': key});
|
||||||
await _session.command('validate', params: {'key': key});
|
|
||||||
if (result['valid']) {
|
if (result['valid']) {
|
||||||
oathState = oathState.copyWith(locked: false);
|
oathState = oathState.copyWith(locked: false);
|
||||||
} else {
|
} else {
|
||||||
_ref
|
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
|
||||||
.read(_oathLockKeyProvider(_session.devicePath).notifier)
|
|
||||||
.unsetKey();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return oathState;
|
return oathState;
|
||||||
});
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> reset() async {
|
Future<void> reset() async {
|
||||||
await _session.command('reset');
|
await _session.command('reset');
|
||||||
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
|
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
|
||||||
_ref.invalidate(_sessionProvider(_session.devicePath));
|
ref.invalidate(_sessionProvider(_session.devicePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -120,7 +112,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
|
|||||||
final bool remembered = validate['remembered'];
|
final bool remembered = validate['remembered'];
|
||||||
if (valid) {
|
if (valid) {
|
||||||
_log.debug('applet unlocked');
|
_log.debug('applet unlocked');
|
||||||
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).setKey(key);
|
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).setKey(key);
|
||||||
setData(state.value!.copyWith(
|
setData(state.value!.copyWith(
|
||||||
locked: false,
|
locked: false,
|
||||||
remembered: remembered,
|
remembered: remembered,
|
||||||
@ -158,7 +150,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
|
|||||||
await _session.command('derive', params: {'password': password});
|
await _session.command('derive', params: {'password': password});
|
||||||
var key = derive['key'];
|
var key = derive['key'];
|
||||||
await _session.command('set_key', params: {'key': key});
|
await _session.command('set_key', params: {'key': key});
|
||||||
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).setKey(key);
|
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).setKey(key);
|
||||||
}
|
}
|
||||||
_log.debug('OATH key set');
|
_log.debug('OATH key set');
|
||||||
|
|
||||||
@ -177,7 +169,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await _session.command('unset_key');
|
await _session.command('unset_key');
|
||||||
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
|
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
|
||||||
setData(oathState.copyWith(hasKey: false, locked: false));
|
setData(oathState.copyWith(hasKey: false, locked: false));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -185,7 +177,7 @@ class _DesktopOathStateNotifier extends OathStateNotifier {
|
|||||||
@override
|
@override
|
||||||
Future<void> forgetPassword() async {
|
Future<void> forgetPassword() async {
|
||||||
await _session.command('forget');
|
await _session.command('forget');
|
||||||
_ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
|
ref.read(_oathLockKeyProvider(_session.devicePath).notifier).unsetKey();
|
||||||
setData(state.value!.copyWith(remembered: false));
|
setData(state.value!.copyWith(remembered: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
429
lib/desktop/piv/state.dart
Normal file
429
lib/desktop/piv/state.dart
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
/*
|
||||||
|
* 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 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
import '../../app/logging.dart';
|
||||||
|
import '../../app/models.dart';
|
||||||
|
import '../../app/state.dart';
|
||||||
|
import '../../app/views/user_interaction.dart';
|
||||||
|
import '../../core/models.dart';
|
||||||
|
import '../../piv/models.dart';
|
||||||
|
import '../../piv/state.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../rpc.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
|
||||||
|
final _log = Logger('desktop.piv.state');
|
||||||
|
|
||||||
|
final _managementKeyProvider =
|
||||||
|
StateProvider.autoDispose.family<String?, DevicePath>(
|
||||||
|
(ref, _) => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final _pinProvider = StateProvider.autoDispose.family<String?, DevicePath>(
|
||||||
|
(ref, _) => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final _sessionProvider =
|
||||||
|
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
|
||||||
|
(ref, devicePath) {
|
||||||
|
// Make sure the managementKey and PIN are held for the duration of the session.
|
||||||
|
ref.watch(_managementKeyProvider(devicePath));
|
||||||
|
ref.watch(_pinProvider(devicePath));
|
||||||
|
return RpcNodeSession(
|
||||||
|
ref.watch(rpcProvider).requireValue, devicePath, ['ccid', 'piv']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final desktopPivState = AsyncNotifierProvider.autoDispose
|
||||||
|
.family<PivStateNotifier, PivState, DevicePath>(
|
||||||
|
_DesktopPivStateNotifier.new);
|
||||||
|
|
||||||
|
class _DesktopPivStateNotifier extends PivStateNotifier {
|
||||||
|
late RpcNodeSession _session;
|
||||||
|
late DevicePath _devicePath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<PivState> build(DevicePath devicePath) async {
|
||||||
|
_session = ref.watch(_sessionProvider(devicePath));
|
||||||
|
_session
|
||||||
|
..setErrorHandler('state-reset', (_) async {
|
||||||
|
ref.invalidate(_sessionProvider(devicePath));
|
||||||
|
})
|
||||||
|
..setErrorHandler('auth-required', (e) async {
|
||||||
|
final String? mgmtKey;
|
||||||
|
if (state.valueOrNull?.metadata?.managementKeyMetadata.defaultValue ==
|
||||||
|
true) {
|
||||||
|
mgmtKey = defaultManagementKey;
|
||||||
|
} else {
|
||||||
|
mgmtKey = ref.read(_managementKeyProvider(devicePath));
|
||||||
|
}
|
||||||
|
if (mgmtKey != null) {
|
||||||
|
if (await authenticate(mgmtKey)) {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
} else {
|
||||||
|
ref.read(_managementKeyProvider(devicePath).notifier).state = null;
|
||||||
|
ref.invalidateSelf();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ref.onDispose(() {
|
||||||
|
_session
|
||||||
|
..unsetErrorHandler('state-reset')
|
||||||
|
..unsetErrorHandler('auth-required');
|
||||||
|
});
|
||||||
|
_devicePath = devicePath;
|
||||||
|
|
||||||
|
final result = await _session.command('get');
|
||||||
|
_log.debug('application status', jsonEncode(result));
|
||||||
|
final pivState = PivState.fromJson(result['data']);
|
||||||
|
|
||||||
|
return pivState;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reset() async {
|
||||||
|
await _session.command('reset');
|
||||||
|
ref.read(_managementKeyProvider(_devicePath).notifier).state = null;
|
||||||
|
ref.invalidate(_sessionProvider(_session.devicePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> authenticate(String managementKey) async {
|
||||||
|
final withContext = ref.watch(withContextProvider);
|
||||||
|
|
||||||
|
final signaler = Signaler();
|
||||||
|
UserInteractionController? controller;
|
||||||
|
try {
|
||||||
|
signaler.signals.listen((signal) async {
|
||||||
|
if (signal.status == 'touch') {
|
||||||
|
controller = await withContext(
|
||||||
|
(context) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return promptUserInteraction(
|
||||||
|
context,
|
||||||
|
icon: const Icon(Icons.touch_app),
|
||||||
|
title: l10n.s_touch_required,
|
||||||
|
description: l10n.l_touch_button_now,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final result = await _session.command(
|
||||||
|
'authenticate',
|
||||||
|
params: {'key': managementKey},
|
||||||
|
signal: signaler,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result['status']) {
|
||||||
|
ref.read(_managementKeyProvider(_devicePath).notifier).state =
|
||||||
|
managementKey;
|
||||||
|
final oldState = state.valueOrNull;
|
||||||
|
if (oldState != null) {
|
||||||
|
state = AsyncData(oldState.copyWith(authenticated: true));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
controller?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PinVerificationStatus> verifyPin(String pin) async {
|
||||||
|
final pivState = state.valueOrNull;
|
||||||
|
|
||||||
|
final signaler = Signaler();
|
||||||
|
UserInteractionController? controller;
|
||||||
|
try {
|
||||||
|
if (pivState?.protectedKey == true) {
|
||||||
|
// Might require touch as this will also authenticate
|
||||||
|
final withContext = ref.watch(withContextProvider);
|
||||||
|
signaler.signals.listen((signal) async {
|
||||||
|
if (signal.status == 'touch') {
|
||||||
|
controller = await withContext(
|
||||||
|
(context) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return promptUserInteraction(
|
||||||
|
context,
|
||||||
|
icon: const Icon(Icons.touch_app),
|
||||||
|
title: l10n.s_touch_required,
|
||||||
|
description: l10n.l_touch_button_now,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await _session.command(
|
||||||
|
'verify_pin',
|
||||||
|
params: {'pin': pin},
|
||||||
|
signal: signaler,
|
||||||
|
);
|
||||||
|
|
||||||
|
ref.read(_pinProvider(_devicePath).notifier).state = pin;
|
||||||
|
|
||||||
|
return const PinVerificationStatus.success();
|
||||||
|
} on RpcError catch (e) {
|
||||||
|
if (e.status == 'invalid-pin') {
|
||||||
|
return PinVerificationStatus.failure(e.body['attempts_remaining']);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
controller?.close();
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PinVerificationStatus> changePin(String pin, String newPin) async {
|
||||||
|
try {
|
||||||
|
await _session.command(
|
||||||
|
'change_pin',
|
||||||
|
params: {'pin': pin, 'new_pin': newPin},
|
||||||
|
);
|
||||||
|
ref.read(_pinProvider(_devicePath).notifier).state = null;
|
||||||
|
return const PinVerificationStatus.success();
|
||||||
|
} on RpcError catch (e) {
|
||||||
|
if (e.status == 'invalid-pin') {
|
||||||
|
return PinVerificationStatus.failure(e.body['attempts_remaining']);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PinVerificationStatus> changePuk(String puk, String newPuk) async {
|
||||||
|
try {
|
||||||
|
await _session.command(
|
||||||
|
'change_puk',
|
||||||
|
params: {'puk': puk, 'new_puk': newPuk},
|
||||||
|
);
|
||||||
|
return const PinVerificationStatus.success();
|
||||||
|
} on RpcError catch (e) {
|
||||||
|
if (e.status == 'invalid-pin') {
|
||||||
|
return PinVerificationStatus.failure(e.body['attempts_remaining']);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setManagementKey(String managementKey,
|
||||||
|
{ManagementKeyType managementKeyType = defaultManagementKeyType,
|
||||||
|
bool storeKey = false}) async {
|
||||||
|
await _session.command(
|
||||||
|
'set_key',
|
||||||
|
params: {
|
||||||
|
'key': managementKey,
|
||||||
|
'key_type': managementKeyType.value,
|
||||||
|
'store_key': storeKey,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ref.read(_managementKeyProvider(_devicePath).notifier).state =
|
||||||
|
managementKey;
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PinVerificationStatus> unblockPin(String puk, String newPin) async {
|
||||||
|
try {
|
||||||
|
await _session.command(
|
||||||
|
'unblock_pin',
|
||||||
|
params: {'puk': puk, 'new_pin': newPin},
|
||||||
|
);
|
||||||
|
return const PinVerificationStatus.success();
|
||||||
|
} on RpcError catch (e) {
|
||||||
|
if (e.status == 'invalid-pin') {
|
||||||
|
return PinVerificationStatus.failure(e.body['attempts_remaining']);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _shownSlots = SlotId.values.map((slot) => slot.id).toList();
|
||||||
|
|
||||||
|
final desktopPivSlots = AsyncNotifierProvider.autoDispose
|
||||||
|
.family<PivSlotsNotifier, List<PivSlot>, DevicePath>(
|
||||||
|
_DesktopPivSlotsNotifier.new);
|
||||||
|
|
||||||
|
class _DesktopPivSlotsNotifier extends PivSlotsNotifier {
|
||||||
|
late RpcNodeSession _session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<PivSlot>> build(DevicePath devicePath) async {
|
||||||
|
_session = ref.watch(_sessionProvider(devicePath));
|
||||||
|
|
||||||
|
final result = await _session.command('get', target: ['slots']);
|
||||||
|
return (result['children'] as Map<String, dynamic>)
|
||||||
|
.values
|
||||||
|
.where((e) => _shownSlots.contains(e['slot']))
|
||||||
|
.map((e) => PivSlot.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(SlotId slot) async {
|
||||||
|
await _session.command('delete', target: ['slots', slot.hexId]);
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PivGenerateResult> generate(
|
||||||
|
SlotId slot,
|
||||||
|
KeyType keyType, {
|
||||||
|
required PivGenerateParameters parameters,
|
||||||
|
PinPolicy pinPolicy = PinPolicy.dfault,
|
||||||
|
TouchPolicy touchPolicy = TouchPolicy.dfault,
|
||||||
|
String? pin,
|
||||||
|
}) async {
|
||||||
|
final withContext = ref.watch(withContextProvider);
|
||||||
|
|
||||||
|
final signaler = Signaler();
|
||||||
|
UserInteractionController? controller;
|
||||||
|
try {
|
||||||
|
signaler.signals.listen((signal) async {
|
||||||
|
if (signal.status == 'touch') {
|
||||||
|
controller = await withContext(
|
||||||
|
(context) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return promptUserInteraction(
|
||||||
|
context,
|
||||||
|
icon: const Icon(Icons.touch_app),
|
||||||
|
title: l10n.s_touch_required,
|
||||||
|
description: l10n.l_touch_button_now,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final (type, subject, validFrom, validTo) = parameters.when(
|
||||||
|
certificate: (subject, validFrom, validTo) => (
|
||||||
|
GenerateType.certificate,
|
||||||
|
subject,
|
||||||
|
dateFormatter.format(validFrom),
|
||||||
|
dateFormatter.format(validTo),
|
||||||
|
),
|
||||||
|
csr: (subject) => (
|
||||||
|
GenerateType.csr,
|
||||||
|
subject,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final pin = ref.read(_pinProvider(_session.devicePath));
|
||||||
|
|
||||||
|
final result = await _session.command(
|
||||||
|
'generate',
|
||||||
|
target: [
|
||||||
|
'slots',
|
||||||
|
slot.hexId,
|
||||||
|
],
|
||||||
|
params: {
|
||||||
|
'key_type': keyType.value,
|
||||||
|
'pin_policy': pinPolicy.value,
|
||||||
|
'touch_policy': touchPolicy.value,
|
||||||
|
'subject': subject,
|
||||||
|
'generate_type': type.name,
|
||||||
|
'valid_from': validFrom,
|
||||||
|
'valid_to': validTo,
|
||||||
|
'pin': pin,
|
||||||
|
},
|
||||||
|
signal: signaler,
|
||||||
|
);
|
||||||
|
|
||||||
|
ref.invalidateSelf();
|
||||||
|
|
||||||
|
return PivGenerateResult.fromJson(
|
||||||
|
{'generate_type': type.name, ...result});
|
||||||
|
} finally {
|
||||||
|
controller?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PivExamineResult> examine(String data, {String? password}) async {
|
||||||
|
final result = await _session.command('examine_file', target: [
|
||||||
|
'slots',
|
||||||
|
], params: {
|
||||||
|
'data': data,
|
||||||
|
'password': password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result['status']) {
|
||||||
|
return PivExamineResult.fromJson({'runtimeType': 'result', ...result});
|
||||||
|
} else {
|
||||||
|
return PivExamineResult.invalidPassword();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PivImportResult> import(SlotId slot, String data,
|
||||||
|
{String? password,
|
||||||
|
PinPolicy pinPolicy = PinPolicy.dfault,
|
||||||
|
TouchPolicy touchPolicy = TouchPolicy.dfault}) async {
|
||||||
|
final result = await _session.command('import_file', target: [
|
||||||
|
'slots',
|
||||||
|
slot.hexId,
|
||||||
|
], params: {
|
||||||
|
'data': data,
|
||||||
|
'password': password,
|
||||||
|
'pin_policy': pinPolicy.value,
|
||||||
|
'touch_policy': touchPolicy.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
ref.invalidateSelf();
|
||||||
|
return PivImportResult.fromJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<(SlotMetadata?, String?)> read(SlotId slot) async {
|
||||||
|
final result = await _session.command('get', target: [
|
||||||
|
'slots',
|
||||||
|
slot.hexId,
|
||||||
|
]);
|
||||||
|
final data = result['data'];
|
||||||
|
final metadata = data['metadata'];
|
||||||
|
return (
|
||||||
|
metadata != null ? SlotMetadata.fromJson(metadata) : null,
|
||||||
|
data['certificate'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
45
lib/fido/keys.dart
Normal file
45
lib/fido/keys.dart
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022-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';
|
||||||
|
|
||||||
|
const _prefix = 'fido.keys';
|
||||||
|
const _keyAction = '$_prefix.actions';
|
||||||
|
const _credentialAction = '$_prefix.credential.actions';
|
||||||
|
const _fingerprintAction = '$_prefix.fingerprint.actions';
|
||||||
|
|
||||||
|
// Key actions
|
||||||
|
const managePinAction = Key('$_keyAction.manage_pin');
|
||||||
|
const addFingerprintAction = Key('$_keyAction.add_fingerprint');
|
||||||
|
const resetAction = Key('$_keyAction.reset');
|
||||||
|
|
||||||
|
// Credential actions
|
||||||
|
const editCredentialAction = Key('$_credentialAction.edit');
|
||||||
|
const deleteCredentialAction = Key('$_credentialAction.delete');
|
||||||
|
|
||||||
|
// Fingerprint actions
|
||||||
|
const editFingerintAction = Key('$_fingerprintAction.edit');
|
||||||
|
const deleteFingerprintAction = Key('$_fingerprintAction.delete');
|
||||||
|
|
||||||
|
const saveButton = Key('$_prefix.save');
|
||||||
|
const deleteButton = Key('$_prefix.delete');
|
||||||
|
const unlockButton = Key('$_prefix.unlock');
|
||||||
|
|
||||||
|
const managementKeyField = Key('$_prefix.management_key');
|
||||||
|
const pinPukField = Key('$_prefix.pin_puk');
|
||||||
|
const newPinPukField = Key('$_prefix.new_pin_puk');
|
||||||
|
const confirmPinPukField = Key('$_prefix.confirm_pin_puk');
|
||||||
|
const subjectField = Key('$_prefix.subject');
|
@ -41,6 +41,8 @@ class FidoState with _$FidoState {
|
|||||||
info['options']['credentialMgmtPreview'] == true;
|
info['options']['credentialMgmtPreview'] == true;
|
||||||
|
|
||||||
bool? get bioEnroll => info['options']['bioEnroll'];
|
bool? get bioEnroll => info['options']['bioEnroll'];
|
||||||
|
|
||||||
|
bool get alwaysUv => info['options']['alwaysUv'] == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
@ -14,16 +14,15 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../app/models.dart';
|
import '../app/models.dart';
|
||||||
import '../core/state.dart';
|
import '../core/state.dart';
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
|
|
||||||
final fidoStateProvider = StateNotifierProvider.autoDispose
|
final fidoStateProvider = AsyncNotifierProvider.autoDispose
|
||||||
.family<FidoStateNotifier, AsyncValue<FidoState>, DevicePath>(
|
.family<FidoStateNotifier, FidoState, DevicePath>(
|
||||||
(ref, devicePath) => throw UnimplementedError(),
|
() => throw UnimplementedError(),
|
||||||
);
|
);
|
||||||
|
|
||||||
abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState> {
|
abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState> {
|
||||||
@ -32,36 +31,24 @@ abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState> {
|
|||||||
Future<PinResult> unlock(String pin);
|
Future<PinResult> unlock(String pin);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class LockedCollectionNotifier<T>
|
final fingerprintProvider = AsyncNotifierProvider.autoDispose
|
||||||
extends StateNotifier<AsyncValue<List<T>>> {
|
.family<FidoFingerprintsNotifier, List<Fingerprint>, DevicePath>(
|
||||||
LockedCollectionNotifier() : super(const AsyncValue.loading());
|
() => throw UnimplementedError(),
|
||||||
|
|
||||||
@protected
|
|
||||||
void setItems(List<T> items) {
|
|
||||||
if (mounted) {
|
|
||||||
state = AsyncValue.data(List.unmodifiable(items));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final fingerprintProvider = StateNotifierProvider.autoDispose.family<
|
|
||||||
FidoFingerprintsNotifier, AsyncValue<List<Fingerprint>>, DevicePath>(
|
|
||||||
(ref, arg) => throw UnimplementedError(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
abstract class FidoFingerprintsNotifier
|
abstract class FidoFingerprintsNotifier
|
||||||
extends LockedCollectionNotifier<Fingerprint> {
|
extends AutoDisposeFamilyAsyncNotifier<List<Fingerprint>, DevicePath> {
|
||||||
Stream<FingerprintEvent> registerFingerprint({String? name});
|
Stream<FingerprintEvent> registerFingerprint({String? name});
|
||||||
Future<Fingerprint> renameFingerprint(Fingerprint fingerprint, String name);
|
Future<Fingerprint> renameFingerprint(Fingerprint fingerprint, String name);
|
||||||
Future<void> deleteFingerprint(Fingerprint fingerprint);
|
Future<void> deleteFingerprint(Fingerprint fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
final credentialProvider = StateNotifierProvider.autoDispose.family<
|
final credentialProvider = AsyncNotifierProvider.autoDispose
|
||||||
FidoCredentialsNotifier, AsyncValue<List<FidoCredential>>, DevicePath>(
|
.family<FidoCredentialsNotifier, List<FidoCredential>, DevicePath>(
|
||||||
(ref, arg) => throw UnimplementedError(),
|
() => throw UnimplementedError(),
|
||||||
);
|
);
|
||||||
|
|
||||||
abstract class FidoCredentialsNotifier
|
abstract class FidoCredentialsNotifier
|
||||||
extends LockedCollectionNotifier<FidoCredential> {
|
extends AutoDisposeFamilyAsyncNotifier<List<FidoCredential>, DevicePath> {
|
||||||
Future<void> deleteCredential(FidoCredential credential);
|
Future<void> deleteCredential(FidoCredential credential);
|
||||||
}
|
}
|
||||||
|
55
lib/fido/views/actions.dart
Normal file
55
lib/fido/views/actions.dart
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* 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 '../../app/models.dart';
|
||||||
|
import '../../app/shortcuts.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
|
||||||
|
List<ActionItem> buildFingerprintActions(AppLocalizations l10n) {
|
||||||
|
return [
|
||||||
|
ActionItem(
|
||||||
|
key: keys.editFingerintAction,
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
title: l10n.s_rename_fp,
|
||||||
|
subtitle: l10n.l_rename_fp_desc,
|
||||||
|
intent: const EditIntent(),
|
||||||
|
),
|
||||||
|
ActionItem(
|
||||||
|
key: keys.deleteFingerprintAction,
|
||||||
|
actionStyle: ActionStyle.error,
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
title: l10n.s_delete_fingerprint,
|
||||||
|
subtitle: l10n.l_delete_fingerprint_desc,
|
||||||
|
intent: const DeleteIntent(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ActionItem> buildCredentialActions(AppLocalizations l10n) {
|
||||||
|
return [
|
||||||
|
ActionItem(
|
||||||
|
key: keys.deleteCredentialAction,
|
||||||
|
actionStyle: ActionStyle.error,
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
title: l10n.s_delete_passkey,
|
||||||
|
subtitle: l10n.l_delete_account_desc,
|
||||||
|
intent: const DeleteIntent(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
93
lib/fido/views/credential_dialog.dart
Normal file
93
lib/fido/views/credential_dialog.dart
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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/shortcuts.dart';
|
||||||
|
import '../../app/state.dart';
|
||||||
|
import '../../app/views/fs_dialog.dart';
|
||||||
|
import '../../app/views/action_list.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'actions.dart';
|
||||||
|
import 'delete_credential_dialog.dart';
|
||||||
|
|
||||||
|
class CredentialDialog extends ConsumerWidget {
|
||||||
|
final FidoCredential credential;
|
||||||
|
const CredentialDialog(this.credential, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// TODO: Solve this in a cleaner way
|
||||||
|
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
|
||||||
|
if (node == null) {
|
||||||
|
// The rest of this method assumes there is a device, and will throw an exception if not.
|
||||||
|
// This will never be shown, as the dialog will be immediately closed
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Actions(
|
||||||
|
actions: {
|
||||||
|
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||||
|
final withContext = ref.read(withContextProvider);
|
||||||
|
final bool? deleted =
|
||||||
|
await ref.read(withContextProvider)((context) async =>
|
||||||
|
await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => DeleteCredentialDialog(
|
||||||
|
node.path,
|
||||||
|
credential,
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false);
|
||||||
|
|
||||||
|
// Pop the account dialog if deleted
|
||||||
|
if (deleted == true) {
|
||||||
|
await withContext((context) async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
child: FocusScope(
|
||||||
|
autofocus: true,
|
||||||
|
child: FsDialog(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 48, bottom: 32),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
credential.userName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
credential.rpId,
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
// This is what ListTile uses for subtitle
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||||
|
color: Theme.of(context).textTheme.bodySmall!.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Icon(Icons.person, size: 72),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ActionListSection.fromMenuActions(
|
||||||
|
context,
|
||||||
|
l10n.s_actions,
|
||||||
|
actions: buildCredentialActions(l10n),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -38,14 +38,14 @@ class DeleteCredentialDialog extends ConsumerWidget {
|
|||||||
final label = credential.userName;
|
final label = credential.userName;
|
||||||
|
|
||||||
return ResponsiveDialog(
|
return ResponsiveDialog(
|
||||||
title: Text(l10n.s_delete_credential),
|
title: Text(l10n.s_delete_passkey),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(l10n.p_warning_delete_credential),
|
Text(l10n.p_warning_delete_passkey),
|
||||||
Text(l10n.l_credential(label)),
|
Text(l10n.l_passkey(label)),
|
||||||
]
|
]
|
||||||
.map((e) => Padding(
|
.map((e) => Padding(
|
||||||
child: e,
|
child: e,
|
||||||
@ -63,7 +63,7 @@ class DeleteCredentialDialog extends ConsumerWidget {
|
|||||||
await ref.read(withContextProvider)(
|
await ref.read(withContextProvider)(
|
||||||
(context) async {
|
(context) async {
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
showMessage(context, l10n.s_credential_deleted);
|
showMessage(context, l10n.s_passkey_deleted);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
109
lib/fido/views/fingerprint_dialog.dart
Normal file
109
lib/fido/views/fingerprint_dialog.dart
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
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/shortcuts.dart';
|
||||||
|
import '../../app/state.dart';
|
||||||
|
import '../../app/views/fs_dialog.dart';
|
||||||
|
import '../../app/views/action_list.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'actions.dart';
|
||||||
|
import 'delete_fingerprint_dialog.dart';
|
||||||
|
import 'rename_fingerprint_dialog.dart';
|
||||||
|
|
||||||
|
class FingerprintDialog extends ConsumerWidget {
|
||||||
|
final Fingerprint fingerprint;
|
||||||
|
const FingerprintDialog(this.fingerprint, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// TODO: Solve this in a cleaner way
|
||||||
|
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
|
||||||
|
if (node == null) {
|
||||||
|
// The rest of this method assumes there is a device, and will throw an exception if not.
|
||||||
|
// This will never be shown, as the dialog will be immediately closed
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return Actions(
|
||||||
|
actions: {
|
||||||
|
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
|
||||||
|
final withContext = ref.read(withContextProvider);
|
||||||
|
final Fingerprint? renamed =
|
||||||
|
await withContext((context) async => await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => RenameFingerprintDialog(
|
||||||
|
node.path,
|
||||||
|
fingerprint,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
if (renamed != null) {
|
||||||
|
// Replace the dialog with the renamed credential
|
||||||
|
await withContext((context) async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return FingerprintDialog(renamed);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return renamed;
|
||||||
|
}),
|
||||||
|
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||||
|
final withContext = ref.read(withContextProvider);
|
||||||
|
final bool? deleted =
|
||||||
|
await ref.read(withContextProvider)((context) async =>
|
||||||
|
await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => DeleteFingerprintDialog(
|
||||||
|
node.path,
|
||||||
|
fingerprint,
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false);
|
||||||
|
|
||||||
|
// Pop the account dialog if deleted
|
||||||
|
if (deleted == true) {
|
||||||
|
await withContext((context) async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
child: FocusScope(
|
||||||
|
autofocus: true,
|
||||||
|
child: FsDialog(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 48, bottom: 32),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
fingerprint.label,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Icon(Icons.fingerprint, size: 72),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ActionListSection.fromMenuActions(
|
||||||
|
context,
|
||||||
|
l10n.s_actions,
|
||||||
|
actions: buildFingerprintActions(l10n),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -19,32 +19,43 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
|||||||
|
|
||||||
import '../../app/message.dart';
|
import '../../app/message.dart';
|
||||||
import '../../app/models.dart';
|
import '../../app/models.dart';
|
||||||
import '../../widgets/list_title.dart';
|
import '../../app/views/fs_dialog.dart';
|
||||||
|
import '../../app/views/action_list.dart';
|
||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
import 'add_fingerprint_dialog.dart';
|
import 'add_fingerprint_dialog.dart';
|
||||||
import 'pin_dialog.dart';
|
import 'pin_dialog.dart';
|
||||||
import 'reset_dialog.dart';
|
import 'reset_dialog.dart';
|
||||||
|
|
||||||
|
bool fidoShowActionsNotifier(FidoState state) {
|
||||||
|
return (state.alwaysUv && !state.hasPin) || state.bioEnroll == false;
|
||||||
|
}
|
||||||
|
|
||||||
Widget fidoBuildActions(
|
Widget fidoBuildActions(
|
||||||
BuildContext context, DeviceNode node, FidoState state, int fingerprints) {
|
BuildContext context, DeviceNode node, FidoState state, int fingerprints) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context).colorScheme;
|
|
||||||
return SimpleDialog(
|
return FsDialog(
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (state.bioEnroll != null) ...[
|
if (state.bioEnroll != null)
|
||||||
ListTitle(l10n.s_setup,
|
ActionListSection(
|
||||||
textStyle: Theme.of(context).textTheme.bodyLarge),
|
l10n.s_setup,
|
||||||
ListTile(
|
children: [
|
||||||
leading: const CircleAvatar(child: Icon(Icons.fingerprint_outlined)),
|
ActionListItem(
|
||||||
title: Text(l10n.s_add_fingerprint),
|
key: keys.addFingerprintAction,
|
||||||
|
actionStyle: ActionStyle.primary,
|
||||||
|
icon: const Icon(Icons.fingerprint_outlined),
|
||||||
|
title: l10n.s_add_fingerprint,
|
||||||
subtitle: state.unlocked
|
subtitle: state.unlocked
|
||||||
? Text(l10n.l_fingerprints_used(fingerprints))
|
? l10n.l_fingerprints_used(fingerprints)
|
||||||
: Text(state.hasPin
|
: state.hasPin
|
||||||
? l10n.l_unlock_pin_first
|
? l10n.l_unlock_pin_first
|
||||||
: l10n.l_set_pin_first),
|
: l10n.l_set_pin_first,
|
||||||
enabled: state.unlocked && fingerprints < 5,
|
trailing:
|
||||||
|
fingerprints == 0 ? const Icon(Icons.warning_amber) : null,
|
||||||
onTap: state.unlocked && fingerprints < 5
|
onTap: state.unlocked && fingerprints < 5
|
||||||
? () {
|
? (context) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
showBlurDialog(
|
showBlurDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -54,30 +65,34 @@ Widget fidoBuildActions(
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
ListTitle(l10n.s_manage,
|
),
|
||||||
textStyle: Theme.of(context).textTheme.bodyLarge),
|
ActionListSection(
|
||||||
ListTile(
|
l10n.s_manage,
|
||||||
leading: const CircleAvatar(child: Icon(Icons.pin_outlined)),
|
children: [
|
||||||
title: Text(state.hasPin ? l10n.s_change_pin : l10n.s_set_pin),
|
ActionListItem(
|
||||||
subtitle: Text(state.hasPin
|
key: keys.managePinAction,
|
||||||
|
icon: const Icon(Icons.pin_outlined),
|
||||||
|
title: state.hasPin ? l10n.s_change_pin : l10n.s_set_pin,
|
||||||
|
subtitle: state.hasPin
|
||||||
? l10n.s_fido_pin_protection
|
? l10n.s_fido_pin_protection
|
||||||
: l10n.l_fido_pin_protection_optional),
|
: l10n.l_fido_pin_protection_optional,
|
||||||
onTap: () {
|
trailing: state.alwaysUv && !state.hasPin
|
||||||
|
? const Icon(Icons.warning_amber)
|
||||||
|
: null,
|
||||||
|
onTap: (context) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
showBlurDialog(
|
showBlurDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => FidoPinDialog(node.path, state),
|
builder: (context) => FidoPinDialog(node.path, state),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
ListTile(
|
ActionListItem(
|
||||||
leading: CircleAvatar(
|
key: keys.resetAction,
|
||||||
foregroundColor: theme.onError,
|
actionStyle: ActionStyle.error,
|
||||||
backgroundColor: theme.error,
|
icon: const Icon(Icons.delete_outline),
|
||||||
child: const Icon(Icons.delete_outline),
|
title: l10n.s_reset_fido,
|
||||||
),
|
subtitle: l10n.l_factory_reset_this_app,
|
||||||
title: Text(l10n.s_reset_fido),
|
onTap: (context) {
|
||||||
subtitle: Text(l10n.l_factory_reset_this_app),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
showBlurDialog(
|
showBlurDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -86,5 +101,8 @@ Widget fidoBuildActions(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -43,6 +43,7 @@ class FidoLockedPage extends ConsumerWidget {
|
|||||||
header: l10n.s_no_fingerprints,
|
header: l10n.s_no_fingerprints,
|
||||||
message: l10n.l_set_pin_fingerprints,
|
message: l10n.l_set_pin_fingerprints,
|
||||||
keyActionsBuilder: _buildActions,
|
keyActionsBuilder: _buildActions,
|
||||||
|
keyActionsBadge: fidoShowActionsNotifier(state),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return MessagePage(
|
return MessagePage(
|
||||||
@ -53,6 +54,7 @@ class FidoLockedPage extends ConsumerWidget {
|
|||||||
: l10n.l_ready_to_use,
|
: l10n.l_ready_to_use,
|
||||||
message: l10n.l_optionally_set_a_pin,
|
message: l10n.l_optionally_set_a_pin,
|
||||||
keyActionsBuilder: _buildActions,
|
keyActionsBuilder: _buildActions,
|
||||||
|
keyActionsBadge: fidoShowActionsNotifier(state),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,14 +20,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../app/message.dart';
|
import '../../app/message.dart';
|
||||||
import '../../app/models.dart';
|
import '../../app/models.dart';
|
||||||
|
import '../../app/shortcuts.dart';
|
||||||
|
import '../../app/views/app_list_item.dart';
|
||||||
import '../../app/views/app_page.dart';
|
import '../../app/views/app_page.dart';
|
||||||
import '../../app/views/graphics.dart';
|
import '../../app/views/graphics.dart';
|
||||||
import '../../app/views/message_page.dart';
|
import '../../app/views/message_page.dart';
|
||||||
import '../../widgets/list_title.dart';
|
import '../../widgets/list_title.dart';
|
||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
import '../state.dart';
|
import '../state.dart';
|
||||||
|
import 'actions.dart';
|
||||||
|
import 'credential_dialog.dart';
|
||||||
import 'delete_credential_dialog.dart';
|
import 'delete_credential_dialog.dart';
|
||||||
import 'delete_fingerprint_dialog.dart';
|
import 'delete_fingerprint_dialog.dart';
|
||||||
|
import 'fingerprint_dialog.dart';
|
||||||
import 'key_actions.dart';
|
import 'key_actions.dart';
|
||||||
import 'rename_fingerprint_dialog.dart';
|
import 'rename_fingerprint_dialog.dart';
|
||||||
|
|
||||||
@ -48,42 +53,26 @@ class FidoUnlockedPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
final creds = data.value;
|
final creds = data.value;
|
||||||
if (creds.isNotEmpty) {
|
if (creds.isNotEmpty) {
|
||||||
children.add(ListTitle(l10n.s_credentials));
|
children.add(ListTitle(l10n.s_passkeys));
|
||||||
children.addAll(
|
children.addAll(creds.map((cred) => Actions(
|
||||||
creds.map(
|
actions: {
|
||||||
(cred) => ListTile(
|
OpenIntent: CallbackAction<OpenIntent>(
|
||||||
leading: CircleAvatar(
|
onInvoke: (_) => showBlurDialog(
|
||||||
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: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
showBlurDialog(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) =>
|
builder: (context) => CredentialDialog(cred),
|
||||||
DeleteCredentialDialog(node.path, cred),
|
)),
|
||||||
);
|
DeleteIntent: CallbackAction<DeleteIntent>(
|
||||||
|
onInvoke: (_) => showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => DeleteCredentialDialog(
|
||||||
|
node.path,
|
||||||
|
cred,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.delete_outline)),
|
child: _CredentialListItem(cred),
|
||||||
],
|
)));
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,40 +86,31 @@ class FidoUnlockedPage extends ConsumerWidget {
|
|||||||
if (fingerprints.isNotEmpty) {
|
if (fingerprints.isNotEmpty) {
|
||||||
nFingerprints = fingerprints.length;
|
nFingerprints = fingerprints.length;
|
||||||
children.add(ListTitle(l10n.s_fingerprints));
|
children.add(ListTitle(l10n.s_fingerprints));
|
||||||
children.addAll(fingerprints.map((fp) => ListTile(
|
children.addAll(fingerprints.map((fp) => Actions(
|
||||||
leading: CircleAvatar(
|
actions: {
|
||||||
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
OpenIntent: CallbackAction<OpenIntent>(
|
||||||
backgroundColor: Theme.of(context).colorScheme.secondary,
|
onInvoke: (_) => showBlurDialog(
|
||||||
child: const Icon(Icons.fingerprint),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
fp.label,
|
|
||||||
softWrap: false,
|
|
||||||
overflow: TextOverflow.fade,
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
showBlurDialog(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) =>
|
builder: (context) => FingerprintDialog(fp),
|
||||||
RenameFingerprintDialog(node.path, fp),
|
)),
|
||||||
);
|
EditIntent: CallbackAction<EditIntent>(
|
||||||
},
|
onInvoke: (_) => showBlurDialog(
|
||||||
icon: const Icon(Icons.edit_outlined)),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
showBlurDialog(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) =>
|
builder: (context) => RenameFingerprintDialog(
|
||||||
DeleteFingerprintDialog(node.path, fp),
|
node.path,
|
||||||
);
|
fp,
|
||||||
},
|
|
||||||
icon: const Icon(Icons.delete_outline)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
)),
|
||||||
|
DeleteIntent: CallbackAction<DeleteIntent>(
|
||||||
|
onInvoke: (_) => showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => DeleteFingerprintDialog(
|
||||||
|
node.path,
|
||||||
|
fp,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
child: _FingerprintListItem(fp),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -140,6 +120,7 @@ class FidoUnlockedPage extends ConsumerWidget {
|
|||||||
title: Text(l10n.s_webauthn),
|
title: Text(l10n.s_webauthn),
|
||||||
keyActionsBuilder: (context) =>
|
keyActionsBuilder: (context) =>
|
||||||
fidoBuildActions(context, node, state, nFingerprints),
|
fidoBuildActions(context, node, state, nFingerprints),
|
||||||
|
keyActionsBadge: fidoShowActionsNotifier(state),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
||||||
);
|
);
|
||||||
@ -153,6 +134,7 @@ class FidoUnlockedPage extends ConsumerWidget {
|
|||||||
message: l10n.l_add_one_or_more_fps,
|
message: l10n.l_add_one_or_more_fps,
|
||||||
keyActionsBuilder: (context) =>
|
keyActionsBuilder: (context) =>
|
||||||
fidoBuildActions(context, node, state, 0),
|
fidoBuildActions(context, node, state, 0),
|
||||||
|
keyActionsBadge: fidoShowActionsNotifier(state),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,6 +144,7 @@ class FidoUnlockedPage extends ConsumerWidget {
|
|||||||
header: l10n.l_no_discoverable_accounts,
|
header: l10n.l_no_discoverable_accounts,
|
||||||
message: l10n.l_register_sk_on_websites,
|
message: l10n.l_register_sk_on_websites,
|
||||||
keyActionsBuilder: (context) => fidoBuildActions(context, node, state, 0),
|
keyActionsBuilder: (context) => fidoBuildActions(context, node, state, 0),
|
||||||
|
keyActionsBadge: fidoShowActionsNotifier(state),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,3 +155,50 @@ class FidoUnlockedPage extends ConsumerWidget {
|
|||||||
child: const CircularProgressIndicator(),
|
child: const CircularProgressIndicator(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CredentialListItem extends StatelessWidget {
|
||||||
|
final FidoCredential credential;
|
||||||
|
const _CredentialListItem(this.credential);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppListItem(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
child: const Icon(Icons.person),
|
||||||
|
),
|
||||||
|
title: credential.userName,
|
||||||
|
subtitle: credential.rpId,
|
||||||
|
trailing: OutlinedButton(
|
||||||
|
onPressed: Actions.handler(context, const OpenIntent()),
|
||||||
|
child: const Icon(Icons.more_horiz),
|
||||||
|
),
|
||||||
|
buildPopupActions: (context) =>
|
||||||
|
buildCredentialActions(AppLocalizations.of(context)!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FingerprintListItem extends StatelessWidget {
|
||||||
|
final Fingerprint fingerprint;
|
||||||
|
const _FingerprintListItem(this.fingerprint);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppListItem(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.secondary,
|
||||||
|
child: const Icon(Icons.fingerprint),
|
||||||
|
),
|
||||||
|
title: fingerprint.label,
|
||||||
|
trailing: OutlinedButton(
|
||||||
|
onPressed: Actions.handler(context, const OpenIntent()),
|
||||||
|
child: const Icon(Icons.more_horiz),
|
||||||
|
),
|
||||||
|
buildPopupActions: (context) =>
|
||||||
|
buildFingerprintActions(AppLocalizations.of(context)!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
445
lib/l10n/app_de.arb
Normal file
445
lib/l10n/app_de.arb
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
{
|
||||||
|
"@@locale": "de",
|
||||||
|
|
||||||
|
"@_readme": {
|
||||||
|
"notes": [
|
||||||
|
"Alle Zeichenketten beginnen mit einem Großbuchstaben.",
|
||||||
|
"Gruppieren Sie Zeichenketten nach Kategorie, aber fügen Sie nicht zu einem Bereich hinzu, wenn sie in mehreren Bereichen verwendet werden können.",
|
||||||
|
"Führen Sie check_strings.py für die .arb Datei aus, um Probleme zu finden. Passen Sie @_lint_rules nach Sprache an wie nötig."
|
||||||
|
],
|
||||||
|
"prefixes": {
|
||||||
|
"s_": "Ein einzelnes Wort oder wenige Wörter. Sollte kurz genug sein, um auf einer Schaltfläche oder einer Überschrift angezeigt zu werden.",
|
||||||
|
"l_": "Eine einzelne Zeile, kann umbgebrochen werden. Sollte nicht mehr als einen Satz umfassen und nicht mit einem Punkt enden.",
|
||||||
|
"p_": "Ein oder mehrere ganze Sätze mit allen Satzzeichen.",
|
||||||
|
"q_": "Eine Frage, die mit einem Fragezeichen endet."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"@_lint_rules": {
|
||||||
|
"s_max_words": 4,
|
||||||
|
"s_max_length": 32
|
||||||
|
},
|
||||||
|
|
||||||
|
"app_name": "Yubico Authenticator",
|
||||||
|
|
||||||
|
"s_save": "Speichern",
|
||||||
|
"s_cancel": "Abbrechen",
|
||||||
|
"s_close": "Schließen",
|
||||||
|
"s_delete": "Löschen",
|
||||||
|
"s_quit": "Beenden",
|
||||||
|
"s_unlock": "Entsperren",
|
||||||
|
"s_calculate": "Berechnen",
|
||||||
|
"s_label": "Beschriftung",
|
||||||
|
"s_name": "Name",
|
||||||
|
"s_usb": "USB",
|
||||||
|
"s_nfc": "NFC",
|
||||||
|
"s_show_window": "Fenster anzeigen",
|
||||||
|
"s_hide_window": "Fenster verstecken",
|
||||||
|
"q_rename_target": "{label} umbenennen?",
|
||||||
|
"@q_rename_target" : {
|
||||||
|
"placeholders": {
|
||||||
|
"label": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"s_about": "Über",
|
||||||
|
"s_appearance": "Aussehen",
|
||||||
|
"s_authenticator": "Authenticator",
|
||||||
|
"s_manage": "Verwalten",
|
||||||
|
"s_setup": "Einrichten",
|
||||||
|
"s_settings": "Einstellungen",
|
||||||
|
"s_webauthn": "WebAuthn",
|
||||||
|
"s_help_and_about": "Hilfe und Über",
|
||||||
|
"s_help_and_feedback": "Hilfe und Feedback",
|
||||||
|
"s_send_feedback": "Senden Sie uns Feedback",
|
||||||
|
"s_i_need_help": "Ich brauche Hilfe",
|
||||||
|
"s_troubleshooting": "Problembehebung",
|
||||||
|
"s_terms_of_use": "Nutzungsbedingungen",
|
||||||
|
"s_privacy_policy": "Datenschutzerklärung",
|
||||||
|
"s_open_src_licenses": "Open Source-Lizenzen",
|
||||||
|
"s_configure_yk": "YubiKey konfigurieren",
|
||||||
|
"s_please_wait": "Bitte warten\u2026",
|
||||||
|
"s_secret_key": "Geheimer Schlüssel",
|
||||||
|
"s_invalid_length": "Ungültige Länge",
|
||||||
|
"s_require_touch": "Berührung ist erforderlich",
|
||||||
|
"q_have_account_info": "Haben Sie Konto-Informationen?",
|
||||||
|
"s_run_diagnostics": "Diagnose ausführen",
|
||||||
|
"s_log_level": "Log-Level: {level}",
|
||||||
|
"@s_log_level": {
|
||||||
|
"placeholders": {
|
||||||
|
"level": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_character_count": "Anzahl Zeichen",
|
||||||
|
"s_learn_more": "Mehr\u00a0erfahren",
|
||||||
|
|
||||||
|
"@_language": {},
|
||||||
|
"s_language": "Sprache",
|
||||||
|
"l_enable_community_translations": "Übersetzungen der Gemeinschaft aktivieren",
|
||||||
|
"p_community_translations_desc": "Diese Übersetzungen werden von der Gemeinschaft erstellt und gewartet. Sie könnten Fehler enthalten oder unvollständig sein.",
|
||||||
|
|
||||||
|
"@_theme": {},
|
||||||
|
"s_app_theme": "App Theme",
|
||||||
|
"s_choose_app_theme": "App Theme auswählen",
|
||||||
|
"s_system_default": "Standard des Systems",
|
||||||
|
"s_light_mode": "Heller Modus",
|
||||||
|
"s_dark_mode": "Dunkler Modus",
|
||||||
|
|
||||||
|
"@_yubikey_selection": {},
|
||||||
|
"s_yk_information": "YubiKey Information",
|
||||||
|
"s_select_yk": "YubiKey auswählen",
|
||||||
|
"s_select_to_scan": "Zum Scannen auswählen",
|
||||||
|
"s_hide_device": "Gerät verstecken",
|
||||||
|
"s_show_hidden_devices": "Versteckte Geräte anzeigen",
|
||||||
|
"s_sn_serial": "S/N: {serial}",
|
||||||
|
"@s_sn_serial" : {
|
||||||
|
"placeholders": {
|
||||||
|
"serial": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_fw_version": "F/W: {version}",
|
||||||
|
"@s_fw_version" : {
|
||||||
|
"placeholders": {
|
||||||
|
"version": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"@_yubikey_interactions": {},
|
||||||
|
"l_insert_yk": "YubiKey anschließen",
|
||||||
|
"l_insert_or_tap_yk": "YubiKey anschließen oder dagegenhalten",
|
||||||
|
"l_unplug_yk": "Entfernen Sie Ihren YubiKey",
|
||||||
|
"l_reinsert_yk": "Schließen Sie Ihren YubiKey wieder an",
|
||||||
|
"l_place_on_nfc_reader": "Halten Sie Ihren YubiKey zum NFC-Leser",
|
||||||
|
"l_replace_yk_on_reader": "Halten Sie Ihren YubiKey wieder zum Leser",
|
||||||
|
"l_remove_yk_from_reader": "Entfernen Sie Ihren YubiKey vom NFC-Leser",
|
||||||
|
"p_try_reinsert_yk": "Versuchen Sie Ihren YubiKey zu entfernen und wieder anzuschließen.",
|
||||||
|
"s_touch_required": "Berührung erforderlich",
|
||||||
|
"l_touch_button_now": "Berühren Sie jetzt die Schaltfläche auf Ihrem YubiKey",
|
||||||
|
"l_keep_touching_yk": "Berühren Sie Ihren YubiKey wiederholt\u2026",
|
||||||
|
|
||||||
|
"@_app_configuration": {},
|
||||||
|
"s_toggle_applications": "Anwendungen umschalten",
|
||||||
|
"l_min_one_interface": "Mindestens ein Interface muss aktiviert sein",
|
||||||
|
"s_reconfiguring_yk": "YubiKey wird neu konfiguriert\u2026",
|
||||||
|
"s_config_updated": "Konfiguration aktualisiert",
|
||||||
|
"l_config_updated_reinsert": "Konfiguration aktualisiert, entfernen Sie Ihren YubiKey und schließen ihn wieder an",
|
||||||
|
"s_app_not_supported": "Anwendung nicht unterstützt",
|
||||||
|
"l_app_not_supported_on_yk": "Der verwendete YubiKey unterstützt die Anwendung '{app}' nicht",
|
||||||
|
"@l_app_not_supported_on_yk" : {
|
||||||
|
"placeholders": {
|
||||||
|
"app": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_app_not_supported_desc": "Diese Anwendung wird nicht unterstützt",
|
||||||
|
"s_app_disabled": "Anwendung deaktiviert",
|
||||||
|
"l_app_disabled_desc": "Aktivieren Sie die Anwendung '{app}' auf Ihrem YubiKey für Zugriff",
|
||||||
|
"@l_app_disabled_desc" : {
|
||||||
|
"placeholders": {
|
||||||
|
"app": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_fido_disabled": "FIDO2 deaktiviert",
|
||||||
|
"l_webauthn_req_fido2": "WebAuthn erfordert, dass die FIDO2 Anwendung auf Ihrem YubiKey aktiviert ist",
|
||||||
|
|
||||||
|
"@_connectivity_issues": {},
|
||||||
|
"l_helper_not_responding": "Der Helper-Prozess antwortet nicht",
|
||||||
|
"l_yk_no_access": "Auf diesen YubiKey kann nicht zugegriffen werden",
|
||||||
|
"s_yk_inaccessible": "Gerät nicht zugänglich",
|
||||||
|
"l_open_connection_failed": "Konnte keine Verbindung öffnen",
|
||||||
|
"l_ccid_connection_failed": "Konnte keine Smartcard-Verbindung öffnen",
|
||||||
|
"p_ccid_service_unavailable": "Stellen Sie sicher, dass Ihr Smartcard-Service funktioniert.",
|
||||||
|
"p_pcscd_unavailable": "Stellen Sie sicher, dass pcscd installiert ist und ausgeführt wird.",
|
||||||
|
"l_no_yk_present": "Kein YubiKey vorhanden",
|
||||||
|
"s_unknown_type": "Unbekannter Typ",
|
||||||
|
"s_unknown_device": "Unbekanntes Gerät",
|
||||||
|
"s_unsupported_yk": "Nicht unterstützter YubiKey",
|
||||||
|
"s_yk_not_recognized": "Geräte nicht erkannt",
|
||||||
|
|
||||||
|
"@_general_errors": {},
|
||||||
|
"l_error_occured": "Es ist ein Fehler aufgetreten",
|
||||||
|
"s_application_error": "Anwendungs-Fehler",
|
||||||
|
"l_import_error": "Import-Fehler",
|
||||||
|
"l_file_not_found": "Datei nicht gefunden",
|
||||||
|
"l_file_too_big": "Datei ist zu groß",
|
||||||
|
"l_filesystem_error": "Fehler beim Dateisystem-Zugriff",
|
||||||
|
|
||||||
|
"@_pins": {},
|
||||||
|
"s_pin": "PIN",
|
||||||
|
"s_set_pin": "PIN setzen",
|
||||||
|
"s_change_pin": "PIN ändern",
|
||||||
|
"s_current_pin": "Derzeitige PIN",
|
||||||
|
"s_new_pin": "Neue PIN",
|
||||||
|
"s_confirm_pin": "PIN bestätigen",
|
||||||
|
"l_new_pin_len": "Neue PIN muss mindestens {length} Zeichen lang sein",
|
||||||
|
"@l_new_pin_len" : {
|
||||||
|
"placeholders": {
|
||||||
|
"length": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_pin_set": "PIN gesetzt",
|
||||||
|
"l_set_pin_failed": "PIN konnte nicht gesetzt werden: {message}",
|
||||||
|
"@l_set_pin_failed" : {
|
||||||
|
"placeholders": {
|
||||||
|
"message": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_wrong_pin_attempts_remaining": "Falsche PIN, {retries} Versuch(e) verbleibend",
|
||||||
|
"@l_wrong_pin_attempts_remaining" : {
|
||||||
|
"placeholders": {
|
||||||
|
"retries": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_fido_pin_protection": "FIDO PIN Schutz",
|
||||||
|
"l_fido_pin_protection_optional": "Optionaler FIDO PIN Schutz",
|
||||||
|
"l_enter_fido2_pin": "Geben Sie die FIDO2 PIN für Ihren YubiKey ein",
|
||||||
|
"l_optionally_set_a_pin": "Setzen Sie optional eine PIN, um den Zugriff auf Ihren YubiKey zu schützen\nAls Sicherheitsschlüssel auf Webseiten registrieren",
|
||||||
|
"l_pin_blocked_reset": "PIN ist blockiert; setzen Sie die FIDO Anwendung auf Werkseinstellung zurück",
|
||||||
|
"l_set_pin_first": "Zuerst ist eine PIN erforderlich",
|
||||||
|
"l_unlock_pin_first": "Zuerst mit PIN entsperren",
|
||||||
|
"l_pin_soft_locked": "PIN wurde blockiert bis der YubiKey entfernt und wieder angeschlossen wird",
|
||||||
|
"p_enter_current_pin_or_reset": "Geben Sie Ihre aktuelle PIN ein. Wenn Sie die PIN nicht wissen, müssen Sie den YubiKey zurücksetzen.",
|
||||||
|
"p_enter_new_fido2_pin": "Geben Sie Ihre neue PIN ein. Eine PIN muss mindestens {length} Zeichen lang sein und kann Buchstaben, Ziffern und spezielle Zeichen enthalten.",
|
||||||
|
"@p_enter_new_fido2_pin" : {
|
||||||
|
"placeholders": {
|
||||||
|
"length": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"@_passwords": {},
|
||||||
|
"s_password": "Passwort",
|
||||||
|
"s_manage_password": "Passwort verwalten",
|
||||||
|
"s_set_password": "Passwort setzen",
|
||||||
|
"s_password_set": "Passwort gesetzt",
|
||||||
|
"l_optional_password_protection": "Optionaler Passwortschutz",
|
||||||
|
"s_new_password": "Neues Passwort",
|
||||||
|
"s_current_password": "Aktuelles Passwort",
|
||||||
|
"s_confirm_password": "Passwort bestätigen",
|
||||||
|
"s_wrong_password": "Falsches Passwort",
|
||||||
|
"s_remove_password": "Passwort entfernen",
|
||||||
|
"s_password_removed": "Passwort entfernt",
|
||||||
|
"s_remember_password": "Passwort speichern",
|
||||||
|
"s_clear_saved_password": "Gespeichertes Passwort entfernen",
|
||||||
|
"s_password_forgotten": "Passwort vergessen",
|
||||||
|
"l_keystore_unavailable": "Passwortspeicher des Betriebssystems nicht verfügbar",
|
||||||
|
"l_remember_pw_failed": "Konnte Passwort nicht speichern",
|
||||||
|
"l_unlock_first": "Zuerst mit Passwort entsperren",
|
||||||
|
"l_enter_oath_pw": "Das OATH-Passwort für Ihren YubiKey eingeben",
|
||||||
|
"p_enter_current_password_or_reset": "Geben Sie Ihr aktuelles Passwort ein. Wenn Sie Ihr Passwort nicht wissen, müssen Sie den YubiKey zurücksetzen.",
|
||||||
|
"p_enter_new_password": "Geben Sie Ihr neues Passwort ein. Ein Passwort kann Buchstaben, Ziffern und spezielle Zeichen enthalten.",
|
||||||
|
|
||||||
|
"@_oath_accounts": {},
|
||||||
|
"l_account": "Konto: {label}",
|
||||||
|
"@l_account" : {
|
||||||
|
"placeholders": {
|
||||||
|
"label": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_accounts": "Konten",
|
||||||
|
"s_no_accounts": "Keine Konten",
|
||||||
|
"s_add_account": "Konto hinzufügen",
|
||||||
|
"s_account_added": "Konto hinzugefügt",
|
||||||
|
"l_account_add_failed": "Fehler beim Hinzufügen des Kontos: {message}",
|
||||||
|
"@l_account_add_failed" : {
|
||||||
|
"placeholders": {
|
||||||
|
"message": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_account_name_required": "Ihr Konto muss einen Namen haben",
|
||||||
|
"l_name_already_exists": "Für diesen Aussteller existiert dieser Name bereits",
|
||||||
|
"l_invalid_character_issuer": "Ungültiges Zeichen: ':' ist im Aussteller nicht erlaubt",
|
||||||
|
"s_pinned": "Angepinnt",
|
||||||
|
"s_pin_account": "Konto anpinnen",
|
||||||
|
"s_unpin_account": "Konto nicht mehr anpinnen",
|
||||||
|
"s_no_pinned_accounts": "Keine angepinnten Konten",
|
||||||
|
"s_rename_account": "Konto umbenennen",
|
||||||
|
"s_account_renamed": "Konto umbenannt",
|
||||||
|
"p_rename_will_change_account_displayed": "Das ändert die Anzeige dieses Kontos in der Liste.",
|
||||||
|
"s_delete_account": "Konto löschen",
|
||||||
|
"s_account_deleted": "Konto gelöscht",
|
||||||
|
"p_warning_delete_account": "Vorsicht! Das löscht das Konto von Ihrem YubiKey.",
|
||||||
|
"p_warning_disable_credential": "Sie werden keine OTPs für dieses Konto mehr erstellen können. Deaktivieren Sie diese Anmeldeinformation zuerst auf der Webseite, um nicht aus dem Konto ausgesperrt zu werden.",
|
||||||
|
"s_account_name": "Kontoname",
|
||||||
|
"s_search_accounts": "Konten durchsuchen",
|
||||||
|
"l_accounts_used": "{used} von {capacity} Konten verwendet",
|
||||||
|
"@l_accounts_used" : {
|
||||||
|
"placeholders": {
|
||||||
|
"used": {},
|
||||||
|
"capacity": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_num_digits": "{num} Ziffern",
|
||||||
|
"@s_num_digits" : {
|
||||||
|
"placeholders": {
|
||||||
|
"num": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_num_sec": "{num} sek",
|
||||||
|
"@s_num_sec" : {
|
||||||
|
"placeholders": {
|
||||||
|
"num": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_issuer_optional": "Aussteller (optional)",
|
||||||
|
"s_counter_based": "Zähler-basiert",
|
||||||
|
"s_time_based": "Zeit-basiert",
|
||||||
|
|
||||||
|
"@_fido_credentials": {},
|
||||||
|
"l_credential": "Anmeldeinformation: {label}",
|
||||||
|
"@l_credential" : {
|
||||||
|
"placeholders": {
|
||||||
|
"label": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_credentials": "Anmeldeinformationen",
|
||||||
|
"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",
|
||||||
|
"s_delete_credential": "Anmeldeinformation löschen",
|
||||||
|
"s_credential_deleted": "Anmeldeinformation gelöscht",
|
||||||
|
"p_warning_delete_credential": "Das löscht die Anmeldeinformation von Ihrem YubiKey.",
|
||||||
|
|
||||||
|
"@_fingerprints": {},
|
||||||
|
"l_fingerprint": "Fingerabdruck: {label}",
|
||||||
|
"@l_fingerprint" : {
|
||||||
|
"placeholders": {
|
||||||
|
"label": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_fingerprints": "Fingerabdrücke",
|
||||||
|
"l_fingerprint_captured": "Fingerabdruck erfolgreich aufgenommen!",
|
||||||
|
"s_fingerprint_added": "Fingerabdruck hinzugefügt",
|
||||||
|
"l_setting_name_failed": "Fehler beim Setzen des Namens: {message}",
|
||||||
|
"@l_setting_name_failed" : {
|
||||||
|
"placeholders": {
|
||||||
|
"message": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_add_fingerprint": "Fingerabdruck hinzufügen",
|
||||||
|
"l_fp_step_1_capture": "Schritt 1/2: Fingerabdruck aufnehmen",
|
||||||
|
"l_fp_step_2_name": "Schritt 2/2: Fingerabdruck benennen",
|
||||||
|
"s_delete_fingerprint": "Fingerabdruck löschen",
|
||||||
|
"s_fingerprint_deleted": "Fingerabdruck gelöscht",
|
||||||
|
"p_warning_delete_fingerprint": "Das löscht den Fingerabdruck von Ihrem YubiKey.",
|
||||||
|
"s_no_fingerprints": "Keine Fingerabdrücke",
|
||||||
|
"l_set_pin_fingerprints": "Setzen Sie eine PIN um Fingerabdrücke zu registrieren",
|
||||||
|
"l_no_fps_added": "Es wurden keine Fingerabdrücke hinzugefügt",
|
||||||
|
"s_rename_fp": "Fingerabdruck umbenennen",
|
||||||
|
"s_fingerprint_renamed": "Fingerabdruck umbenannt",
|
||||||
|
"l_rename_fp_failed": "Fehler beim Umbenennen: {message}",
|
||||||
|
"@l_rename_fp_failed" : {
|
||||||
|
"placeholders": {
|
||||||
|
"message": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_add_one_or_more_fps": "Fügen Sie einen oder bis zu fünf Fingerabdrücke hinzu",
|
||||||
|
"l_fingerprints_used": "{used}/5 Fingerabdrücke registriert",
|
||||||
|
"@l_fingerprints_used": {
|
||||||
|
"placeholders": {
|
||||||
|
"used": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"p_press_fingerprint_begin": "Drücken Sie Ihren Finger gegen den YubiKey um zu beginnen.",
|
||||||
|
"p_will_change_label_fp": "Das ändert die Beschriftung des Fingerabdrucks.",
|
||||||
|
|
||||||
|
"@_permissions": {},
|
||||||
|
"s_enable_nfc": "NFC aktivieren",
|
||||||
|
"s_permission_denied": "Zugriff verweigert",
|
||||||
|
"l_elevating_permissions": "Erhöhe Berechtigungen\u2026",
|
||||||
|
"s_review_permissions": "Berechtigungen überprüfen",
|
||||||
|
"p_elevated_permissions_required": "Die Verwaltung dieses Geräts benötigt erhöhte Berechtigungen.",
|
||||||
|
"p_webauthn_elevated_permissions_required": "WebAuthn-Verwaltung benötigt erhöhte Berechtigungen.",
|
||||||
|
"p_need_camera_permission": "Yubico Authenticator benötigt Zugriff auf die Kamera um QR-Codes aufnehmen zu können.",
|
||||||
|
|
||||||
|
"@_qr_codes": {},
|
||||||
|
"s_qr_scan": "QR-Code aufnehmen",
|
||||||
|
"l_qr_scanned": "QR-Code aufgenommen",
|
||||||
|
"l_invalid_qr": "Ungültiger QR-Code",
|
||||||
|
"l_qr_not_found": "Kein QR-Code gefunden",
|
||||||
|
"l_qr_not_read": "Fehler beim Lesen des QR-Codes: {message}",
|
||||||
|
"@l_qr_not_read" : {
|
||||||
|
"placeholders": {
|
||||||
|
"message": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_point_camera_scan": "Halten Sie Ihre Kamera auf einen QR-Code um ihn aufzunehmen",
|
||||||
|
"q_want_to_scan": "Möchten Sie aufnehmen?",
|
||||||
|
"q_no_qr": "Kein QR-Code?",
|
||||||
|
"s_enter_manually": "Manuell eingeben",
|
||||||
|
|
||||||
|
"@_factory_reset": {},
|
||||||
|
"s_reset": "Zurücksetzen",
|
||||||
|
"s_factory_reset": "Werkseinstellungen",
|
||||||
|
"l_factory_reset_this_app": "Anwendung auf Werkseinstellung zurücksetzen",
|
||||||
|
"s_reset_oath": "OATH zurücksetzen",
|
||||||
|
"l_oath_application_reset": "OATH Anwendung zurücksetzen",
|
||||||
|
"s_reset_fido": "FIDO zurücksetzen",
|
||||||
|
"l_fido_app_reset": "FIDO Anwendung zurückgesetzt",
|
||||||
|
"l_press_reset_to_begin": "Drücken Sie Zurücksetzen um zu beginnen\u2026",
|
||||||
|
"l_reset_failed": "Fehler beim Zurücksetzen: {message}",
|
||||||
|
"@l_reset_failed" : {
|
||||||
|
"placeholders": {
|
||||||
|
"message": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"p_warning_factory_reset": "Achtung! Das löscht alle OATH TOTP/HOTP Konten unwiederbringlich von Ihrem YubiKey.",
|
||||||
|
"p_warning_disable_credentials": "Ihre OATH Anmeldeinformationen und jedes gesetzte Passwort wird von diesem YubiKey entfernt. Deaktivieren Sie diese zuerst auf den zugehörigen Webseiten, um nicht aus ihren Konten ausgesperrt zu werden.",
|
||||||
|
"p_warning_deletes_accounts": "Achtung! Das löscht alle U2F und FIDO2 Konten unwiederbringlich von Ihrem YubiKey.",
|
||||||
|
"p_warning_disable_accounts": "Ihre Anmeldeinformationen und jede gesetzte PIN wird von diesem YubiKey entfernt. Deaktivieren Sie diese zuerst auf den zugehörigen Webseiten, um nicht aus ihren Konten ausgesperrt zu werden.",
|
||||||
|
|
||||||
|
"@_copy_to_clipboard": {},
|
||||||
|
"l_copy_to_clipboard": "In die Zwischenablage kopieren",
|
||||||
|
"s_code_copied": "Code kopiert",
|
||||||
|
"l_code_copied_clipboard": "Code in die Zwischenablage kopiert",
|
||||||
|
"s_copy_log": "Log kopiert",
|
||||||
|
"l_log_copied": "Log in die Zwischenablage kopiert",
|
||||||
|
"l_diagnostics_copied": "Diagnosedaten in die Zwischenablage kopiert",
|
||||||
|
"p_target_copied_clipboard": "{label} in die Zwischenablage kopiert.",
|
||||||
|
"@p_target_copied_clipboard" : {
|
||||||
|
"placeholders": {
|
||||||
|
"label": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"@_custom_icons": {},
|
||||||
|
"s_custom_icons": "Eigene Icons",
|
||||||
|
"l_set_icons_for_accounts": "Icons für Konten setzen",
|
||||||
|
"p_custom_icons_description": "Icon-Pakete machen Ihre Konten mit bekannten Logos und Farben leichter unterscheidbar.",
|
||||||
|
"s_replace_icon_pack": "Icon-Paket ersetzen",
|
||||||
|
"l_loading_icon_pack": "Lade Icon-Paket\u2026",
|
||||||
|
"s_load_icon_pack": "Icon-Paket laden",
|
||||||
|
"s_remove_icon_pack": "Icon-Paket entfernen",
|
||||||
|
"l_icon_pack_removed": "Icon-Paket entfernt",
|
||||||
|
"l_remove_icon_pack_failed": "Fehler beim Entfernen des Icon-Pakets",
|
||||||
|
"s_choose_icon_pack": "Icon-Paket auswählen",
|
||||||
|
"l_icon_pack_imported": "Icon-Paket importiert",
|
||||||
|
"l_import_icon_pack_failed": "Fehler beim Importieren des Icon-Pakets: {message}",
|
||||||
|
"@l_import_icon_pack_failed": {
|
||||||
|
"placeholders": {
|
||||||
|
"message": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_invalid_icon_pack": "Ungültiges Icon-Paket",
|
||||||
|
"l_icon_pack_copy_failed": "Kopieren der Dateien des Icon-Pakets fehlgeschlagen",
|
||||||
|
|
||||||
|
"@_android_settings": {},
|
||||||
|
"s_nfc_options": "NFC Optionen",
|
||||||
|
"l_on_yk_nfc_tap": "Bei YubiKey NFC-Berührung",
|
||||||
|
"l_launch_ya": "Yubico Authenticator starten",
|
||||||
|
"l_copy_otp_clipboard": "OTP in Zwischenablage kopieren",
|
||||||
|
"l_launch_and_copy_otp": "App starten und OTP kopieren",
|
||||||
|
"l_kbd_layout_for_static": "Tastaturlayout (für statisches Passwort)",
|
||||||
|
"s_choose_kbd_layout": "Tastaturlayout auswählen",
|
||||||
|
"l_bypass_touch_requirement": "Notwendigkeit zur Berührung umgehen",
|
||||||
|
"l_bypass_touch_requirement_on": "Konten, die Berührung erfordern, werden automatisch über NFC angezeigt",
|
||||||
|
"l_bypass_touch_requirement_off": "Konten, die Berührung erfordern, benötigen eine zusätzliche NFC-Berührung",
|
||||||
|
"s_silence_nfc_sounds": "NFC-Töne stummschalten",
|
||||||
|
"l_silence_nfc_sounds_on": "Keine Töne werden bei NFC-Berührung abgespielt",
|
||||||
|
"l_silence_nfc_sounds_off": "Töne werden bei NFC-Berührung abgespielt",
|
||||||
|
"s_usb_options": "USB Optionen",
|
||||||
|
"l_launch_app_on_usb": "Starten, wenn YubiKey angesteckt wird",
|
||||||
|
"l_launch_app_on_usb_on": "Das verhindert, dass andere Anwendungen den YubiKey über USB nutzen",
|
||||||
|
"l_launch_app_on_usb_off": "Andere Anwendungen können den YubiKey über USB nutzen",
|
||||||
|
"s_allow_screenshots": "Bildschirmfotos erlauben",
|
||||||
|
|
||||||
|
"@_eof": {}
|
||||||
|
}
|
@ -29,6 +29,7 @@
|
|||||||
"s_quit": "Quit",
|
"s_quit": "Quit",
|
||||||
"s_unlock": "Unlock",
|
"s_unlock": "Unlock",
|
||||||
"s_calculate": "Calculate",
|
"s_calculate": "Calculate",
|
||||||
|
"s_import": "Import",
|
||||||
"s_label": "Label",
|
"s_label": "Label",
|
||||||
"s_name": "Name",
|
"s_name": "Name",
|
||||||
"s_usb": "USB",
|
"s_usb": "USB",
|
||||||
@ -41,13 +42,21 @@
|
|||||||
"label": {}
|
"label": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"l_bullet": "• {item}",
|
||||||
|
"@l_bullet" : {
|
||||||
|
"placeholders": {
|
||||||
|
"item": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"s_about": "About",
|
"s_about": "About",
|
||||||
"s_appearance": "Appearance",
|
"s_appearance": "Appearance",
|
||||||
"s_authenticator": "Authenticator",
|
"s_authenticator": "Authenticator",
|
||||||
|
"s_actions": "Actions",
|
||||||
"s_manage": "Manage",
|
"s_manage": "Manage",
|
||||||
"s_setup": "Setup",
|
"s_setup": "Setup",
|
||||||
"s_settings": "Settings",
|
"s_settings": "Settings",
|
||||||
|
"s_piv": "PIV",
|
||||||
"s_webauthn": "WebAuthn",
|
"s_webauthn": "WebAuthn",
|
||||||
"s_help_and_about": "Help and about",
|
"s_help_and_about": "Help and about",
|
||||||
"s_help_and_feedback": "Help and feedback",
|
"s_help_and_feedback": "Help and feedback",
|
||||||
@ -60,6 +69,7 @@
|
|||||||
"s_configure_yk": "Configure YubiKey",
|
"s_configure_yk": "Configure YubiKey",
|
||||||
"s_please_wait": "Please wait\u2026",
|
"s_please_wait": "Please wait\u2026",
|
||||||
"s_secret_key": "Secret key",
|
"s_secret_key": "Secret key",
|
||||||
|
"s_private_key": "Private key",
|
||||||
"s_invalid_length": "Invalid length",
|
"s_invalid_length": "Invalid length",
|
||||||
"s_require_touch": "Require touch",
|
"s_require_touch": "Require touch",
|
||||||
"q_have_account_info": "Have account info?",
|
"q_have_account_info": "Have account info?",
|
||||||
@ -165,11 +175,17 @@
|
|||||||
|
|
||||||
"@_pins": {},
|
"@_pins": {},
|
||||||
"s_pin": "PIN",
|
"s_pin": "PIN",
|
||||||
|
"s_puk": "PUK",
|
||||||
"s_set_pin": "Set PIN",
|
"s_set_pin": "Set PIN",
|
||||||
"s_change_pin": "Change PIN",
|
"s_change_pin": "Change PIN",
|
||||||
|
"s_change_puk": "Change PUK",
|
||||||
"s_current_pin": "Current PIN",
|
"s_current_pin": "Current PIN",
|
||||||
|
"s_current_puk": "Current PUK",
|
||||||
"s_new_pin": "New PIN",
|
"s_new_pin": "New PIN",
|
||||||
|
"s_new_puk": "New PUK",
|
||||||
"s_confirm_pin": "Confirm PIN",
|
"s_confirm_pin": "Confirm PIN",
|
||||||
|
"s_confirm_puk": "Confirm PUK",
|
||||||
|
"s_unblock_pin": "Unblock PIN",
|
||||||
"l_new_pin_len": "New PIN must be at least {length} characters",
|
"l_new_pin_len": "New PIN must be at least {length} characters",
|
||||||
"@l_new_pin_len" : {
|
"@l_new_pin_len" : {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@ -177,12 +193,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"s_pin_set": "PIN set",
|
"s_pin_set": "PIN set",
|
||||||
|
"s_puk_set": "PUK set",
|
||||||
"l_set_pin_failed": "Failed to set PIN: {message}",
|
"l_set_pin_failed": "Failed to set PIN: {message}",
|
||||||
"@l_set_pin_failed" : {
|
"@l_set_pin_failed" : {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"message": {}
|
"message": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"l_attempts_remaining": "{retries} attempt(s) remaining",
|
||||||
|
"@l_attempts_remaining" : {
|
||||||
|
"placeholders": {
|
||||||
|
"retries": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
"l_wrong_pin_attempts_remaining": "Wrong PIN, {retries} attempt(s) remaining",
|
"l_wrong_pin_attempts_remaining": "Wrong PIN, {retries} attempt(s) remaining",
|
||||||
"@l_wrong_pin_attempts_remaining" : {
|
"@l_wrong_pin_attempts_remaining" : {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@ -197,13 +220,23 @@
|
|||||||
"l_set_pin_first": "A PIN is required first",
|
"l_set_pin_first": "A PIN is required first",
|
||||||
"l_unlock_pin_first": "Unlock with PIN first",
|
"l_unlock_pin_first": "Unlock with PIN first",
|
||||||
"l_pin_soft_locked": "PIN has been blocked until the YubiKey is removed and reinserted",
|
"l_pin_soft_locked": "PIN has been blocked until the YubiKey is removed and reinserted",
|
||||||
"p_enter_current_pin_or_reset": "Enter your current PIN. If you don't know your PIN, you'll need to reset the YubiKey.",
|
"p_enter_current_pin_or_reset": "Enter your current PIN. If you don't know your PIN, you'll need to unblock it with the PUK or reset the YubiKey.",
|
||||||
|
"p_enter_current_puk_or_reset": "Enter your current PUK. If you don't know your PUK, you'll need to reset the YubiKey.",
|
||||||
"p_enter_new_fido2_pin": "Enter your new PIN. A PIN must be at least {length} characters long and may contain letters, numbers and special characters.",
|
"p_enter_new_fido2_pin": "Enter your new PIN. A PIN must be at least {length} characters long and may contain letters, numbers and special characters.",
|
||||||
"@p_enter_new_fido2_pin" : {
|
"@p_enter_new_fido2_pin" : {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"length": {}
|
"length": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"s_pin_required": "PIN required",
|
||||||
|
"p_pin_required_desc": "The action you are about to perform requires the PIV PIN to be entered.",
|
||||||
|
"l_piv_pin_blocked": "Blocked, use PUK to reset",
|
||||||
|
"p_enter_new_piv_pin_puk": "Enter a new {name} to set. Must be 6-8 characters.",
|
||||||
|
"@p_enter_new_piv_pin_puk" : {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"@_passwords": {},
|
"@_passwords": {},
|
||||||
"s_password": "Password",
|
"s_password": "Password",
|
||||||
@ -227,6 +260,21 @@
|
|||||||
"p_enter_current_password_or_reset": "Enter your current password. If you don't know your password, you'll need to reset the YubiKey.",
|
"p_enter_current_password_or_reset": "Enter your current password. If you don't know your password, you'll need to reset the YubiKey.",
|
||||||
"p_enter_new_password": "Enter your new password. A password may contain letters, numbers and special characters.",
|
"p_enter_new_password": "Enter your new password. A password may contain letters, numbers and special characters.",
|
||||||
|
|
||||||
|
"@_management_key": {},
|
||||||
|
"s_management_key": "Management key",
|
||||||
|
"s_current_management_key": "Current management key",
|
||||||
|
"s_new_management_key": "New management key",
|
||||||
|
"l_change_management_key": "Change management key",
|
||||||
|
"p_change_management_key_desc": "Change your management key. You can optionally choose to allow the PIN to be used instead of the management key.",
|
||||||
|
"l_management_key_changed": "Management key changed",
|
||||||
|
"l_default_key_used": "Default management key used",
|
||||||
|
"l_warning_default_key": "Warning: Default key used",
|
||||||
|
"s_protect_key": "Protect with PIN",
|
||||||
|
"l_pin_protected_key": "PIN can be used instead",
|
||||||
|
"l_wrong_key": "Wrong key",
|
||||||
|
"l_unlock_piv_management": "Unlock PIV management",
|
||||||
|
"p_unlock_piv_management_desc": "The action you are about to perform requires the PIV management key. Provide this key to unlock management functionality for this session.",
|
||||||
|
|
||||||
"@_oath_accounts": {},
|
"@_oath_accounts": {},
|
||||||
"l_account": "Account: {label}",
|
"l_account": "Account: {label}",
|
||||||
"@l_account" : {
|
"@l_account" : {
|
||||||
@ -251,10 +299,13 @@
|
|||||||
"s_pin_account": "Pin account",
|
"s_pin_account": "Pin account",
|
||||||
"s_unpin_account": "Unpin account",
|
"s_unpin_account": "Unpin account",
|
||||||
"s_no_pinned_accounts": "No pinned accounts",
|
"s_no_pinned_accounts": "No pinned accounts",
|
||||||
|
"l_pin_account_desc": "Keep your important accounts together",
|
||||||
"s_rename_account": "Rename account",
|
"s_rename_account": "Rename account",
|
||||||
|
"l_rename_account_desc": "Edit the issuer/name of the account",
|
||||||
"s_account_renamed": "Account renamed",
|
"s_account_renamed": "Account renamed",
|
||||||
"p_rename_will_change_account_displayed": "This will change how the account is displayed in the list.",
|
"p_rename_will_change_account_displayed": "This will change how the account is displayed in the list.",
|
||||||
"s_delete_account": "Delete account",
|
"s_delete_account": "Delete account",
|
||||||
|
"l_delete_account_desc": "Remove the account from your YubiKey",
|
||||||
"s_account_deleted": "Account deleted",
|
"s_account_deleted": "Account deleted",
|
||||||
"p_warning_delete_account": "Warning! This action will delete the account from your YubiKey.",
|
"p_warning_delete_account": "Warning! This action will delete the account from your YubiKey.",
|
||||||
"p_warning_disable_credential": "You will no longer be able to generate OTPs for this account. Make sure to first disable this credential from the website to avoid being locked out of your account.",
|
"p_warning_disable_credential": "You will no longer be able to generate OTPs for this account. Make sure to first disable this credential from the website to avoid being locked out of your account.",
|
||||||
@ -282,21 +333,25 @@
|
|||||||
"s_issuer_optional": "Issuer (optional)",
|
"s_issuer_optional": "Issuer (optional)",
|
||||||
"s_counter_based": "Counter based",
|
"s_counter_based": "Counter based",
|
||||||
"s_time_based": "Time based",
|
"s_time_based": "Time based",
|
||||||
|
"l_copy_code_desc": "Easily paste the code into another app",
|
||||||
|
"s_calculate_code": "Calculate code",
|
||||||
|
"l_calculate_code_desc": "Get a new code from your YubiKey",
|
||||||
|
|
||||||
"@_fido_credentials": {},
|
"@_fido_credentials": {},
|
||||||
"l_credential": "Credential: {label}",
|
"l_passkey": "Passkey: {label}",
|
||||||
"@l_credential" : {
|
"@l_passkey" : {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"label": {}
|
"label": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"s_credentials": "Credentials",
|
"s_passkeys": "Passkeys",
|
||||||
"l_ready_to_use": "Ready to use",
|
"l_ready_to_use": "Ready to use",
|
||||||
"l_register_sk_on_websites": "Register as a Security Key on websites",
|
"l_register_sk_on_websites": "Register as a Security Key on websites",
|
||||||
"l_no_discoverable_accounts": "No discoverable accounts",
|
"l_no_discoverable_accounts": "No Passkeys stored",
|
||||||
"s_delete_credential": "Delete credential",
|
"s_delete_passkey": "Delete Passkey",
|
||||||
"s_credential_deleted": "Credential deleted",
|
"l_delete_passkey_desc": "Remove the Passkey from the YubiKey",
|
||||||
"p_warning_delete_credential": "This will delete the credential from your YubiKey.",
|
"s_passkey_deleted": "Passkey deleted",
|
||||||
|
"p_warning_delete_passkey": "This will delete the Passkey from your YubiKey.",
|
||||||
|
|
||||||
"@_fingerprints": {},
|
"@_fingerprints": {},
|
||||||
"l_fingerprint": "Fingerprint: {label}",
|
"l_fingerprint": "Fingerprint: {label}",
|
||||||
@ -318,12 +373,14 @@
|
|||||||
"l_fp_step_1_capture": "Step 1/2: Capture fingerprint",
|
"l_fp_step_1_capture": "Step 1/2: Capture fingerprint",
|
||||||
"l_fp_step_2_name": "Step 2/2: Name fingerprint",
|
"l_fp_step_2_name": "Step 2/2: Name fingerprint",
|
||||||
"s_delete_fingerprint": "Delete fingerprint",
|
"s_delete_fingerprint": "Delete fingerprint",
|
||||||
|
"l_delete_fingerprint_desc": "Remove the fingerprint from the YubiKey",
|
||||||
"s_fingerprint_deleted": "Fingerprint deleted",
|
"s_fingerprint_deleted": "Fingerprint deleted",
|
||||||
"p_warning_delete_fingerprint": "This will delete the fingerprint from your YubiKey.",
|
"p_warning_delete_fingerprint": "This will delete the fingerprint from your YubiKey.",
|
||||||
"s_no_fingerprints": "No fingerprints",
|
"s_no_fingerprints": "No fingerprints",
|
||||||
"l_set_pin_fingerprints": "Set a PIN to register fingerprints",
|
"l_set_pin_fingerprints": "Set a PIN to register fingerprints",
|
||||||
"l_no_fps_added": "No fingerprints have been added",
|
"l_no_fps_added": "No fingerprints have been added",
|
||||||
"s_rename_fp": "Rename fingerprint",
|
"s_rename_fp": "Rename fingerprint",
|
||||||
|
"l_rename_fp_desc": "Change the label",
|
||||||
"s_fingerprint_renamed": "Fingerprint renamed",
|
"s_fingerprint_renamed": "Fingerprint renamed",
|
||||||
"l_rename_fp_failed": "Error renaming: {message}",
|
"l_rename_fp_failed": "Error renaming: {message}",
|
||||||
"@l_rename_fp_failed" : {
|
"@l_rename_fp_failed" : {
|
||||||
@ -341,6 +398,88 @@
|
|||||||
"p_press_fingerprint_begin": "Press your finger against the YubiKey to begin.",
|
"p_press_fingerprint_begin": "Press your finger against the YubiKey to begin.",
|
||||||
"p_will_change_label_fp": "This will change the label of the fingerprint.",
|
"p_will_change_label_fp": "This will change the label of the fingerprint.",
|
||||||
|
|
||||||
|
"@_certificates": {},
|
||||||
|
"s_certificate": "Certificate",
|
||||||
|
"s_certificates": "Certificates",
|
||||||
|
"s_csr": "CSR",
|
||||||
|
"s_subject": "Subject",
|
||||||
|
"l_export_csr_file": "Save CSR to file",
|
||||||
|
"l_select_import_file": "Select file to import",
|
||||||
|
"l_export_certificate": "Export certificate",
|
||||||
|
"l_export_certificate_file": "Export certificate to file",
|
||||||
|
"l_export_certificate_desc": "Export the certificate to a file",
|
||||||
|
"l_certificate_exported": "Certificate exported",
|
||||||
|
"l_import_file": "Import file",
|
||||||
|
"l_import_desc": "Import a key and/or certificate",
|
||||||
|
"l_delete_certificate": "Delete certificate",
|
||||||
|
"l_delete_certificate_desc": "Remove the certificate from your YubiKey",
|
||||||
|
"l_subject_issuer": "Subject: {subject}, Issuer: {issuer}",
|
||||||
|
"@l_subject_issuer" : {
|
||||||
|
"placeholders": {
|
||||||
|
"subject": {},
|
||||||
|
"issuer": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_serial": "Serial: {serial}",
|
||||||
|
"@l_serial" : {
|
||||||
|
"placeholders": {
|
||||||
|
"serial": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_certificate_fingerprint": "Fingerprint: {fingerprint}",
|
||||||
|
"@l_certificate_fingerprint" : {
|
||||||
|
"placeholders": {
|
||||||
|
"fingerprint": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_valid": "Valid: {not_before} - {not_after}",
|
||||||
|
"@l_valid" : {
|
||||||
|
"placeholders": {
|
||||||
|
"not_before": {},
|
||||||
|
"not_after": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_no_certificate": "No certificate loaded",
|
||||||
|
"l_key_no_certificate": "Key without certificate loaded",
|
||||||
|
"s_generate_key": "Generate key",
|
||||||
|
"l_generate_desc": "Generate a new certificate or CSR",
|
||||||
|
"p_generate_desc": "This will generate a new key on the YubiKey in PIV slot {slot}. The public key will be embedded into a self-signed certificate stored on the YubiKey, or in a certificate signing request (CSR) saved to file.",
|
||||||
|
"@p_generate_desc" : {
|
||||||
|
"placeholders": {
|
||||||
|
"slot": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_generating_private_key": "Generating private key\u2026",
|
||||||
|
"s_private_key_generated": "Private key generated",
|
||||||
|
"p_warning_delete_certificate": "Warning! This action will delete the certificate from your YubiKey.",
|
||||||
|
"q_delete_certificate_confirm": "Delete the certficate in PIV slot {slot}?",
|
||||||
|
"@q_delete_certificate_confirm" : {
|
||||||
|
"placeholders": {
|
||||||
|
"slot": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l_certificate_deleted": "Certificate deleted",
|
||||||
|
"p_password_protected_file": "The selected file is password protected. Enter the password to proceed.",
|
||||||
|
"p_import_items_desc": "The following items will be imported into PIV slot {slot}.",
|
||||||
|
"@p_import_items_desc" : {
|
||||||
|
"placeholders": {
|
||||||
|
"slot": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"@_piv_slots": {},
|
||||||
|
"s_slot_display_name": "{name} ({hexid})",
|
||||||
|
"@s_slot_display_name" : {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {},
|
||||||
|
"hexid": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s_slot_9a": "Authentication",
|
||||||
|
"s_slot_9c": "Digital Signature",
|
||||||
|
"s_slot_9d": "Key Management",
|
||||||
|
"s_slot_9e": "Card Authentication",
|
||||||
|
|
||||||
"@_permissions": {},
|
"@_permissions": {},
|
||||||
"s_enable_nfc": "Enable NFC",
|
"s_enable_nfc": "Enable NFC",
|
||||||
"s_permission_denied": "Permission denied",
|
"s_permission_denied": "Permission denied",
|
||||||
@ -381,10 +520,14 @@
|
|||||||
"message": {}
|
"message": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"s_reset_piv": "Reset PIV",
|
||||||
|
"l_piv_app_reset": "PIV application reset",
|
||||||
"p_warning_factory_reset": "Warning! This will irrevocably delete all OATH TOTP/HOTP accounts from your YubiKey.",
|
"p_warning_factory_reset": "Warning! This will irrevocably delete all OATH TOTP/HOTP accounts from your YubiKey.",
|
||||||
"p_warning_disable_credentials": "Your OATH credentials, as well as any password set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.",
|
"p_warning_disable_credentials": "Your OATH credentials, as well as any password set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.",
|
||||||
"p_warning_deletes_accounts": "Warning! This will irrevocably delete all U2F and FIDO2 accounts from your YubiKey.",
|
"p_warning_deletes_accounts": "Warning! This will irrevocably delete all U2F and FIDO2 accounts from your YubiKey.",
|
||||||
"p_warning_disable_accounts": "Your credentials, as well as any PIN set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.",
|
"p_warning_disable_accounts": "Your credentials, as well as any PIN set, will be removed from this YubiKey. Make sure to first disable these from their respective web sites to avoid being locked out of your accounts.",
|
||||||
|
"p_warning_piv_reset": "Warning! All data stored for PIV will be irrevocably deleted from your YubiKey.",
|
||||||
|
"p_warning_piv_reset_desc": "This includes private keys and certificates. Your PIN, PUK, and management key will be reset to their factory default values.",
|
||||||
|
|
||||||
"@_copy_to_clipboard": {},
|
"@_copy_to_clipboard": {},
|
||||||
"l_copy_to_clipboard": "Copy to clipboard",
|
"l_copy_to_clipboard": "Copy to clipboard",
|
||||||
|
@ -20,9 +20,9 @@ import 'package:yubico_authenticator/management/models.dart';
|
|||||||
import '../app/models.dart';
|
import '../app/models.dart';
|
||||||
import '../core/state.dart';
|
import '../core/state.dart';
|
||||||
|
|
||||||
final managementStateProvider = StateNotifierProvider.autoDispose
|
final managementStateProvider = AsyncNotifierProvider.autoDispose
|
||||||
.family<ManagementStateNotifier, AsyncValue<DeviceInfo>, DevicePath>(
|
.family<ManagementStateNotifier, DeviceInfo, DevicePath>(
|
||||||
(ref, devicePath) => throw UnimplementedError(),
|
() => throw UnimplementedError(),
|
||||||
);
|
);
|
||||||
|
|
||||||
abstract class ManagementStateNotifier
|
abstract class ManagementStateNotifier
|
||||||
|
@ -118,7 +118,6 @@ class _CapabilitiesForm extends StatelessWidget {
|
|||||||
leading: const Icon(Icons.usb),
|
leading: const Icon(Icons.usb),
|
||||||
title: Text(l10n.s_usb),
|
title: Text(l10n.s_usb),
|
||||||
contentPadding: const EdgeInsets.only(bottom: 8),
|
contentPadding: const EdgeInsets.only(bottom: 8),
|
||||||
horizontalTitleGap: 0,
|
|
||||||
),
|
),
|
||||||
_CapabilityForm(
|
_CapabilityForm(
|
||||||
type: _CapabilityType.usb,
|
type: _CapabilityType.usb,
|
||||||
@ -139,7 +138,6 @@ class _CapabilitiesForm extends StatelessWidget {
|
|||||||
leading: nfcIcon,
|
leading: nfcIcon,
|
||||||
title: Text(l10n.s_nfc),
|
title: Text(l10n.s_nfc),
|
||||||
contentPadding: const EdgeInsets.only(bottom: 8),
|
contentPadding: const EdgeInsets.only(bottom: 8),
|
||||||
horizontalTitleGap: 0,
|
|
||||||
),
|
),
|
||||||
_CapabilityForm(
|
_CapabilityForm(
|
||||||
type: _CapabilityType.nfc,
|
type: _CapabilityType.nfc,
|
||||||
|
@ -17,18 +17,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
const _prefix = 'oath.keys';
|
const _prefix = 'oath.keys';
|
||||||
|
const _keyAction = '$_prefix.actions';
|
||||||
const setOrManagePasswordAction = Key('$_prefix.set_or_manage_password');
|
const _accountAction = '$_prefix.account.actions';
|
||||||
const addAccountAction = Key('$_prefix.add_account');
|
|
||||||
const resetAction = Key('$_prefix.reset');
|
|
||||||
|
|
||||||
const customIconsAction = Key('$_prefix.custom_icons');
|
|
||||||
|
|
||||||
const noAccountsView = Key('$_prefix.no_accounts');
|
|
||||||
|
|
||||||
// This is global so we can access it from the global Ctrl+F shortcut.
|
// This is global so we can access it from the global Ctrl+F shortcut.
|
||||||
final searchAccountsField = GlobalKey();
|
final searchAccountsField = GlobalKey();
|
||||||
|
|
||||||
|
// Key actions
|
||||||
|
const setOrManagePasswordAction =
|
||||||
|
Key('$_keyAction.action.set_or_manage_password');
|
||||||
|
const addAccountAction = Key('$_keyAction.add_account');
|
||||||
|
const resetAction = Key('$_keyAction.reset');
|
||||||
|
const customIconsAction = Key('$_keyAction.custom_icons');
|
||||||
|
|
||||||
|
// Credential actions
|
||||||
|
const copyAction = Key('$_accountAction.copy');
|
||||||
|
const calculateAction = Key('$_accountAction.calculate');
|
||||||
|
const togglePinAction = Key('$_accountAction.toggle_pin');
|
||||||
|
const editAction = Key('$_accountAction.edit');
|
||||||
|
const deleteAction = Key('$_accountAction.delete');
|
||||||
|
|
||||||
|
const noAccountsView = Key('$_prefix.no_accounts');
|
||||||
|
|
||||||
const passwordField = Key('$_prefix.password');
|
const passwordField = Key('$_prefix.password');
|
||||||
const currentPasswordField = Key('$_prefix.current_password');
|
const currentPasswordField = Key('$_prefix.current_password');
|
||||||
const newPasswordField = Key('$_prefix.new_password');
|
const newPasswordField = Key('$_prefix.new_password');
|
||||||
|
@ -37,9 +37,9 @@ class SearchNotifier extends StateNotifier<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final oathStateProvider = StateNotifierProvider.autoDispose
|
final oathStateProvider = AsyncNotifierProvider.autoDispose
|
||||||
.family<OathStateNotifier, AsyncValue<OathState>, DevicePath>(
|
.family<OathStateNotifier, OathState, DevicePath>(
|
||||||
(ref, devicePath) => throw UnimplementedError(),
|
() => throw UnimplementedError(),
|
||||||
);
|
);
|
||||||
|
|
||||||
abstract class OathStateNotifier extends ApplicationStateNotifier<OathState> {
|
abstract class OathStateNotifier extends ApplicationStateNotifier<OathState> {
|
||||||
|
@ -23,6 +23,8 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
|||||||
import '../../app/message.dart';
|
import '../../app/message.dart';
|
||||||
import '../../app/shortcuts.dart';
|
import '../../app/shortcuts.dart';
|
||||||
import '../../app/state.dart';
|
import '../../app/state.dart';
|
||||||
|
import '../../app/views/fs_dialog.dart';
|
||||||
|
import '../../app/views/action_list.dart';
|
||||||
import '../../core/models.dart';
|
import '../../core/models.dart';
|
||||||
import '../../core/state.dart';
|
import '../../core/state.dart';
|
||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
@ -37,60 +39,6 @@ class AccountDialog extends ConsumerWidget {
|
|||||||
|
|
||||||
const AccountDialog(this.credential, {super.key});
|
const AccountDialog(this.credential, {super.key});
|
||||||
|
|
||||||
List<Widget> _buildActions(BuildContext context, AccountHelper helper) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final actions = helper.buildActions();
|
|
||||||
|
|
||||||
final theme =
|
|
||||||
ButtonTheme.of(context).colorScheme ?? Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
final copy =
|
|
||||||
actions.firstWhere(((e) => e.text == l10n.l_copy_to_clipboard));
|
|
||||||
final delete = actions.firstWhere(((e) => e.text == l10n.s_delete_account));
|
|
||||||
final colors = {
|
|
||||||
copy: (theme.primary, theme.onPrimary),
|
|
||||||
delete: (theme.error, theme.onError),
|
|
||||||
};
|
|
||||||
|
|
||||||
// If we can't copy, but can calculate, highlight that button instead
|
|
||||||
if (copy.intent == null) {
|
|
||||||
final calculates = actions.where(((e) => e.text == l10n.s_calculate));
|
|
||||||
if (calculates.isNotEmpty) {
|
|
||||||
colors[calculates.first] = (theme.primary, theme.onPrimary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions.map((e) {
|
|
||||||
final intent = e.intent;
|
|
||||||
final (firstColor, secondColor) =
|
|
||||||
colors[e] ?? (theme.secondary, theme.onSecondary);
|
|
||||||
final tooltip = e.trailing != null ? '${e.text}\n${e.trailing}' : e.text;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6.0),
|
|
||||||
child: CircleAvatar(
|
|
||||||
backgroundColor: intent != null ? firstColor : theme.secondary,
|
|
||||||
foregroundColor: secondColor,
|
|
||||||
child: IconButton(
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: intent != null ? firstColor : theme.secondary,
|
|
||||||
foregroundColor: secondColor,
|
|
||||||
disabledBackgroundColor: theme.onSecondary.withOpacity(0.2),
|
|
||||||
fixedSize: const Size.square(38),
|
|
||||||
),
|
|
||||||
icon: e.icon,
|
|
||||||
iconSize: 22,
|
|
||||||
tooltip: tooltip,
|
|
||||||
onPressed: intent != null
|
|
||||||
? () {
|
|
||||||
Actions.invoke(context, intent);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
// TODO: Solve this in a cleaner way
|
// TODO: Solve this in a cleaner way
|
||||||
@ -168,42 +116,11 @@ class AccountDialog extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
return FocusScope(
|
return FocusScope(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
child: AlertDialog(
|
child: FsDialog(
|
||||||
title: Center(
|
child: Column(
|
||||||
child: Text(
|
|
||||||
helper.title,
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
softWrap: true,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
content: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
if (subtitle != null)
|
Padding(
|
||||||
Text(
|
padding: const EdgeInsets.only(top: 48, bottom: 16),
|
||||||
subtitle,
|
|
||||||
softWrap: true,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
// This is what ListTile uses for subtitle
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
|
||||||
color: Theme.of(context).textTheme.bodySmall!.color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12.0),
|
|
||||||
DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.rectangle,
|
|
||||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: FittedBox(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16.0, vertical: 8.0),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
@ -220,20 +137,30 @@ class AccountDialog extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Text(
|
||||||
|
helper.title,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (subtitle != null)
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
// This is what ListTile uses for subtitle
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||||
|
color: Theme.of(context).textTheme.bodySmall!.color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ActionListSection.fromMenuActions(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.s_actions,
|
||||||
|
actions: helper.buildActions(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actionsPadding: const EdgeInsets.symmetric(vertical: 10.0),
|
|
||||||
actions: [
|
|
||||||
Center(
|
|
||||||
child: FittedBox(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Row(children: _buildActions(context, helper)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -29,6 +29,7 @@ import '../../widgets/circle_timer.dart';
|
|||||||
import '../../widgets/custom_icons.dart';
|
import '../../widgets/custom_icons.dart';
|
||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
import '../state.dart';
|
import '../state.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
import 'actions.dart';
|
import 'actions.dart';
|
||||||
|
|
||||||
/// Support class for presenting an OATH account.
|
/// Support class for presenting an OATH account.
|
||||||
@ -53,7 +54,7 @@ class AccountHelper {
|
|||||||
String get title => credential.issuer ?? credential.name;
|
String get title => credential.issuer ?? credential.name;
|
||||||
String? get subtitle => credential.issuer != null ? credential.name : null;
|
String? get subtitle => credential.issuer != null ? credential.name : null;
|
||||||
|
|
||||||
List<MenuAction> buildActions() => _ref
|
List<ActionItem> buildActions() => _ref
|
||||||
.watch(currentDeviceDataProvider)
|
.watch(currentDeviceDataProvider)
|
||||||
.maybeWhen(
|
.maybeWhen(
|
||||||
data: (data) {
|
data: (data) {
|
||||||
@ -61,38 +62,51 @@ class AccountHelper {
|
|||||||
credential.touchRequired || credential.oathType == OathType.hotp;
|
credential.touchRequired || credential.oathType == OathType.hotp;
|
||||||
final ready = expired || credential.oathType == OathType.hotp;
|
final ready = expired || credential.oathType == OathType.hotp;
|
||||||
final pinned = _ref.watch(favoritesProvider).contains(credential.id);
|
final pinned = _ref.watch(favoritesProvider).contains(credential.id);
|
||||||
|
|
||||||
final l10n = AppLocalizations.of(_context)!;
|
final l10n = AppLocalizations.of(_context)!;
|
||||||
final shortcut = Platform.isMacOS ? '\u2318 C' : 'Ctrl+C';
|
final canCopy = code != null && !expired;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
MenuAction(
|
ActionItem(
|
||||||
text: l10n.l_copy_to_clipboard,
|
key: keys.copyAction,
|
||||||
icon: const Icon(Icons.copy),
|
icon: const Icon(Icons.copy),
|
||||||
intent: code == null || expired ? null : const CopyIntent(),
|
title: l10n.l_copy_to_clipboard,
|
||||||
trailing: shortcut,
|
subtitle: l10n.l_copy_code_desc,
|
||||||
|
shortcut: Platform.isMacOS ? '\u2318 C' : 'Ctrl+C',
|
||||||
|
actionStyle: canCopy ? ActionStyle.primary : null,
|
||||||
|
intent: canCopy ? const CopyIntent() : null,
|
||||||
),
|
),
|
||||||
if (manual)
|
if (manual)
|
||||||
MenuAction(
|
ActionItem(
|
||||||
text: l10n.s_calculate,
|
key: keys.calculateAction,
|
||||||
|
actionStyle: !canCopy ? ActionStyle.primary : null,
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
|
title: l10n.s_calculate,
|
||||||
|
subtitle: l10n.l_calculate_code_desc,
|
||||||
intent: ready ? const CalculateIntent() : null,
|
intent: ready ? const CalculateIntent() : null,
|
||||||
),
|
),
|
||||||
MenuAction(
|
ActionItem(
|
||||||
text: pinned ? l10n.s_unpin_account : l10n.s_pin_account,
|
key: keys.togglePinAction,
|
||||||
icon: pinned
|
icon: pinned
|
||||||
? pushPinStrokeIcon
|
? pushPinStrokeIcon
|
||||||
: const Icon(Icons.push_pin_outlined),
|
: const Icon(Icons.push_pin_outlined),
|
||||||
|
title: pinned ? l10n.s_unpin_account : l10n.s_pin_account,
|
||||||
|
subtitle: l10n.l_pin_account_desc,
|
||||||
intent: const TogglePinIntent(),
|
intent: const TogglePinIntent(),
|
||||||
),
|
),
|
||||||
if (data.info.version.isAtLeast(5, 3))
|
if (data.info.version.isAtLeast(5, 3))
|
||||||
MenuAction(
|
ActionItem(
|
||||||
|
key: keys.editAction,
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(Icons.edit_outlined),
|
||||||
text: l10n.s_rename_account,
|
title: l10n.s_rename_account,
|
||||||
|
subtitle: l10n.l_rename_account_desc,
|
||||||
intent: const EditIntent(),
|
intent: const EditIntent(),
|
||||||
),
|
),
|
||||||
MenuAction(
|
ActionItem(
|
||||||
text: l10n.s_delete_account,
|
key: keys.deleteAction,
|
||||||
|
actionStyle: ActionStyle.error,
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
title: l10n.s_delete_account,
|
||||||
|
subtitle: l10n.l_delete_account_desc,
|
||||||
intent: const DeleteIntent(),
|
intent: const DeleteIntent(),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
@ -22,8 +22,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../app/message.dart';
|
import '../../app/message.dart';
|
||||||
import '../../app/shortcuts.dart';
|
import '../../app/shortcuts.dart';
|
||||||
import '../../app/state.dart';
|
import '../../app/state.dart';
|
||||||
import '../../core/state.dart';
|
import '../../app/views/app_list_item.dart';
|
||||||
import '../../widgets/menu_list_tile.dart';
|
|
||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
import '../state.dart';
|
import '../state.dart';
|
||||||
import 'account_dialog.dart';
|
import 'account_dialog.dart';
|
||||||
@ -48,15 +47,6 @@ String _a11yCredentialLabel(String? issuer, String name, String? code) {
|
|||||||
class _AccountViewState extends ConsumerState<AccountView> {
|
class _AccountViewState extends ConsumerState<AccountView> {
|
||||||
OathCredential get credential => widget.credential;
|
OathCredential get credential => widget.credential;
|
||||||
|
|
||||||
final _focusNode = FocusNode();
|
|
||||||
int _lastTap = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_focusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _iconColor(int shade) {
|
Color _iconColor(int shade) {
|
||||||
final colors = [
|
final colors = [
|
||||||
Colors.red[shade],
|
Colors.red[shade],
|
||||||
@ -87,23 +77,6 @@ class _AccountViewState extends ConsumerState<AccountView> {
|
|||||||
return colors[label.hashCode % colors.length]!;
|
return colors[label.hashCode % colors.length]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<PopupMenuItem> _buildPopupMenu(
|
|
||||||
BuildContext context, AccountHelper helper) {
|
|
||||||
return helper.buildActions().map((e) {
|
|
||||||
final intent = e.intent;
|
|
||||||
return buildMenuItem(
|
|
||||||
leading: e.icon,
|
|
||||||
title: Text(e.text),
|
|
||||||
action: intent != null
|
|
||||||
? () {
|
|
||||||
Actions.invoke(context, intent);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
trailing: e.trailing,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@ -165,83 +138,27 @@ class _AccountViewState extends ConsumerState<AccountView> {
|
|||||||
child: Semantics(
|
child: Semantics(
|
||||||
label: _a11yCredentialLabel(
|
label: _a11yCredentialLabel(
|
||||||
credential.issuer, credential.name, helper.code?.value),
|
credential.issuer, credential.name, helper.code?.value),
|
||||||
child: InkWell(
|
child: AppListItem(
|
||||||
focusNode: _focusNode,
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
onSecondaryTapDown: (details) {
|
|
||||||
showMenu(
|
|
||||||
context: context,
|
|
||||||
position: RelativeRect.fromLTRB(
|
|
||||||
details.globalPosition.dx,
|
|
||||||
details.globalPosition.dy,
|
|
||||||
details.globalPosition.dx,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
items: _buildPopupMenu(context, helper),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onTap: () {
|
|
||||||
if (isDesktop) {
|
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
if (now - _lastTap < 500) {
|
|
||||||
setState(() {
|
|
||||||
_lastTap = 0;
|
|
||||||
});
|
|
||||||
Actions.maybeInvoke(context, const CopyIntent());
|
|
||||||
} else {
|
|
||||||
_focusNode.requestFocus();
|
|
||||||
setState(() {
|
|
||||||
_lastTap = now;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Actions.maybeInvoke<OpenIntent>(
|
|
||||||
context, const OpenIntent());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
|
||||||
Actions.maybeInvoke(context, const CopyIntent());
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
leading: showAvatar
|
leading: showAvatar
|
||||||
? AccountIcon(
|
? AccountIcon(
|
||||||
issuer: credential.issuer,
|
issuer: credential.issuer,
|
||||||
defaultWidget: circleAvatar)
|
defaultWidget: circleAvatar)
|
||||||
: null,
|
: null,
|
||||||
title: Text(
|
title: helper.title,
|
||||||
helper.title,
|
subtitle: subtitle,
|
||||||
overflow: TextOverflow.fade,
|
trailing: helper.code != null
|
||||||
maxLines: 1,
|
|
||||||
softWrap: false,
|
|
||||||
),
|
|
||||||
subtitle: subtitle != null
|
|
||||||
? Text(
|
|
||||||
subtitle,
|
|
||||||
overflow: TextOverflow.fade,
|
|
||||||
maxLines: 1,
|
|
||||||
softWrap: false,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
trailing: Focus(
|
|
||||||
skipTraversal: true,
|
|
||||||
descendantsAreTraversable: false,
|
|
||||||
child: helper.code != null
|
|
||||||
? FilledButton.tonalIcon(
|
? FilledButton.tonalIcon(
|
||||||
icon: helper.buildCodeIcon(),
|
icon: helper.buildCodeIcon(),
|
||||||
label: helper.buildCodeLabel(),
|
label: helper.buildCodeLabel(),
|
||||||
onPressed: () {
|
onPressed:
|
||||||
Actions.maybeInvoke<OpenIntent>(
|
Actions.handler(context, const OpenIntent()),
|
||||||
context, const OpenIntent());
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
: FilledButton.tonal(
|
: FilledButton.tonal(
|
||||||
onPressed: () {
|
onPressed:
|
||||||
Actions.maybeInvoke<OpenIntent>(
|
Actions.handler(context, const OpenIntent()),
|
||||||
context, const OpenIntent());
|
|
||||||
},
|
|
||||||
child: helper.buildCodeIcon()),
|
child: helper.buildCodeIcon()),
|
||||||
),
|
activationIntent: const CopyIntent(),
|
||||||
),
|
buildPopupActions: (_) => helper.buildActions(),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
@ -1,3 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* 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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
@ -22,9 +22,10 @@ import 'package:yubico_authenticator/oath/icon_provider/icon_pack_dialog.dart';
|
|||||||
import '../../app/message.dart';
|
import '../../app/message.dart';
|
||||||
import '../../app/models.dart';
|
import '../../app/models.dart';
|
||||||
import '../../app/state.dart';
|
import '../../app/state.dart';
|
||||||
|
import '../../app/views/fs_dialog.dart';
|
||||||
|
import '../../app/views/action_list.dart';
|
||||||
import '../../core/state.dart';
|
import '../../core/state.dart';
|
||||||
import '../../exception/cancellation_exception.dart';
|
import '../../exception/cancellation_exception.dart';
|
||||||
import '../../widgets/list_title.dart';
|
|
||||||
import '../models.dart';
|
import '../models.dart';
|
||||||
import '../state.dart';
|
import '../state.dart';
|
||||||
import '../keys.dart' as keys;
|
import '../keys.dart' as keys;
|
||||||
@ -41,21 +42,23 @@ Widget oathBuildActions(
|
|||||||
}) {
|
}) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final capacity = oathState.version.isAtLeast(4) ? 32 : null;
|
final capacity = oathState.version.isAtLeast(4) ? 32 : null;
|
||||||
final theme = Theme.of(context).colorScheme;
|
|
||||||
return SimpleDialog(
|
return FsDialog(
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
ListTitle(l10n.s_setup, textStyle: Theme.of(context).textTheme.bodyLarge),
|
ActionListSection(l10n.s_setup, children: [
|
||||||
ListTile(
|
ActionListItem(
|
||||||
title: Text(l10n.s_add_account),
|
|
||||||
key: keys.addAccountAction,
|
key: keys.addAccountAction,
|
||||||
leading:
|
actionStyle: ActionStyle.primary,
|
||||||
const CircleAvatar(child: Icon(Icons.person_add_alt_1_outlined)),
|
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
subtitle: Text(used == null
|
title: l10n.s_add_account,
|
||||||
|
subtitle: used == null
|
||||||
? l10n.l_unlock_first
|
? l10n.l_unlock_first
|
||||||
: (capacity != null ? l10n.l_accounts_used(used, capacity) : '')),
|
: (capacity != null
|
||||||
enabled: used != null && (capacity == null || capacity > used),
|
? l10n.l_accounts_used(used, capacity)
|
||||||
|
: ''),
|
||||||
onTap: used != null && (capacity == null || capacity > used)
|
onTap: used != null && (capacity == null || capacity > used)
|
||||||
? () async {
|
? (context) async {
|
||||||
final credentials = ref.read(credentialsProvider);
|
final credentials = ref.read(credentialsProvider);
|
||||||
final withContext = ref.read(withContextProvider);
|
final withContext = ref.read(withContextProvider);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@ -88,16 +91,14 @@ Widget oathBuildActions(
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
ListTitle(l10n.s_manage,
|
]),
|
||||||
textStyle: Theme.of(context).textTheme.bodyLarge),
|
ActionListSection(l10n.s_manage, children: [
|
||||||
ListTile(
|
ActionListItem(
|
||||||
key: keys.customIconsAction,
|
key: keys.customIconsAction,
|
||||||
title: Text(l10n.s_custom_icons),
|
title: l10n.s_custom_icons,
|
||||||
subtitle: Text(l10n.l_set_icons_for_accounts),
|
subtitle: l10n.l_set_icons_for_accounts,
|
||||||
leading: const CircleAvatar(
|
icon: const Icon(Icons.image_outlined),
|
||||||
child: Icon(Icons.image_outlined),
|
onTap: (context) async {
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
await ref.read(withContextProvider)((context) => showBlurDialog(
|
await ref.read(withContextProvider)((context) => showBlurDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -106,35 +107,36 @@ Widget oathBuildActions(
|
|||||||
builder: (context) => const IconPackDialog(),
|
builder: (context) => const IconPackDialog(),
|
||||||
));
|
));
|
||||||
}),
|
}),
|
||||||
ListTile(
|
ActionListItem(
|
||||||
key: keys.setOrManagePasswordAction,
|
key: keys.setOrManagePasswordAction,
|
||||||
title: Text(
|
title: oathState.hasKey
|
||||||
oathState.hasKey ? l10n.s_manage_password : l10n.s_set_password),
|
? l10n.s_manage_password
|
||||||
subtitle: Text(l10n.l_optional_password_protection),
|
: l10n.s_set_password,
|
||||||
leading: const CircleAvatar(child: Icon(Icons.password_outlined)),
|
subtitle: l10n.l_optional_password_protection,
|
||||||
onTap: () {
|
icon: const Icon(Icons.password_outlined),
|
||||||
|
onTap: (context) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
showBlurDialog(
|
showBlurDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => ManagePasswordDialog(devicePath, oathState),
|
builder: (context) =>
|
||||||
|
ManagePasswordDialog(devicePath, oathState),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
ListTile(
|
ActionListItem(
|
||||||
key: keys.resetAction,
|
key: keys.resetAction,
|
||||||
title: Text(l10n.s_reset_oath),
|
icon: const Icon(Icons.delete_outline),
|
||||||
subtitle: Text(l10n.l_factory_reset_this_app),
|
actionStyle: ActionStyle.error,
|
||||||
leading: CircleAvatar(
|
title: l10n.s_reset_oath,
|
||||||
foregroundColor: theme.onError,
|
subtitle: l10n.l_factory_reset_this_app,
|
||||||
backgroundColor: theme.error,
|
onTap: (context) {
|
||||||
child: const Icon(Icons.delete_outline),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
showBlurDialog(
|
showBlurDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => ResetDialog(devicePath),
|
builder: (context) => ResetDialog(devicePath),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
44
lib/piv/keys.dart
Normal file
44
lib/piv/keys.dart
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022-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';
|
||||||
|
|
||||||
|
const _prefix = 'piv.keys';
|
||||||
|
const _keyAction = '$_prefix.actions';
|
||||||
|
const _slotAction = '$_prefix.slot.actions';
|
||||||
|
|
||||||
|
// Key actions
|
||||||
|
const managePinAction = Key('$_keyAction.manage_pin');
|
||||||
|
const managePukAction = Key('$_keyAction.manage_puk');
|
||||||
|
const manageManagementKeyAction = Key('$_keyAction.manage_management_key');
|
||||||
|
const resetAction = Key('$_keyAction.reset');
|
||||||
|
const setupMacOsAction = Key('$_keyAction.setup_macos');
|
||||||
|
|
||||||
|
// Slot actions
|
||||||
|
const generateAction = Key('$_slotAction.generate');
|
||||||
|
const importAction = Key('$_slotAction.import');
|
||||||
|
const exportAction = Key('$_slotAction.export');
|
||||||
|
const deleteAction = Key('$_slotAction.delete');
|
||||||
|
|
||||||
|
const saveButton = Key('$_prefix.save');
|
||||||
|
const deleteButton = Key('$_prefix.delete');
|
||||||
|
const unlockButton = Key('$_prefix.unlock');
|
||||||
|
|
||||||
|
const managementKeyField = Key('$_prefix.management_key');
|
||||||
|
const pinPukField = Key('$_prefix.pin_puk');
|
||||||
|
const newPinPukField = Key('$_prefix.new_pin_puk');
|
||||||
|
const confirmPinPukField = Key('$_prefix.confirm_pin_puk');
|
||||||
|
const subjectField = Key('$_prefix.subject');
|
312
lib/piv/models.dart
Normal file
312
lib/piv/models.dart
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
/*
|
||||||
|
* 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:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
import '../core/models.dart';
|
||||||
|
|
||||||
|
part 'models.freezed.dart';
|
||||||
|
part 'models.g.dart';
|
||||||
|
|
||||||
|
const defaultManagementKey = '010203040506070801020304050607080102030405060708';
|
||||||
|
const defaultManagementKeyType = ManagementKeyType.tdes;
|
||||||
|
const defaultKeyType = KeyType.rsa2048;
|
||||||
|
const defaultGenerateType = GenerateType.certificate;
|
||||||
|
|
||||||
|
enum GenerateType {
|
||||||
|
certificate,
|
||||||
|
csr;
|
||||||
|
|
||||||
|
String getDisplayName(AppLocalizations l10n) {
|
||||||
|
return switch (this) {
|
||||||
|
GenerateType.certificate => l10n.s_certificate,
|
||||||
|
GenerateType.csr => l10n.s_csr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SlotId {
|
||||||
|
authentication(0x9a),
|
||||||
|
signature(0x9c),
|
||||||
|
keyManagement(0x9d),
|
||||||
|
cardAuth(0x9e);
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
const SlotId(this.id);
|
||||||
|
|
||||||
|
String get hexId => id.toRadixString(16).padLeft(2, '0');
|
||||||
|
|
||||||
|
String getDisplayName(AppLocalizations l10n) {
|
||||||
|
String nameFor(String name) => l10n.s_slot_display_name(name, hexId);
|
||||||
|
return switch (this) {
|
||||||
|
SlotId.authentication => nameFor(l10n.s_slot_9a),
|
||||||
|
SlotId.signature => nameFor(l10n.s_slot_9c),
|
||||||
|
SlotId.keyManagement => nameFor(l10n.s_slot_9d),
|
||||||
|
SlotId.cardAuth => nameFor(l10n.s_slot_9e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SlotId.fromJson(int value) =>
|
||||||
|
SlotId.values.firstWhere((e) => e.id == value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonEnum(alwaysCreate: true)
|
||||||
|
enum PinPolicy {
|
||||||
|
@JsonValue(0x00)
|
||||||
|
dfault,
|
||||||
|
@JsonValue(0x01)
|
||||||
|
never,
|
||||||
|
@JsonValue(0x02)
|
||||||
|
once,
|
||||||
|
@JsonValue(0x03)
|
||||||
|
always;
|
||||||
|
|
||||||
|
const PinPolicy();
|
||||||
|
|
||||||
|
int get value => _$PinPolicyEnumMap[this]!;
|
||||||
|
|
||||||
|
String getDisplayName(AppLocalizations l10n) {
|
||||||
|
return switch (this) {
|
||||||
|
// TODO:
|
||||||
|
_ => name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonEnum(alwaysCreate: true)
|
||||||
|
enum TouchPolicy {
|
||||||
|
@JsonValue(0x00)
|
||||||
|
dfault,
|
||||||
|
@JsonValue(0x01)
|
||||||
|
never,
|
||||||
|
@JsonValue(0x02)
|
||||||
|
always,
|
||||||
|
@JsonValue(0x03)
|
||||||
|
cached;
|
||||||
|
|
||||||
|
const TouchPolicy();
|
||||||
|
|
||||||
|
int get value => _$TouchPolicyEnumMap[this]!;
|
||||||
|
|
||||||
|
String getDisplayName(AppLocalizations l10n) {
|
||||||
|
return switch (this) {
|
||||||
|
// TODO:
|
||||||
|
_ => name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonEnum(alwaysCreate: true)
|
||||||
|
enum KeyType {
|
||||||
|
@JsonValue(0x06)
|
||||||
|
rsa1024,
|
||||||
|
@JsonValue(0x07)
|
||||||
|
rsa2048,
|
||||||
|
@JsonValue(0x11)
|
||||||
|
eccp256,
|
||||||
|
@JsonValue(0x14)
|
||||||
|
eccp384;
|
||||||
|
|
||||||
|
const KeyType();
|
||||||
|
|
||||||
|
int get value => _$KeyTypeEnumMap[this]!;
|
||||||
|
|
||||||
|
String getDisplayName(AppLocalizations l10n) {
|
||||||
|
return switch (this) {
|
||||||
|
// TODO: Should these be translatable?
|
||||||
|
_ => name.toUpperCase()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ManagementKeyType {
|
||||||
|
@JsonValue(0x03)
|
||||||
|
tdes(24),
|
||||||
|
@JsonValue(0x08)
|
||||||
|
aes128(16),
|
||||||
|
@JsonValue(0x0A)
|
||||||
|
aes192(24),
|
||||||
|
@JsonValue(0x0C)
|
||||||
|
aes256(32);
|
||||||
|
|
||||||
|
const ManagementKeyType(this.keyLength);
|
||||||
|
final int keyLength;
|
||||||
|
|
||||||
|
int get value => _$ManagementKeyTypeEnumMap[this]!;
|
||||||
|
|
||||||
|
String getDisplayName(AppLocalizations l10n) {
|
||||||
|
return switch (this) {
|
||||||
|
// TODO: Should these be translatable?
|
||||||
|
_ => name.toUpperCase()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class PinMetadata with _$PinMetadata {
|
||||||
|
factory PinMetadata(
|
||||||
|
bool defaultValue,
|
||||||
|
int totalAttempts,
|
||||||
|
int attemptsRemaining,
|
||||||
|
) = _PinMetadata;
|
||||||
|
|
||||||
|
factory PinMetadata.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PinMetadataFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class PinVerificationStatus with _$PinVerificationStatus {
|
||||||
|
const factory PinVerificationStatus.success() = _PinSuccess;
|
||||||
|
factory PinVerificationStatus.failure(int attemptsRemaining) = _PinFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class ManagementKeyMetadata with _$ManagementKeyMetadata {
|
||||||
|
factory ManagementKeyMetadata(
|
||||||
|
ManagementKeyType keyType,
|
||||||
|
bool defaultValue,
|
||||||
|
TouchPolicy touchPolicy,
|
||||||
|
) = _ManagementKeyMetadata;
|
||||||
|
|
||||||
|
factory ManagementKeyMetadata.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ManagementKeyMetadataFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class SlotMetadata with _$SlotMetadata {
|
||||||
|
factory SlotMetadata(
|
||||||
|
KeyType keyType,
|
||||||
|
PinPolicy pinPolicy,
|
||||||
|
TouchPolicy touchPolicy,
|
||||||
|
bool generated,
|
||||||
|
String publicKeyEncoded,
|
||||||
|
) = _SlotMetadata;
|
||||||
|
|
||||||
|
factory SlotMetadata.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SlotMetadataFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class PivStateMetadata with _$PivStateMetadata {
|
||||||
|
factory PivStateMetadata({
|
||||||
|
required ManagementKeyMetadata managementKeyMetadata,
|
||||||
|
required PinMetadata pinMetadata,
|
||||||
|
required PinMetadata pukMetadata,
|
||||||
|
}) = _PivStateMetadata;
|
||||||
|
|
||||||
|
factory PivStateMetadata.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PivStateMetadataFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class PivState with _$PivState {
|
||||||
|
const PivState._();
|
||||||
|
|
||||||
|
factory PivState({
|
||||||
|
required Version version,
|
||||||
|
required bool authenticated,
|
||||||
|
required bool derivedKey,
|
||||||
|
required bool storedKey,
|
||||||
|
required int pinAttempts,
|
||||||
|
String? chuid,
|
||||||
|
String? ccc,
|
||||||
|
PivStateMetadata? metadata,
|
||||||
|
}) = _PivState;
|
||||||
|
|
||||||
|
bool get protectedKey => derivedKey || storedKey;
|
||||||
|
bool get needsAuth =>
|
||||||
|
!authenticated && metadata?.managementKeyMetadata.defaultValue != true;
|
||||||
|
|
||||||
|
factory PivState.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PivStateFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class CertInfo with _$CertInfo {
|
||||||
|
factory CertInfo({
|
||||||
|
required String subject,
|
||||||
|
required String issuer,
|
||||||
|
required String serial,
|
||||||
|
required String notValidBefore,
|
||||||
|
required String notValidAfter,
|
||||||
|
required String fingerprint,
|
||||||
|
}) = _CertInfo;
|
||||||
|
|
||||||
|
factory CertInfo.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CertInfoFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class PivSlot with _$PivSlot {
|
||||||
|
factory PivSlot({
|
||||||
|
required SlotId slot,
|
||||||
|
bool? hasKey,
|
||||||
|
CertInfo? certInfo,
|
||||||
|
}) = _PivSlot;
|
||||||
|
|
||||||
|
factory PivSlot.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PivSlotFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class PivExamineResult with _$PivExamineResult {
|
||||||
|
factory PivExamineResult.result({
|
||||||
|
required bool password,
|
||||||
|
required bool privateKey,
|
||||||
|
required int certificates,
|
||||||
|
}) = _ExamineResult;
|
||||||
|
factory PivExamineResult.invalidPassword() = _InvalidPassword;
|
||||||
|
|
||||||
|
factory PivExamineResult.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PivExamineResultFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class PivGenerateParameters with _$PivGenerateParameters {
|
||||||
|
factory PivGenerateParameters.certificate({
|
||||||
|
required String subject,
|
||||||
|
required DateTime validFrom,
|
||||||
|
required DateTime validTo,
|
||||||
|
}) = _GenerateCertificate;
|
||||||
|
factory PivGenerateParameters.csr({
|
||||||
|
required String subject,
|
||||||
|
}) = _GenerateCsr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class PivGenerateResult with _$PivGenerateResult {
|
||||||
|
factory PivGenerateResult({
|
||||||
|
required GenerateType generateType,
|
||||||
|
required String publicKey,
|
||||||
|
required String result,
|
||||||
|
}) = _PivGenerateResult;
|
||||||
|
|
||||||
|
factory PivGenerateResult.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PivGenerateResultFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class PivImportResult with _$PivImportResult {
|
||||||
|
factory PivImportResult({
|
||||||
|
required SlotMetadata? metadata,
|
||||||
|
required String? publicKey,
|
||||||
|
required String? certificate,
|
||||||
|
}) = _PivImportResult;
|
||||||
|
|
||||||
|
factory PivImportResult.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PivImportResultFromJson(json);
|
||||||
|
}
|
2984
lib/piv/models.freezed.dart
Normal file
2984
lib/piv/models.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
228
lib/piv/models.g.dart
Normal file
228
lib/piv/models.g.dart
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'models.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_$_PinMetadata _$$_PinMetadataFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$_PinMetadata(
|
||||||
|
json['default_value'] as bool,
|
||||||
|
json['total_attempts'] as int,
|
||||||
|
json['attempts_remaining'] as int,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_PinMetadataToJson(_$_PinMetadata instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'default_value': instance.defaultValue,
|
||||||
|
'total_attempts': instance.totalAttempts,
|
||||||
|
'attempts_remaining': instance.attemptsRemaining,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_ManagementKeyMetadata _$$_ManagementKeyMetadataFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$_ManagementKeyMetadata(
|
||||||
|
$enumDecode(_$ManagementKeyTypeEnumMap, json['key_type']),
|
||||||
|
json['default_value'] as bool,
|
||||||
|
$enumDecode(_$TouchPolicyEnumMap, json['touch_policy']),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_ManagementKeyMetadataToJson(
|
||||||
|
_$_ManagementKeyMetadata instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'key_type': _$ManagementKeyTypeEnumMap[instance.keyType]!,
|
||||||
|
'default_value': instance.defaultValue,
|
||||||
|
'touch_policy': _$TouchPolicyEnumMap[instance.touchPolicy]!,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$ManagementKeyTypeEnumMap = {
|
||||||
|
ManagementKeyType.tdes: 3,
|
||||||
|
ManagementKeyType.aes128: 8,
|
||||||
|
ManagementKeyType.aes192: 10,
|
||||||
|
ManagementKeyType.aes256: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$TouchPolicyEnumMap = {
|
||||||
|
TouchPolicy.dfault: 0,
|
||||||
|
TouchPolicy.never: 1,
|
||||||
|
TouchPolicy.always: 2,
|
||||||
|
TouchPolicy.cached: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_SlotMetadata _$$_SlotMetadataFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$_SlotMetadata(
|
||||||
|
$enumDecode(_$KeyTypeEnumMap, json['key_type']),
|
||||||
|
$enumDecode(_$PinPolicyEnumMap, json['pin_policy']),
|
||||||
|
$enumDecode(_$TouchPolicyEnumMap, json['touch_policy']),
|
||||||
|
json['generated'] as bool,
|
||||||
|
json['public_key_encoded'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_SlotMetadataToJson(_$_SlotMetadata instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'key_type': _$KeyTypeEnumMap[instance.keyType]!,
|
||||||
|
'pin_policy': _$PinPolicyEnumMap[instance.pinPolicy]!,
|
||||||
|
'touch_policy': _$TouchPolicyEnumMap[instance.touchPolicy]!,
|
||||||
|
'generated': instance.generated,
|
||||||
|
'public_key_encoded': instance.publicKeyEncoded,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$KeyTypeEnumMap = {
|
||||||
|
KeyType.rsa1024: 6,
|
||||||
|
KeyType.rsa2048: 7,
|
||||||
|
KeyType.eccp256: 17,
|
||||||
|
KeyType.eccp384: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$PinPolicyEnumMap = {
|
||||||
|
PinPolicy.dfault: 0,
|
||||||
|
PinPolicy.never: 1,
|
||||||
|
PinPolicy.once: 2,
|
||||||
|
PinPolicy.always: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_PivStateMetadata _$$_PivStateMetadataFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$_PivStateMetadata(
|
||||||
|
managementKeyMetadata: ManagementKeyMetadata.fromJson(
|
||||||
|
json['management_key_metadata'] as Map<String, dynamic>),
|
||||||
|
pinMetadata:
|
||||||
|
PinMetadata.fromJson(json['pin_metadata'] as Map<String, dynamic>),
|
||||||
|
pukMetadata:
|
||||||
|
PinMetadata.fromJson(json['puk_metadata'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_PivStateMetadataToJson(_$_PivStateMetadata instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'management_key_metadata': instance.managementKeyMetadata,
|
||||||
|
'pin_metadata': instance.pinMetadata,
|
||||||
|
'puk_metadata': instance.pukMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_PivState _$$_PivStateFromJson(Map<String, dynamic> json) => _$_PivState(
|
||||||
|
version: Version.fromJson(json['version'] as List<dynamic>),
|
||||||
|
authenticated: json['authenticated'] as bool,
|
||||||
|
derivedKey: json['derived_key'] as bool,
|
||||||
|
storedKey: json['stored_key'] as bool,
|
||||||
|
pinAttempts: json['pin_attempts'] as int,
|
||||||
|
chuid: json['chuid'] as String?,
|
||||||
|
ccc: json['ccc'] as String?,
|
||||||
|
metadata: json['metadata'] == null
|
||||||
|
? null
|
||||||
|
: PivStateMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_PivStateToJson(_$_PivState instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'version': instance.version,
|
||||||
|
'authenticated': instance.authenticated,
|
||||||
|
'derived_key': instance.derivedKey,
|
||||||
|
'stored_key': instance.storedKey,
|
||||||
|
'pin_attempts': instance.pinAttempts,
|
||||||
|
'chuid': instance.chuid,
|
||||||
|
'ccc': instance.ccc,
|
||||||
|
'metadata': instance.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_CertInfo _$$_CertInfoFromJson(Map<String, dynamic> json) => _$_CertInfo(
|
||||||
|
subject: json['subject'] as String,
|
||||||
|
issuer: json['issuer'] as String,
|
||||||
|
serial: json['serial'] as String,
|
||||||
|
notValidBefore: json['not_valid_before'] as String,
|
||||||
|
notValidAfter: json['not_valid_after'] as String,
|
||||||
|
fingerprint: json['fingerprint'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_CertInfoToJson(_$_CertInfo instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'subject': instance.subject,
|
||||||
|
'issuer': instance.issuer,
|
||||||
|
'serial': instance.serial,
|
||||||
|
'not_valid_before': instance.notValidBefore,
|
||||||
|
'not_valid_after': instance.notValidAfter,
|
||||||
|
'fingerprint': instance.fingerprint,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_PivSlot _$$_PivSlotFromJson(Map<String, dynamic> json) => _$_PivSlot(
|
||||||
|
slot: SlotId.fromJson(json['slot'] as int),
|
||||||
|
hasKey: json['has_key'] as bool?,
|
||||||
|
certInfo: json['cert_info'] == null
|
||||||
|
? null
|
||||||
|
: CertInfo.fromJson(json['cert_info'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_PivSlotToJson(_$_PivSlot instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'slot': _$SlotIdEnumMap[instance.slot]!,
|
||||||
|
'has_key': instance.hasKey,
|
||||||
|
'cert_info': instance.certInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$SlotIdEnumMap = {
|
||||||
|
SlotId.authentication: 'authentication',
|
||||||
|
SlotId.signature: 'signature',
|
||||||
|
SlotId.keyManagement: 'keyManagement',
|
||||||
|
SlotId.cardAuth: 'cardAuth',
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_ExamineResult _$$_ExamineResultFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$_ExamineResult(
|
||||||
|
password: json['password'] as bool,
|
||||||
|
privateKey: json['private_key'] as bool,
|
||||||
|
certificates: json['certificates'] as int,
|
||||||
|
$type: json['runtimeType'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_ExamineResultToJson(_$_ExamineResult instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'password': instance.password,
|
||||||
|
'private_key': instance.privateKey,
|
||||||
|
'certificates': instance.certificates,
|
||||||
|
'runtimeType': instance.$type,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_InvalidPassword _$$_InvalidPasswordFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$_InvalidPassword(
|
||||||
|
$type: json['runtimeType'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_InvalidPasswordToJson(_$_InvalidPassword instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'runtimeType': instance.$type,
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_PivGenerateResult _$$_PivGenerateResultFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$_PivGenerateResult(
|
||||||
|
generateType: $enumDecode(_$GenerateTypeEnumMap, json['generate_type']),
|
||||||
|
publicKey: json['public_key'] as String,
|
||||||
|
result: json['result'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_PivGenerateResultToJson(
|
||||||
|
_$_PivGenerateResult instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'generate_type': _$GenerateTypeEnumMap[instance.generateType]!,
|
||||||
|
'public_key': instance.publicKey,
|
||||||
|
'result': instance.result,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$GenerateTypeEnumMap = {
|
||||||
|
GenerateType.certificate: 'certificate',
|
||||||
|
GenerateType.csr: 'csr',
|
||||||
|
};
|
||||||
|
|
||||||
|
_$_PivImportResult _$$_PivImportResultFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$_PivImportResult(
|
||||||
|
metadata: json['metadata'] == null
|
||||||
|
? null
|
||||||
|
: SlotMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
|
||||||
|
publicKey: json['public_key'] as String?,
|
||||||
|
certificate: json['certificate'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_PivImportResultToJson(_$_PivImportResult instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'metadata': instance.metadata,
|
||||||
|
'public_key': instance.publicKey,
|
||||||
|
'certificate': instance.certificate,
|
||||||
|
};
|
70
lib/piv/state.dart
Normal file
70
lib/piv/state.dart
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* 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_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../app/models.dart';
|
||||||
|
import '../core/state.dart';
|
||||||
|
import 'models.dart';
|
||||||
|
|
||||||
|
final pivStateProvider = AsyncNotifierProvider.autoDispose
|
||||||
|
.family<PivStateNotifier, PivState, DevicePath>(
|
||||||
|
() => throw UnimplementedError(),
|
||||||
|
);
|
||||||
|
|
||||||
|
abstract class PivStateNotifier extends ApplicationStateNotifier<PivState> {
|
||||||
|
Future<void> reset();
|
||||||
|
|
||||||
|
Future<bool> authenticate(String managementKey);
|
||||||
|
Future<void> setManagementKey(
|
||||||
|
String managementKey, {
|
||||||
|
ManagementKeyType managementKeyType = defaultManagementKeyType,
|
||||||
|
bool storeKey = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<PinVerificationStatus> verifyPin(
|
||||||
|
String pin); //TODO: Maybe return authenticated?
|
||||||
|
Future<PinVerificationStatus> changePin(String pin, String newPin);
|
||||||
|
Future<PinVerificationStatus> changePuk(String puk, String newPuk);
|
||||||
|
Future<PinVerificationStatus> unblockPin(String puk, String newPin);
|
||||||
|
}
|
||||||
|
|
||||||
|
final pivSlotsProvider = AsyncNotifierProvider.autoDispose
|
||||||
|
.family<PivSlotsNotifier, List<PivSlot>, DevicePath>(
|
||||||
|
() => throw UnimplementedError(),
|
||||||
|
);
|
||||||
|
|
||||||
|
abstract class PivSlotsNotifier
|
||||||
|
extends AutoDisposeFamilyAsyncNotifier<List<PivSlot>, DevicePath> {
|
||||||
|
Future<PivExamineResult> examine(String data, {String? password});
|
||||||
|
Future<(SlotMetadata?, String?)> read(SlotId slot);
|
||||||
|
Future<PivGenerateResult> generate(
|
||||||
|
SlotId slot,
|
||||||
|
KeyType keyType, {
|
||||||
|
required PivGenerateParameters parameters,
|
||||||
|
PinPolicy pinPolicy = PinPolicy.dfault,
|
||||||
|
TouchPolicy touchPolicy = TouchPolicy.dfault,
|
||||||
|
String? pin,
|
||||||
|
});
|
||||||
|
Future<PivImportResult> import(
|
||||||
|
SlotId slot,
|
||||||
|
String data, {
|
||||||
|
String? password,
|
||||||
|
PinPolicy pinPolicy = PinPolicy.dfault,
|
||||||
|
TouchPolicy touchPolicy = TouchPolicy.dfault,
|
||||||
|
});
|
||||||
|
Future<void> delete(SlotId slot);
|
||||||
|
}
|
256
lib/piv/views/actions.dart
Normal file
256
lib/piv/views/actions.dart
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
/*
|
||||||
|
* 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 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
import '../../app/message.dart';
|
||||||
|
import '../../app/shortcuts.dart';
|
||||||
|
import '../../app/state.dart';
|
||||||
|
import '../../app/models.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
import 'authentication_dialog.dart';
|
||||||
|
import 'delete_certificate_dialog.dart';
|
||||||
|
import 'generate_key_dialog.dart';
|
||||||
|
import 'import_file_dialog.dart';
|
||||||
|
import 'pin_dialog.dart';
|
||||||
|
|
||||||
|
class GenerateIntent extends Intent {
|
||||||
|
const GenerateIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportIntent extends Intent {
|
||||||
|
const ImportIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExportIntent extends Intent {
|
||||||
|
const ExportIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _authenticate(
|
||||||
|
WidgetRef ref, DevicePath devicePath, PivState pivState) async {
|
||||||
|
final withContext = ref.read(withContextProvider);
|
||||||
|
return await withContext((context) async =>
|
||||||
|
await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AuthenticationDialog(
|
||||||
|
devicePath,
|
||||||
|
pivState,
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _authIfNeeded(
|
||||||
|
WidgetRef ref, DevicePath devicePath, PivState pivState) async {
|
||||||
|
if (pivState.needsAuth) {
|
||||||
|
return await _authenticate(ref, devicePath, pivState);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget registerPivActions(
|
||||||
|
DevicePath devicePath,
|
||||||
|
PivState pivState,
|
||||||
|
PivSlot pivSlot, {
|
||||||
|
required WidgetRef ref,
|
||||||
|
required Widget Function(BuildContext context) builder,
|
||||||
|
Map<Type, Action<Intent>> actions = const {},
|
||||||
|
}) =>
|
||||||
|
Actions(
|
||||||
|
actions: {
|
||||||
|
GenerateIntent:
|
||||||
|
CallbackAction<GenerateIntent>(onInvoke: (intent) async {
|
||||||
|
if (!pivState.protectedKey &&
|
||||||
|
!await _authIfNeeded(ref, devicePath, pivState)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final withContext = ref.read(withContextProvider);
|
||||||
|
|
||||||
|
// TODO: Avoid asking for PIN if not needed?
|
||||||
|
final verified = await withContext((context) async =>
|
||||||
|
await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => PinDialog(devicePath))) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withContext((context) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final PivGenerateResult? result = await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => GenerateKeyDialog(
|
||||||
|
devicePath,
|
||||||
|
pivState,
|
||||||
|
pivSlot,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (result?.generateType) {
|
||||||
|
case GenerateType.csr:
|
||||||
|
final filePath = await FilePicker.platform.saveFile(
|
||||||
|
dialogTitle: l10n.l_export_csr_file,
|
||||||
|
allowedExtensions: ['csr'],
|
||||||
|
type: FileType.custom,
|
||||||
|
lockParentWindow: true,
|
||||||
|
);
|
||||||
|
if (filePath != null) {
|
||||||
|
final file = File(filePath);
|
||||||
|
await file.writeAsString(result!.result, flush: true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result != null;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
ImportIntent: CallbackAction<ImportIntent>(onInvoke: (intent) async {
|
||||||
|
if (!await _authIfNeeded(ref, devicePath, pivState)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final withContext = ref.read(withContextProvider);
|
||||||
|
|
||||||
|
final picked = await withContext(
|
||||||
|
(context) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return await FilePicker.platform.pickFiles(
|
||||||
|
allowedExtensions: ['pem', 'der', 'pfx', 'p12', 'key', 'crt'],
|
||||||
|
type: FileType.custom,
|
||||||
|
allowMultiple: false,
|
||||||
|
lockParentWindow: true,
|
||||||
|
dialogTitle: l10n.l_select_import_file);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (picked == null || picked.files.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withContext((context) async =>
|
||||||
|
await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ImportFileDialog(
|
||||||
|
devicePath,
|
||||||
|
pivState,
|
||||||
|
pivSlot,
|
||||||
|
File(picked.paths.first!),
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false);
|
||||||
|
}),
|
||||||
|
ExportIntent: CallbackAction<ExportIntent>(onInvoke: (intent) async {
|
||||||
|
final (_, cert) = await ref
|
||||||
|
.read(pivSlotsProvider(devicePath).notifier)
|
||||||
|
.read(pivSlot.slot);
|
||||||
|
|
||||||
|
if (cert == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final withContext = ref.read(withContextProvider);
|
||||||
|
|
||||||
|
final filePath = await withContext((context) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return await FilePicker.platform.saveFile(
|
||||||
|
dialogTitle: l10n.l_export_certificate_file,
|
||||||
|
allowedExtensions: ['pem'],
|
||||||
|
type: FileType.custom,
|
||||||
|
lockParentWindow: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filePath == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = File(filePath);
|
||||||
|
await file.writeAsString(cert, flush: true);
|
||||||
|
|
||||||
|
await withContext((context) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
showMessage(context, l10n.l_certificate_exported);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
|
||||||
|
if (!await _authIfNeeded(ref, devicePath, pivState)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final withContext = ref.read(withContextProvider);
|
||||||
|
final bool? deleted = await withContext((context) async =>
|
||||||
|
await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => DeleteCertificateDialog(
|
||||||
|
devicePath,
|
||||||
|
pivSlot,
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false);
|
||||||
|
return deleted;
|
||||||
|
}),
|
||||||
|
...actions,
|
||||||
|
},
|
||||||
|
child: Builder(builder: builder),
|
||||||
|
);
|
||||||
|
|
||||||
|
List<ActionItem> buildSlotActions(bool hasCert, AppLocalizations l10n) {
|
||||||
|
return [
|
||||||
|
ActionItem(
|
||||||
|
key: keys.generateAction,
|
||||||
|
icon: const Icon(Icons.add_outlined),
|
||||||
|
actionStyle: ActionStyle.primary,
|
||||||
|
title: l10n.s_generate_key,
|
||||||
|
subtitle: l10n.l_generate_desc,
|
||||||
|
intent: const GenerateIntent(),
|
||||||
|
),
|
||||||
|
ActionItem(
|
||||||
|
key: keys.importAction,
|
||||||
|
icon: const Icon(Icons.file_download_outlined),
|
||||||
|
title: l10n.l_import_file,
|
||||||
|
subtitle: l10n.l_import_desc,
|
||||||
|
intent: const ImportIntent(),
|
||||||
|
),
|
||||||
|
if (hasCert) ...[
|
||||||
|
ActionItem(
|
||||||
|
key: keys.exportAction,
|
||||||
|
icon: const Icon(Icons.file_upload_outlined),
|
||||||
|
title: l10n.l_export_certificate,
|
||||||
|
subtitle: l10n.l_export_certificate_desc,
|
||||||
|
intent: const ExportIntent(),
|
||||||
|
),
|
||||||
|
ActionItem(
|
||||||
|
key: keys.deleteAction,
|
||||||
|
actionStyle: ActionStyle.error,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
title: l10n.l_delete_certificate,
|
||||||
|
subtitle: l10n.l_delete_certificate_desc,
|
||||||
|
intent: const DeleteIntent(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
122
lib/piv/views/authentication_dialog.dart
Normal file
122
lib/piv/views/authentication_dialog.dart
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* 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/services.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../app/models.dart';
|
||||||
|
import '../../exception/cancellation_exception.dart';
|
||||||
|
import '../../widgets/responsive_dialog.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
|
||||||
|
class AuthenticationDialog extends ConsumerStatefulWidget {
|
||||||
|
final DevicePath devicePath;
|
||||||
|
final PivState pivState;
|
||||||
|
const AuthenticationDialog(this.devicePath, this.pivState, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||||
|
_AuthenticationDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthenticationDialogState extends ConsumerState<AuthenticationDialog> {
|
||||||
|
String _managementKey = '';
|
||||||
|
bool _keyIsWrong = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final keyLen = (widget.pivState.metadata?.managementKeyMetadata.keyType ??
|
||||||
|
ManagementKeyType.tdes)
|
||||||
|
.keyLength *
|
||||||
|
2;
|
||||||
|
return ResponsiveDialog(
|
||||||
|
title: Text(l10n.l_unlock_piv_management),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: keys.unlockButton,
|
||||||
|
onPressed: _managementKey.length == keyLen
|
||||||
|
? () async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
try {
|
||||||
|
final status = await ref
|
||||||
|
.read(pivStateProvider(widget.devicePath).notifier)
|
||||||
|
.authenticate(_managementKey);
|
||||||
|
if (status) {
|
||||||
|
navigator.pop(true);
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_keyIsWrong = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} on CancellationException catch (_) {
|
||||||
|
navigator.pop(false);
|
||||||
|
} catch (_) {
|
||||||
|
// TODO: More error cases
|
||||||
|
setState(() {
|
||||||
|
_keyIsWrong = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: Text(l10n.s_unlock),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.p_unlock_piv_management_desc),
|
||||||
|
TextField(
|
||||||
|
key: keys.managementKeyField,
|
||||||
|
autofocus: true,
|
||||||
|
maxLength: keyLen,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(
|
||||||
|
RegExp('[a-f0-9]', caseSensitive: false))
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: l10n.s_management_key,
|
||||||
|
prefixIcon: const Icon(Icons.key_outlined),
|
||||||
|
errorText: _keyIsWrong ? l10n.l_wrong_key : null,
|
||||||
|
errorMaxLines: 3,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_keyIsWrong = false;
|
||||||
|
_managementKey = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: e,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
79
lib/piv/views/delete_certificate_dialog.dart
Normal file
79
lib/piv/views/delete_certificate_dialog.dart
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* 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/responsive_dialog.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
|
||||||
|
class DeleteCertificateDialog extends ConsumerWidget {
|
||||||
|
final DevicePath devicePath;
|
||||||
|
final PivSlot pivSlot;
|
||||||
|
const DeleteCertificateDialog(this.devicePath, this.pivSlot, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return ResponsiveDialog(
|
||||||
|
title: Text(l10n.l_delete_certificate),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: keys.deleteButton,
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(pivSlotsProvider(devicePath).notifier)
|
||||||
|
.delete(pivSlot.slot);
|
||||||
|
await ref.read(withContextProvider)(
|
||||||
|
(context) async {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
showMessage(context, l10n.l_certificate_deleted);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} on CancellationException catch (_) {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(l10n.s_delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.p_warning_delete_certificate),
|
||||||
|
Text(l10n.q_delete_certificate_confirm(
|
||||||
|
pivSlot.slot.getDisplayName(l10n))),
|
||||||
|
]
|
||||||
|
.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: e,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
206
lib/piv/views/generate_key_dialog.dart
Normal file
206
lib/piv/views/generate_key_dialog.dart
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
* 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 '../../core/models.dart';
|
||||||
|
import '../../widgets/choice_filter_chip.dart';
|
||||||
|
import '../../widgets/responsive_dialog.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
|
||||||
|
class GenerateKeyDialog extends ConsumerStatefulWidget {
|
||||||
|
final DevicePath devicePath;
|
||||||
|
final PivState pivState;
|
||||||
|
final PivSlot pivSlot;
|
||||||
|
const GenerateKeyDialog(this.devicePath, this.pivState, this.pivSlot,
|
||||||
|
{super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||||
|
_GenerateKeyDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GenerateKeyDialogState extends ConsumerState<GenerateKeyDialog> {
|
||||||
|
String _subject = '';
|
||||||
|
GenerateType _generateType = defaultGenerateType;
|
||||||
|
KeyType _keyType = defaultKeyType;
|
||||||
|
late DateTime _validFrom;
|
||||||
|
late DateTime _validTo;
|
||||||
|
late DateTime _validToDefault;
|
||||||
|
late DateTime _validToMax;
|
||||||
|
bool _generating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
_validFrom = DateTime.utc(now.year, now.month, now.day);
|
||||||
|
_validToDefault = DateTime.utc(now.year + 1, now.month, now.day);
|
||||||
|
_validTo = _validToDefault;
|
||||||
|
_validToMax = DateTime.utc(now.year + 10, now.month, now.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return ResponsiveDialog(
|
||||||
|
allowCancel: !_generating,
|
||||||
|
title: Text(l10n.s_generate_key),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: keys.saveButton,
|
||||||
|
onPressed: _generating || _subject.isEmpty
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
setState(() {
|
||||||
|
_generating = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
Function()? close;
|
||||||
|
final PivGenerateResult result;
|
||||||
|
try {
|
||||||
|
close = showMessage(
|
||||||
|
context,
|
||||||
|
l10n.l_generating_private_key,
|
||||||
|
duration: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
result = await ref
|
||||||
|
.read(pivSlotsProvider(widget.devicePath).notifier)
|
||||||
|
.generate(
|
||||||
|
widget.pivSlot.slot,
|
||||||
|
_keyType,
|
||||||
|
parameters: switch (_generateType) {
|
||||||
|
GenerateType.certificate =>
|
||||||
|
PivGenerateParameters.certificate(
|
||||||
|
subject: _subject,
|
||||||
|
validFrom: _validFrom,
|
||||||
|
validTo: _validTo),
|
||||||
|
GenerateType.csr =>
|
||||||
|
PivGenerateParameters.csr(subject: _subject),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
close?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref.read(withContextProvider)(
|
||||||
|
(context) async {
|
||||||
|
Navigator.of(context).pop(result);
|
||||||
|
showMessage(
|
||||||
|
context,
|
||||||
|
l10n.s_private_key_generated,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(l10n.s_save),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
autofocus: true,
|
||||||
|
key: keys.subjectField,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: l10n.s_subject,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
enabled: !_generating,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value.isEmpty) {
|
||||||
|
_subject = '';
|
||||||
|
} else {
|
||||||
|
_subject = value.contains('=') ? value : 'CN=$value';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 4.0,
|
||||||
|
runSpacing: 8.0,
|
||||||
|
children: [
|
||||||
|
ChoiceFilterChip<GenerateType>(
|
||||||
|
items: GenerateType.values,
|
||||||
|
value: _generateType,
|
||||||
|
selected: _generateType != defaultGenerateType,
|
||||||
|
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
|
||||||
|
onChanged: _generating
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
setState(() {
|
||||||
|
_generateType = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ChoiceFilterChip<KeyType>(
|
||||||
|
items: KeyType.values,
|
||||||
|
value: _keyType,
|
||||||
|
selected: _keyType != defaultKeyType,
|
||||||
|
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
|
||||||
|
onChanged: _generating
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
setState(() {
|
||||||
|
_keyType = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_generateType == GenerateType.certificate)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(dateFormatter.format(_validTo)),
|
||||||
|
onSelected: _generating
|
||||||
|
? null
|
||||||
|
: (value) async {
|
||||||
|
final selected = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _validTo,
|
||||||
|
firstDate: _validFrom,
|
||||||
|
lastDate: _validToMax,
|
||||||
|
);
|
||||||
|
if (selected != null) {
|
||||||
|
setState(() {
|
||||||
|
_validTo = selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: e,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
187
lib/piv/views/import_file_dialog.dart
Normal file
187
lib/piv/views/import_file_dialog.dart
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* 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 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../app/models.dart';
|
||||||
|
import '../../widgets/responsive_dialog.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
|
||||||
|
class ImportFileDialog extends ConsumerStatefulWidget {
|
||||||
|
final DevicePath devicePath;
|
||||||
|
final PivState pivState;
|
||||||
|
final PivSlot pivSlot;
|
||||||
|
final File file;
|
||||||
|
const ImportFileDialog(
|
||||||
|
this.devicePath, this.pivState, this.pivSlot, this.file,
|
||||||
|
{super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||||
|
_ImportFileDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportFileDialogState extends ConsumerState<ImportFileDialog> {
|
||||||
|
late String _data;
|
||||||
|
PivExamineResult? _state;
|
||||||
|
String _password = '';
|
||||||
|
bool _passwordIsWrong = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _init() async {
|
||||||
|
final bytes = await widget.file.readAsBytes();
|
||||||
|
_data = bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
_examine();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _examine() async {
|
||||||
|
setState(() {
|
||||||
|
_state = null;
|
||||||
|
});
|
||||||
|
final result = await ref
|
||||||
|
.read(pivSlotsProvider(widget.devicePath).notifier)
|
||||||
|
.examine(_data, password: _password.isNotEmpty ? _password : null);
|
||||||
|
setState(() {
|
||||||
|
_state = result;
|
||||||
|
_passwordIsWrong = result.maybeWhen(
|
||||||
|
invalidPassword: () => _password.isNotEmpty,
|
||||||
|
orElse: () => true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final state = _state;
|
||||||
|
if (state == null) {
|
||||||
|
return ResponsiveDialog(
|
||||||
|
title: Text(l10n.l_import_file),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: keys.unlockButton,
|
||||||
|
onPressed: null,
|
||||||
|
child: Text(l10n.s_unlock),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.when(
|
||||||
|
invalidPassword: () => ResponsiveDialog(
|
||||||
|
title: Text(l10n.l_import_file),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: keys.unlockButton,
|
||||||
|
onPressed: () => _examine(),
|
||||||
|
child: Text(l10n.s_unlock),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.p_password_protected_file),
|
||||||
|
TextField(
|
||||||
|
autofocus: true,
|
||||||
|
obscureText: true,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
key: keys.managementKeyField,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: l10n.s_password,
|
||||||
|
prefixIcon: const Icon(Icons.password_outlined),
|
||||||
|
errorText: _passwordIsWrong ? l10n.s_wrong_password : null,
|
||||||
|
errorMaxLines: 3),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_passwordIsWrong = false;
|
||||||
|
_password = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _examine(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: e,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
result: (_, privateKey, certificates) => ResponsiveDialog(
|
||||||
|
title: Text(l10n.l_import_file),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: keys.unlockButton,
|
||||||
|
onPressed: () async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(pivSlotsProvider(widget.devicePath).notifier)
|
||||||
|
.import(widget.pivSlot.slot, _data,
|
||||||
|
password: _password.isNotEmpty ? _password : null);
|
||||||
|
navigator.pop(true);
|
||||||
|
} catch (_) {
|
||||||
|
// TODO: More error cases
|
||||||
|
setState(() {
|
||||||
|
_passwordIsWrong = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(l10n.s_import),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.p_import_items_desc(
|
||||||
|
widget.pivSlot.slot.getDisplayName(l10n))),
|
||||||
|
if (privateKey) Text(l10n.l_bullet(l10n.s_private_key)),
|
||||||
|
if (certificates > 0) Text(l10n.l_bullet(l10n.s_certificate)),
|
||||||
|
]
|
||||||
|
.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: e,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
137
lib/piv/views/key_actions.dart
Normal file
137
lib/piv/views/key_actions.dart
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* 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_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
import '../../app/message.dart';
|
||||||
|
import '../../app/models.dart';
|
||||||
|
import '../../app/views/fs_dialog.dart';
|
||||||
|
import '../../app/views/action_list.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
import 'manage_key_dialog.dart';
|
||||||
|
import 'manage_pin_puk_dialog.dart';
|
||||||
|
import 'reset_dialog.dart';
|
||||||
|
|
||||||
|
Widget pivBuildActions(BuildContext context, DevicePath devicePath,
|
||||||
|
PivState pivState, WidgetRef ref) {
|
||||||
|
final colors = Theme.of(context).buttonTheme.colorScheme ??
|
||||||
|
Theme.of(context).colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
final usingDefaultMgmtKey =
|
||||||
|
pivState.metadata?.managementKeyMetadata.defaultValue == true;
|
||||||
|
|
||||||
|
final pinBlocked = pivState.pinAttempts == 0;
|
||||||
|
final pukAttempts = pivState.metadata?.pukMetadata.attemptsRemaining;
|
||||||
|
|
||||||
|
return FsDialog(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ActionListSection(
|
||||||
|
l10n.s_manage,
|
||||||
|
children: [
|
||||||
|
ActionListItem(
|
||||||
|
key: keys.managePinAction,
|
||||||
|
title: l10n.s_pin,
|
||||||
|
subtitle: pinBlocked
|
||||||
|
? l10n.l_piv_pin_blocked
|
||||||
|
: l10n.l_attempts_remaining(pivState.pinAttempts),
|
||||||
|
icon: const Icon(Icons.pin_outlined),
|
||||||
|
onTap: (context) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ManagePinPukDialog(
|
||||||
|
devicePath,
|
||||||
|
target:
|
||||||
|
pinBlocked ? ManageTarget.unblock : ManageTarget.pin,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
ActionListItem(
|
||||||
|
key: keys.managePukAction,
|
||||||
|
title: l10n.s_puk,
|
||||||
|
subtitle: pukAttempts != null
|
||||||
|
? l10n.l_attempts_remaining(pukAttempts)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.pin_outlined),
|
||||||
|
onTap: (context) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ManagePinPukDialog(devicePath,
|
||||||
|
target: ManageTarget.puk),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
ActionListItem(
|
||||||
|
key: keys.manageManagementKeyAction,
|
||||||
|
title: l10n.s_management_key,
|
||||||
|
subtitle: usingDefaultMgmtKey
|
||||||
|
? l10n.l_warning_default_key
|
||||||
|
: (pivState.protectedKey
|
||||||
|
? l10n.l_pin_protected_key
|
||||||
|
: l10n.l_change_management_key),
|
||||||
|
icon: const Icon(Icons.key_outlined),
|
||||||
|
trailing: usingDefaultMgmtKey
|
||||||
|
? Icon(Icons.warning_amber, color: colors.tertiary)
|
||||||
|
: null,
|
||||||
|
onTap: (context) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ManageKeyDialog(devicePath, pivState),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
ActionListItem(
|
||||||
|
key: keys.resetAction,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
actionStyle: ActionStyle.error,
|
||||||
|
title: l10n.s_reset_piv,
|
||||||
|
subtitle: l10n.l_factory_reset_this_app,
|
||||||
|
onTap: (context) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ResetDialog(devicePath),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// TODO
|
||||||
|
/*
|
||||||
|
if (false == true) ...[
|
||||||
|
KeyActionTitle(l10n.s_setup),
|
||||||
|
KeyActionItem(
|
||||||
|
key: keys.setupMacOsAction,
|
||||||
|
title: Text('Setup for macOS'),
|
||||||
|
subtitle: Text('Create certificates for macOS login'),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: theme.secondary,
|
||||||
|
foregroundColor: theme.onSecondary,
|
||||||
|
child: const Icon(Icons.laptop),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
*/
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
278
lib/piv/views/manage_key_dialog.dart
Normal file
278
lib/piv/views/manage_key_dialog.dart
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022 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 'dart:math';
|
||||||
|
|
||||||
|
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 '../../app/message.dart';
|
||||||
|
import '../../app/models.dart';
|
||||||
|
import '../../app/state.dart';
|
||||||
|
import '../../widgets/choice_filter_chip.dart';
|
||||||
|
import '../../widgets/responsive_dialog.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
import 'pin_dialog.dart';
|
||||||
|
|
||||||
|
class ManageKeyDialog extends ConsumerStatefulWidget {
|
||||||
|
final DevicePath path;
|
||||||
|
final PivState pivState;
|
||||||
|
const ManageKeyDialog(this.path, this.pivState, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||||
|
_ManageKeyDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManageKeyDialogState extends ConsumerState<ManageKeyDialog> {
|
||||||
|
late bool _defaultKeyUsed;
|
||||||
|
late bool _usesStoredKey;
|
||||||
|
late bool _storeKey;
|
||||||
|
String _currentKeyOrPin = '';
|
||||||
|
bool _currentIsWrong = false;
|
||||||
|
int _attemptsRemaining = -1;
|
||||||
|
ManagementKeyType _keyType = ManagementKeyType.tdes;
|
||||||
|
final _keyController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_defaultKeyUsed =
|
||||||
|
widget.pivState.metadata?.managementKeyMetadata.defaultValue ?? false;
|
||||||
|
_usesStoredKey = widget.pivState.protectedKey;
|
||||||
|
if (!_usesStoredKey && _defaultKeyUsed) {
|
||||||
|
_currentKeyOrPin = defaultManagementKey;
|
||||||
|
}
|
||||||
|
_storeKey = _usesStoredKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_keyController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_submit() async {
|
||||||
|
final notifier = ref.read(pivStateProvider(widget.path).notifier);
|
||||||
|
if (_usesStoredKey) {
|
||||||
|
final status = (await notifier.verifyPin(_currentKeyOrPin)).when(
|
||||||
|
success: () => true,
|
||||||
|
failure: (attemptsRemaining) {
|
||||||
|
setState(() {
|
||||||
|
_attemptsRemaining = attemptsRemaining;
|
||||||
|
_currentIsWrong = true;
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!await notifier.authenticate(_currentKeyOrPin)) {
|
||||||
|
setState(() {
|
||||||
|
_currentIsWrong = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_storeKey && !_usesStoredKey) {
|
||||||
|
final withContext = ref.read(withContextProvider);
|
||||||
|
final verified = await withContext((context) async =>
|
||||||
|
await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => PinDialog(widget.path))) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifier.setManagementKey(_keyController.text,
|
||||||
|
managementKeyType: _keyType, storeKey: _storeKey);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
showMessage(context, l10n.l_management_key_changed);
|
||||||
|
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final currentType =
|
||||||
|
widget.pivState.metadata?.managementKeyMetadata.keyType ??
|
||||||
|
ManagementKeyType.tdes;
|
||||||
|
final hexLength = _keyType.keyLength * 2;
|
||||||
|
final protected = widget.pivState.protectedKey;
|
||||||
|
final currentLenOk = protected
|
||||||
|
? _currentKeyOrPin.length >= 4
|
||||||
|
: _currentKeyOrPin.length == currentType.keyLength * 2;
|
||||||
|
final newLenOk = _keyController.text.length == hexLength;
|
||||||
|
|
||||||
|
return ResponsiveDialog(
|
||||||
|
title: Text(l10n.l_change_management_key),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: currentLenOk && newLenOk ? _submit : null,
|
||||||
|
key: keys.saveButton,
|
||||||
|
child: Text(l10n.s_save),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.p_change_management_key_desc),
|
||||||
|
if (protected)
|
||||||
|
TextField(
|
||||||
|
autofocus: true,
|
||||||
|
obscureText: true,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
key: keys.pinPukField,
|
||||||
|
maxLength: 8,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: l10n.s_pin,
|
||||||
|
prefixIcon: const Icon(Icons.pin_outlined),
|
||||||
|
errorText: _currentIsWrong
|
||||||
|
? l10n
|
||||||
|
.l_wrong_pin_attempts_remaining(_attemptsRemaining)
|
||||||
|
: null,
|
||||||
|
errorMaxLines: 3),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_currentIsWrong = false;
|
||||||
|
_currentKeyOrPin = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!protected)
|
||||||
|
TextFormField(
|
||||||
|
key: keys.managementKeyField,
|
||||||
|
autofocus: !_defaultKeyUsed,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
initialValue: _defaultKeyUsed ? defaultManagementKey : null,
|
||||||
|
readOnly: _defaultKeyUsed,
|
||||||
|
maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: l10n.s_current_management_key,
|
||||||
|
prefixIcon: const Icon(Icons.password_outlined),
|
||||||
|
errorText: _currentIsWrong ? l10n.l_wrong_key : null,
|
||||||
|
errorMaxLines: 3,
|
||||||
|
helperText: _defaultKeyUsed ? l10n.l_default_key_used : null,
|
||||||
|
),
|
||||||
|
inputFormatters: <TextInputFormatter>[
|
||||||
|
FilteringTextInputFormatter.allow(
|
||||||
|
RegExp('[a-f0-9]', caseSensitive: false))
|
||||||
|
],
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_currentIsWrong = false;
|
||||||
|
_currentKeyOrPin = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
key: keys.newPinPukField,
|
||||||
|
autofocus: _defaultKeyUsed,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
maxLength: hexLength,
|
||||||
|
controller: _keyController,
|
||||||
|
inputFormatters: <TextInputFormatter>[
|
||||||
|
FilteringTextInputFormatter.allow(
|
||||||
|
RegExp('[a-f0-9]', caseSensitive: false))
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: l10n.s_new_management_key,
|
||||||
|
prefixIcon: const Icon(Icons.password_outlined),
|
||||||
|
enabled: currentLenOk,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: currentLenOk
|
||||||
|
? () {
|
||||||
|
final random = Random.secure();
|
||||||
|
final key = List.generate(
|
||||||
|
_keyType.keyLength,
|
||||||
|
(_) => random
|
||||||
|
.nextInt(256)
|
||||||
|
.toRadixString(16)
|
||||||
|
.padLeft(2, '0')).join();
|
||||||
|
setState(() {
|
||||||
|
_keyController.text = key;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (currentLenOk && newLenOk) {
|
||||||
|
_submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 4.0,
|
||||||
|
runSpacing: 8.0,
|
||||||
|
children: [
|
||||||
|
if (widget.pivState.metadata != null)
|
||||||
|
ChoiceFilterChip<ManagementKeyType>(
|
||||||
|
items: ManagementKeyType.values,
|
||||||
|
value: _keyType,
|
||||||
|
selected: _keyType != defaultManagementKeyType,
|
||||||
|
itemBuilder: (value) => Text(value.getDisplayName(l10n)),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_keyType = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FilterChip(
|
||||||
|
label: Text(l10n.s_protect_key),
|
||||||
|
selected: _storeKey,
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() {
|
||||||
|
_storeKey = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: e,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
191
lib/piv/views/manage_pin_puk_dialog.dart
Normal file
191
lib/piv/views/manage_pin_puk_dialog.dart
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022 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 '../../widgets/responsive_dialog.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
|
||||||
|
enum ManageTarget { pin, puk, unblock }
|
||||||
|
|
||||||
|
class ManagePinPukDialog extends ConsumerStatefulWidget {
|
||||||
|
final DevicePath path;
|
||||||
|
final ManageTarget target;
|
||||||
|
const ManagePinPukDialog(this.path,
|
||||||
|
{super.key, this.target = ManageTarget.pin});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||||
|
_ManagePinPukDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManagePinPukDialogState extends ConsumerState<ManagePinPukDialog> {
|
||||||
|
String _currentPin = '';
|
||||||
|
String _newPin = '';
|
||||||
|
String _confirmPin = '';
|
||||||
|
bool _currentIsWrong = false;
|
||||||
|
int _attemptsRemaining = -1;
|
||||||
|
|
||||||
|
_submit() async {
|
||||||
|
final notifier = ref.read(pivStateProvider(widget.path).notifier);
|
||||||
|
final result = await switch (widget.target) {
|
||||||
|
ManageTarget.pin => notifier.changePin(_currentPin, _newPin),
|
||||||
|
ManageTarget.puk => notifier.changePuk(_currentPin, _newPin),
|
||||||
|
ManageTarget.unblock => notifier.unblockPin(_currentPin, _newPin),
|
||||||
|
};
|
||||||
|
|
||||||
|
result.when(success: () {
|
||||||
|
if (!mounted) return;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showMessage(
|
||||||
|
context,
|
||||||
|
switch (widget.target) {
|
||||||
|
ManageTarget.puk => l10n.s_puk_set,
|
||||||
|
_ => l10n.s_pin_set,
|
||||||
|
});
|
||||||
|
}, failure: (attemptsRemaining) {
|
||||||
|
setState(() {
|
||||||
|
_attemptsRemaining = attemptsRemaining;
|
||||||
|
_currentIsWrong = true;
|
||||||
|
_currentPin = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final isValid =
|
||||||
|
_newPin.isNotEmpty && _newPin == _confirmPin && _currentPin.isNotEmpty;
|
||||||
|
|
||||||
|
final titleText = switch (widget.target) {
|
||||||
|
ManageTarget.pin => l10n.s_change_pin,
|
||||||
|
ManageTarget.puk => l10n.s_change_puk,
|
||||||
|
ManageTarget.unblock => l10n.s_unblock_pin,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ResponsiveDialog(
|
||||||
|
title: Text(titleText),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isValid ? _submit : null,
|
||||||
|
key: keys.saveButton,
|
||||||
|
child: Text(l10n.s_save),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
//TODO fix string
|
||||||
|
Text(widget.target == ManageTarget.pin
|
||||||
|
? l10n.p_enter_current_pin_or_reset
|
||||||
|
: l10n.p_enter_current_puk_or_reset),
|
||||||
|
TextField(
|
||||||
|
autofocus: true,
|
||||||
|
obscureText: true,
|
||||||
|
maxLength: 8,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
key: keys.pinPukField,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: widget.target == ManageTarget.pin
|
||||||
|
? l10n.s_current_pin
|
||||||
|
: l10n.s_current_puk,
|
||||||
|
prefixIcon: const Icon(Icons.password_outlined),
|
||||||
|
errorText: _currentIsWrong
|
||||||
|
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
|
||||||
|
: null,
|
||||||
|
errorMaxLines: 3),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_currentIsWrong = false;
|
||||||
|
_currentPin = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Text(l10n.p_enter_new_piv_pin_puk(
|
||||||
|
widget.target == ManageTarget.puk ? l10n.s_puk : l10n.s_pin)),
|
||||||
|
TextField(
|
||||||
|
key: keys.newPinPukField,
|
||||||
|
obscureText: true,
|
||||||
|
maxLength: 8,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: widget.target == ManageTarget.puk
|
||||||
|
? l10n.s_new_puk
|
||||||
|
: l10n.s_new_pin,
|
||||||
|
prefixIcon: const Icon(Icons.password_outlined),
|
||||||
|
// Old YubiKeys allowed a 4 digit PIN
|
||||||
|
enabled: _currentPin.length >= 4,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_newPin = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (isValid) {
|
||||||
|
_submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
key: keys.confirmPinPukField,
|
||||||
|
obscureText: true,
|
||||||
|
maxLength: 8,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: widget.target == ManageTarget.puk
|
||||||
|
? l10n.s_confirm_puk
|
||||||
|
: l10n.s_confirm_pin,
|
||||||
|
prefixIcon: const Icon(Icons.password_outlined),
|
||||||
|
enabled: _currentPin.length >= 4 && _newPin.length >= 6,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_confirmPin = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (isValid) {
|
||||||
|
_submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: e,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
113
lib/piv/views/pin_dialog.dart
Normal file
113
lib/piv/views/pin_dialog.dart
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* 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/models.dart';
|
||||||
|
import '../../exception/cancellation_exception.dart';
|
||||||
|
import '../../widgets/responsive_dialog.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import '../keys.dart' as keys;
|
||||||
|
|
||||||
|
class PinDialog extends ConsumerStatefulWidget {
|
||||||
|
final DevicePath devicePath;
|
||||||
|
const PinDialog(this.devicePath, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConsumerStatefulWidget> createState() => _PinDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PinDialogState extends ConsumerState<PinDialog> {
|
||||||
|
String _pin = '';
|
||||||
|
bool _pinIsWrong = false;
|
||||||
|
int _attemptsRemaining = -1;
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
try {
|
||||||
|
final status = await ref
|
||||||
|
.read(pivStateProvider(widget.devicePath).notifier)
|
||||||
|
.verifyPin(_pin);
|
||||||
|
status.when(
|
||||||
|
success: () {
|
||||||
|
navigator.pop(true);
|
||||||
|
},
|
||||||
|
failure: (attemptsRemaining) {
|
||||||
|
setState(() {
|
||||||
|
_attemptsRemaining = attemptsRemaining;
|
||||||
|
_pinIsWrong = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} on CancellationException catch (_) {
|
||||||
|
navigator.pop(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return ResponsiveDialog(
|
||||||
|
title: Text(l10n.s_pin_required),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: keys.unlockButton,
|
||||||
|
onPressed: _pin.length >= 4 ? _submit : null,
|
||||||
|
child: Text(l10n.s_unlock),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.p_pin_required_desc),
|
||||||
|
TextField(
|
||||||
|
autofocus: true,
|
||||||
|
obscureText: true,
|
||||||
|
maxLength: 8,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
key: keys.managementKeyField,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: l10n.s_pin,
|
||||||
|
prefixIcon: const Icon(Icons.pin_outlined),
|
||||||
|
errorText: _pinIsWrong
|
||||||
|
? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining)
|
||||||
|
: null,
|
||||||
|
errorMaxLines: 3),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_pinIsWrong = false;
|
||||||
|
_pin = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: e,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
118
lib/piv/views/piv_screen.dart
Normal file
118
lib/piv/views/piv_screen.dart
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022 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/shortcuts.dart';
|
||||||
|
import '../../app/views/app_failure_page.dart';
|
||||||
|
import '../../app/views/app_list_item.dart';
|
||||||
|
import '../../app/views/app_page.dart';
|
||||||
|
import '../../app/views/message_page.dart';
|
||||||
|
import '../../widgets/list_title.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import 'actions.dart';
|
||||||
|
import 'key_actions.dart';
|
||||||
|
import 'slot_dialog.dart';
|
||||||
|
|
||||||
|
class PivScreen extends ConsumerWidget {
|
||||||
|
final DevicePath devicePath;
|
||||||
|
|
||||||
|
const PivScreen(this.devicePath, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return ref.watch(pivStateProvider(devicePath)).when(
|
||||||
|
loading: () => MessagePage(
|
||||||
|
title: Text(l10n.s_piv),
|
||||||
|
graphic: const CircularProgressIndicator(),
|
||||||
|
delayedContent: true,
|
||||||
|
),
|
||||||
|
error: (error, _) => AppFailurePage(
|
||||||
|
title: Text(l10n.s_piv),
|
||||||
|
cause: error,
|
||||||
|
),
|
||||||
|
data: (pivState) {
|
||||||
|
final pivSlots = ref.watch(pivSlotsProvider(devicePath)).asData;
|
||||||
|
return AppPage(
|
||||||
|
title: Text(l10n.s_piv),
|
||||||
|
keyActionsBuilder: (context) =>
|
||||||
|
pivBuildActions(context, devicePath, pivState, ref),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ListTitle(l10n.s_certificates),
|
||||||
|
if (pivSlots?.hasValue == true)
|
||||||
|
...pivSlots!.value.map((e) => registerPivActions(
|
||||||
|
devicePath,
|
||||||
|
pivState,
|
||||||
|
e,
|
||||||
|
ref: ref,
|
||||||
|
actions: {
|
||||||
|
OpenIntent:
|
||||||
|
CallbackAction<OpenIntent>(onInvoke: (_) async {
|
||||||
|
await showBlurDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SlotDialog(e.slot),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
builder: (context) => _CertificateListItem(e),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CertificateListItem extends StatelessWidget {
|
||||||
|
final PivSlot pivSlot;
|
||||||
|
const _CertificateListItem(this.pivSlot);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final slot = pivSlot.slot;
|
||||||
|
final certInfo = pivSlot.certInfo;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return AppListItem(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
foregroundColor: colorScheme.onSecondary,
|
||||||
|
backgroundColor: colorScheme.secondary,
|
||||||
|
child: const Icon(Icons.approval),
|
||||||
|
),
|
||||||
|
title: slot.getDisplayName(l10n),
|
||||||
|
subtitle: certInfo != null
|
||||||
|
? l10n.l_subject_issuer(certInfo.subject, certInfo.issuer)
|
||||||
|
: pivSlot.hasKey == true
|
||||||
|
? l10n.l_key_no_certificate
|
||||||
|
: l10n.l_no_certificate,
|
||||||
|
trailing: OutlinedButton(
|
||||||
|
onPressed: Actions.handler(context, const OpenIntent()),
|
||||||
|
child: const Icon(Icons.more_horiz),
|
||||||
|
),
|
||||||
|
buildPopupActions: (context) => buildSlotActions(certInfo != null, l10n),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
67
lib/piv/views/reset_dialog.dart
Normal file
67
lib/piv/views/reset_dialog.dart
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* 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 '../../widgets/responsive_dialog.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import '../../app/models.dart';
|
||||||
|
import '../../app/state.dart';
|
||||||
|
|
||||||
|
class ResetDialog extends ConsumerWidget {
|
||||||
|
final DevicePath devicePath;
|
||||||
|
const ResetDialog(this.devicePath, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return ResponsiveDialog(
|
||||||
|
title: Text(l10n.s_factory_reset),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await ref.read(pivStateProvider(devicePath).notifier).reset();
|
||||||
|
await ref.read(withContextProvider)((context) async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showMessage(context, l10n.l_piv_app_reset);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(l10n.s_reset),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.p_warning_piv_reset,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(l10n.p_warning_piv_reset_desc),
|
||||||
|
]
|
||||||
|
.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: e,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
117
lib/piv/views/slot_dialog.dart
Normal file
117
lib/piv/views/slot_dialog.dart
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../app/state.dart';
|
||||||
|
import '../../app/views/fs_dialog.dart';
|
||||||
|
import '../../app/views/action_list.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../state.dart';
|
||||||
|
import 'actions.dart';
|
||||||
|
|
||||||
|
class SlotDialog extends ConsumerWidget {
|
||||||
|
final SlotId pivSlot;
|
||||||
|
const SlotDialog(this.pivSlot, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// TODO: Solve this in a cleaner way
|
||||||
|
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
|
||||||
|
if (node == null) {
|
||||||
|
// The rest of this method assumes there is a device, and will throw an exception if not.
|
||||||
|
// This will never be shown, as the dialog will be immediately closed
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
// This is what ListTile uses for subtitle
|
||||||
|
final subtitleStyle = textTheme.bodyMedium!.copyWith(
|
||||||
|
color: textTheme.bodySmall!.color,
|
||||||
|
);
|
||||||
|
|
||||||
|
final pivState = ref.watch(pivStateProvider(node.path)).valueOrNull;
|
||||||
|
final slotData = ref.watch(pivSlotsProvider(node.path).select((value) =>
|
||||||
|
value.whenOrNull(
|
||||||
|
data: (data) =>
|
||||||
|
data.firstWhere((element) => element.slot == pivSlot))));
|
||||||
|
|
||||||
|
if (pivState == null || slotData == null) {
|
||||||
|
return const FsDialog(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final certInfo = slotData.certInfo;
|
||||||
|
return registerPivActions(
|
||||||
|
node.path,
|
||||||
|
pivState,
|
||||||
|
slotData,
|
||||||
|
ref: ref,
|
||||||
|
builder: (context) => FocusScope(
|
||||||
|
autofocus: true,
|
||||||
|
child: FsDialog(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 48, bottom: 32),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
pivSlot.getDisplayName(l10n),
|
||||||
|
style: textTheme.headlineSmall,
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (certInfo != null) ...[
|
||||||
|
Text(
|
||||||
|
l10n.l_subject_issuer(
|
||||||
|
certInfo.subject, certInfo.issuer),
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: subtitleStyle,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.l_serial(certInfo.serial),
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: subtitleStyle,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.l_certificate_fingerprint(certInfo.fingerprint),
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: subtitleStyle,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.l_valid(
|
||||||
|
certInfo.notValidBefore, certInfo.notValidAfter),
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: subtitleStyle,
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: Text(
|
||||||
|
l10n.l_no_certificate,
|
||||||
|
softWrap: true,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: subtitleStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ActionListSection.fromMenuActions(
|
||||||
|
context,
|
||||||
|
l10n.s_actions,
|
||||||
|
actions: buildSlotActions(certInfo != null, l10n),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -21,6 +21,7 @@ const accentGreen = Color(0xff9aca3c);
|
|||||||
const primaryBlue = Color(0xff325f74);
|
const primaryBlue = Color(0xff325f74);
|
||||||
const primaryRed = Color(0xffea4335);
|
const primaryRed = Color(0xffea4335);
|
||||||
const darkRed = Color(0xffda4d41);
|
const darkRed = Color(0xffda4d41);
|
||||||
|
const amber = Color(0xffffca28);
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
static ThemeData get lightTheme => ThemeData(
|
static ThemeData get lightTheme => ThemeData(
|
||||||
@ -32,6 +33,7 @@ class AppTheme {
|
|||||||
).copyWith(
|
).copyWith(
|
||||||
primary: primaryBlue,
|
primary: primaryBlue,
|
||||||
//secondary: accentGreen,
|
//secondary: accentGreen,
|
||||||
|
tertiary: amber.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
textTheme: TextTheme(
|
textTheme: TextTheme(
|
||||||
bodySmall: TextStyle(color: Colors.grey.shade900),
|
bodySmall: TextStyle(color: Colors.grey.shade900),
|
||||||
@ -57,6 +59,7 @@ class AppTheme {
|
|||||||
//onPrimaryContainer: Colors.grey.shade100,
|
//onPrimaryContainer: Colors.grey.shade100,
|
||||||
error: darkRed,
|
error: darkRed,
|
||||||
onError: Colors.white.withOpacity(0.9),
|
onError: Colors.white.withOpacity(0.9),
|
||||||
|
tertiary: amber.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
textTheme: TextTheme(
|
textTheme: TextTheme(
|
||||||
bodySmall: TextStyle(color: Colors.grey.shade500),
|
bodySmall: TextStyle(color: Colors.grey.shade500),
|
||||||
|
@ -1,47 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2022 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 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
PopupMenuItem buildMenuItem({
|
|
||||||
required Widget title,
|
|
||||||
Widget? leading,
|
|
||||||
String? trailing,
|
|
||||||
void Function()? action,
|
|
||||||
}) =>
|
|
||||||
PopupMenuItem(
|
|
||||||
enabled: action != null,
|
|
||||||
onTap: () {
|
|
||||||
// Wait for popup menu to close before running action.
|
|
||||||
Timer.run(action!);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
enabled: action != null,
|
|
||||||
dense: true,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
minLeadingWidth: 0,
|
|
||||||
title: title,
|
|
||||||
leading: leading,
|
|
||||||
trailing: trailing != null
|
|
||||||
? Opacity(
|
|
||||||
opacity: 0.5,
|
|
||||||
child: Text(trailing, textScaleFactor: 0.7),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
);
|
|
@ -22,13 +22,16 @@ class ResponsiveDialog extends StatefulWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
final List<Widget> actions;
|
final List<Widget> actions;
|
||||||
final Function()? onCancel;
|
final Function()? onCancel;
|
||||||
|
final bool allowCancel;
|
||||||
|
|
||||||
const ResponsiveDialog(
|
const ResponsiveDialog({
|
||||||
{super.key,
|
super.key,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.title,
|
this.title,
|
||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
this.onCancel});
|
this.onCancel,
|
||||||
|
this.allowCancel = true,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ResponsiveDialog> createState() => _ResponsiveDialogState();
|
State<ResponsiveDialog> createState() => _ResponsiveDialogState();
|
||||||
@ -36,31 +39,35 @@ class ResponsiveDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _ResponsiveDialogState extends State<ResponsiveDialog> {
|
class _ResponsiveDialogState extends State<ResponsiveDialog> {
|
||||||
final Key _childKey = GlobalKey();
|
final Key _childKey = GlobalKey();
|
||||||
|
final _focus = FocusScopeNode();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) =>
|
void dispose() {
|
||||||
LayoutBuilder(builder: ((context, constraints) {
|
super.dispose();
|
||||||
final l10n = AppLocalizations.of(context)!;
|
_focus.dispose();
|
||||||
if (constraints.maxWidth < 540) {
|
}
|
||||||
// Fullscreen
|
|
||||||
return Scaffold(
|
Widget _buildFullscreen(BuildContext context) => Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: widget.title,
|
title: widget.title,
|
||||||
actions: widget.actions,
|
actions: widget.actions,
|
||||||
leading: CloseButton(
|
leading: IconButton(
|
||||||
onPressed: () {
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: widget.allowCancel
|
||||||
|
? () {
|
||||||
widget.onCancel?.call();
|
widget.onCancel?.call();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
}
|
||||||
),
|
: null),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: SafeArea(
|
child:
|
||||||
child: Container(key: _childKey, child: widget.child)),
|
SafeArea(child: Container(key: _childKey, child: widget.child)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// Dialog
|
Widget _buildDialog(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final cancelText = widget.onCancel == null && widget.actions.isEmpty
|
final cancelText = widget.onCancel == null && widget.actions.isEmpty
|
||||||
? l10n.s_close
|
? l10n.s_close
|
||||||
: l10n.s_cancel;
|
: l10n.s_cancel;
|
||||||
@ -85,5 +92,22 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) =>
|
||||||
|
LayoutBuilder(builder: ((context, constraints) {
|
||||||
|
// This keeps the focus in the dialog, even if the underlying page changes.
|
||||||
|
return FocusScope(
|
||||||
|
node: _focus,
|
||||||
|
autofocus: true,
|
||||||
|
onFocusChange: (focused) {
|
||||||
|
if (!focused) {
|
||||||
|
_focus.requestFocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: constraints.maxWidth < 540
|
||||||
|
? _buildFullscreen(context)
|
||||||
|
: _buildDialog(context),
|
||||||
|
);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user