feat(@desktop/wallet): keypair rename (a profile keypair name should follow display name)

Fixes: #10769
This commit is contained in:
Sale Djenic 2023-07-13 15:58:48 +02:00 committed by saledjenic
parent bed7db5528
commit 6d25a888d3
17 changed files with 364 additions and 30 deletions

View File

@ -1,7 +1,6 @@
import io_interface import io_interface
import ../../../../../../app_service/service/wallet_account/service as wallet_account_service import app_service/service/wallet_account/service as wallet_account_service
import app/modules/shared_modules/keycard_popup/io_interface as keycard_shared_module
import ../../../../shared_modules/keycard_popup/io_interface as keycard_shared_module
type type
Controller* = ref object of RootObj Controller* = ref object of RootObj
@ -31,6 +30,9 @@ proc updateAccount*(self: Controller, address: string, accountName: string, colo
proc updateAccountPosition*(self: Controller, address: string, position: int) = proc updateAccountPosition*(self: Controller, address: string, position: int) =
self.walletAccountService.updateWalletAccountPosition(address, position) self.walletAccountService.updateWalletAccountPosition(address, position)
proc renameKeypair*(self: Controller, keyUid: string, name: string) =
self.walletAccountService.updateKeypairName(keyUid, name)
proc deleteAccount*(self: Controller, address: string) = proc deleteAccount*(self: Controller, address: string) =
self.walletAccountService.deleteAccount(address) self.walletAccountService.deleteAccount(address)

View File

@ -28,6 +28,9 @@ method updateAccount*(self: AccessInterface, address: string, accountName: strin
method updateAccountPosition*(self: AccessInterface, address: string, position: int) {.base.} = method updateAccountPosition*(self: AccessInterface, address: string, position: int) {.base.} =
raise newException(ValueError, "No implementation available") raise newException(ValueError, "No implementation available")
method renameKeypair*(self: AccessInterface, keyUid: string, name: string) {.base.} =
raise newException(ValueError, "No implementation available")
# View Delegate Interface # View Delegate Interface
# Delegate for the view must be declared here due to use of QtObject and multi # Delegate for the view must be declared here due to use of QtObject and multi
# inheritance, which is not well supported in Nim. # inheritance, which is not well supported in Nim.

View File

@ -1,6 +1,8 @@
import NimQml, Tables, strutils, strformat import NimQml, Tables, strutils, strformat
import ./item import ./item
export item
type type
ModelRole {.pure.} = enum ModelRole {.pure.} = enum
Name = UserRole + 1, Name = UserRole + 1,

View File

@ -2,15 +2,15 @@ import NimQml, sequtils, sugar, chronicles
import ./io_interface, ./view, ./item, ./controller import ./io_interface, ./view, ./item, ./controller
import ../io_interface as delegate_interface import ../io_interface as delegate_interface
import ../../../../shared/wallet_utils import app/modules/shared/wallet_utils
import ../../../../shared/keypairs import app/modules/shared/keypairs
import ../../../../shared_models/keypair_item import app/modules/shared_models/keypair_model
import ../../../../../global/global_singleton import app/global/global_singleton
import ../../../../../core/eventemitter import app/core/eventemitter
import ../../../../../../app_service/service/keycard/service as keycard_service import app_service/service/keycard/service as keycard_service
import ../../../../../../app_service/service/wallet_account/service as wallet_account_service import app_service/service/wallet_account/service as wallet_account_service
import ../../../../../../app_service/service/network/service as network_service import app_service/service/network/service as network_service
import ../../../../../../app_service/service/settings/service import app_service/service/settings/service
export io_interface export io_interface
@ -39,6 +39,9 @@ proc newModule*(
result.controller = controller.newController(result, walletAccountService) result.controller = controller.newController(result, walletAccountService)
result.moduleLoaded = false result.moduleLoaded = false
## Forward declarations
proc onKeypairRenamed(self: Module, keyUid: string, name: string)
method delete*(self: Module) = method delete*(self: Module) =
self.view.delete self.view.delete
self.viewVariant.delete self.viewVariant.delete
@ -102,6 +105,14 @@ method load*(self: Module) =
return return
self.refreshWalletAccounts() self.refreshWalletAccounts()
self.events.on(SIGNAL_KEYPAIR_NAME_CHANGED) do(e: Args):
let args = KeypairArgs(e)
self.onKeypairRenamed(args.keypair.keyUid, args.keypair.name)
self.events.on(SIGNAL_DISPLAY_NAME_UPDATED) do(e:Args):
let args = SettingsTextValueArgs(e)
self.onKeypairRenamed(singletonInstance.userProfile.getKeyUid(), args.value)
self.events.on(SIGNAL_WALLET_ACCOUNT_POSITION_UPDATED) do(e:Args): self.events.on(SIGNAL_WALLET_ACCOUNT_POSITION_UPDATED) do(e:Args):
self.refreshWalletAccounts() self.refreshWalletAccounts()
@ -131,3 +142,9 @@ method deleteAccount*(self: Module, address: string) =
method toggleIncludeWatchOnlyAccount*(self: Module) = method toggleIncludeWatchOnlyAccount*(self: Module) =
self.controller.toggleIncludeWatchOnlyAccount() self.controller.toggleIncludeWatchOnlyAccount()
method renameKeypair*(self: Module, keyUid: string, name: string) =
self.controller.renameKeypair(keyUid, name)
proc onKeypairRenamed(self: Module, keyUid: string, name: string) =
self.view.keyPairModel.updateKeypairName(keyUid, name)

View File

@ -1,9 +1,8 @@
import NimQml, sequtils, strutils, sugar import NimQml, sequtils, strutils, sugar
import ./model
import ./item
import ./io_interface import ./io_interface
import ../../../../shared_models/[keypair_model, keypair_item] import ./model
import app/modules/shared_models/keypair_model
QtObject: QtObject:
type type
@ -56,6 +55,9 @@ QtObject:
proc deleteAccount*(self: View, address: string) {.slot.} = proc deleteAccount*(self: View, address: string) {.slot.} =
self.delegate.deleteAccount(address) self.delegate.deleteAccount(address)
proc keyPairModel*(self: View): KeyPairModel =
return self.keyPairModel
proc keyPairModelChanged*(self: View) {.signal.} proc keyPairModelChanged*(self: View) {.signal.}
proc getKeyPairModel(self: View): QVariant {.slot.} = proc getKeyPairModel(self: View): QVariant {.slot.} =
return newQVariant(self.keyPairModel) return newQVariant(self.keyPairModel)
@ -80,3 +82,9 @@ QtObject:
proc setIncludeWatchOnlyAccount*(self: View, includeWatchOnlyAccount: bool) = proc setIncludeWatchOnlyAccount*(self: View, includeWatchOnlyAccount: bool) =
self.includeWatchOnlyAccount = includeWatchOnlyAccount self.includeWatchOnlyAccount = includeWatchOnlyAccount
self.includeWatchOnlyAccountChanged() self.includeWatchOnlyAccountChanged()
proc keypairNameExists*(self: View, name: string): bool {.slot.} =
return self.keyPairModel.keypairNameExists(name)
proc renameKeypair*(self: View, keyUid: string, name: string) {.slot.} =
self.delegate.renameKeypair(keyUid, name)

View File

@ -1,4 +1,4 @@
import NimQml, Tables, strformat import NimQml, Tables, strformat, sequtils, sugar
import keypair_item import keypair_item
export keypair_item export keypair_item
@ -80,3 +80,12 @@ QtObject:
if keyUid == item.getKeyUid(): if keyUid == item.getKeyUid():
item.getAccountsModel().updateDetailsForAddressIfTheyAreSet(address, name, colorId, emoji) item.getAccountsModel().updateDetailsForAddressIfTheyAreSet(address, name, colorId, emoji)
break break
proc keypairNameExists*(self: KeyPairModel, name: string): bool =
return self.items.any(x => x.getName() == name)
proc updateKeypairName*(self: KeyPairModel, keyUid: string, name: string) =
let item = self.findItemByKeyUid(keyUid)
if item.isNil:
return
item.setName(name)

View File

@ -576,6 +576,24 @@ QtObject:
except Exception as e: except Exception as e:
error "error: ", procName="updateAccountPosition", errName=e.name, errDesription=e.msg error "error: ", procName="updateAccountPosition", errName=e.name, errDesription=e.msg
proc updateKeypairName*(self: Service, keyUid: string, name: string) =
try:
let response = backend.updateKeypairName(keyUid, name)
if not response.error.isNil:
error "status-go error", procName="updateKeypairName", errCode=response.error.code, errDesription=response.error.message
return
# Once we start maintaining local store by keypairs we will need to update that store from here,
# till then we just emit signal from here.
self.events.emit(SIGNAL_KEYPAIR_NAME_CHANGED, KeypairArgs(
keypair: KeypairDto(
keyUid: keyUid,
name: name
)
)
)
except Exception as e:
error "error: ", procName="updateKeypairName", errName=e.name, errDesription=e.msg
proc fetchDerivedAddresses*(self: Service, password: string, derivedFrom: string, paths: seq[string], hashPassword: bool) = proc fetchDerivedAddresses*(self: Service, password: string, derivedFrom: string, paths: seq[string], hashPassword: bool) =
let arg = FetchDerivedAddressesTaskArg( let arg = FetchDerivedAddressesTaskArg(
password: if hashPassword: utils.hashPassword(password) else: password, password: if hashPassword: utils.hashPassword(password) else: password,
@ -945,10 +963,7 @@ QtObject:
proc handleKeypair(self: Service, keypair: KeypairDto) = proc handleKeypair(self: Service, keypair: KeypairDto) =
## In some point in future instead `self.walletAccounts` table we should switch to maintaining local state in the ## In some point in future instead `self.walletAccounts` table we should switch to maintaining local state in the
## form of keypairs + another list just for watch only accounts. We will benefint from that in terms of maintaining. ## form of keypairs + another list just for watch only accounts. We will benefint from that in terms of maintaining.
## Keycards detaiils will be in that case tracked easier and stored locally as well. Also at that point we can check ## Keycards details will be in that case tracked easier and stored locally as well.
## if the local keypair name is different than one received here and emit signal only in that case, till then,
## we emit it always.
self.events.emit(SIGNAL_KEYPAIR_NAME_CHANGED, KeypairArgs(keypair: KeypairDto(name: keypair.name)))
# handle keypair related accounts # handle keypair related accounts
# - first remove removed accounts from the UI # - first remove removed accounts from the UI

View File

@ -263,6 +263,10 @@ rpc(updateAccountPosition, "accounts"):
address: string address: string
position: int position: int
rpc(updateKeypairName, "accounts"):
keyUid: string
name: string
rpc(getHourlyMarketValues, "wallet"): rpc(getHourlyMarketValues, "wallet"):
symbol: string symbol: string
currency: string currency: string

View File

@ -215,6 +215,7 @@
<file>assets/img/icons/key_pair_private_key.svg</file> <file>assets/img/icons/key_pair_private_key.svg</file>
<file>assets/img/icons/key_pair_seed_phrase.svg</file> <file>assets/img/icons/key_pair_seed_phrase.svg</file>
<file>assets/img/icons/keyboard.svg</file> <file>assets/img/icons/keyboard.svg</file>
<file>assets/img/icons/keycard-crossed.svg</file>
<file>assets/img/icons/keycard-logo.svg</file> <file>assets/img/icons/keycard-logo.svg</file>
<file>assets/img/icons/keycard.svg</file> <file>assets/img/icons/keycard.svg</file>
<file>assets/img/icons/language.svg</file> <file>assets/img/icons/language.svg</file>

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Union" fill-rule="evenodd" clip-rule="evenodd" d="M3.46967 2.21967C3.76256 1.92678 4.23744 1.92678 4.53033 2.21967L6.91421 4.60355C7.00798 4.69732 7.13516 4.75 7.26777 4.75H17C19.2091 4.75 21 6.54086 21 8.75V16.75C21 17.2 20.9257 17.6326 20.7887 18.0362C20.7225 18.2313 20.7611 18.4504 20.9067 18.596L23.0303 20.7197C23.3232 21.0126 23.3232 21.4874 23.0303 21.7803C22.7374 22.0732 22.2626 22.0732 21.9697 21.7803L3.46967 3.28033C3.17678 2.98744 3.17678 2.51256 3.46967 2.21967ZM19.1785 16.8678C19.299 16.9884 19.5 16.9205 19.5 16.75V8.75C19.5 7.36929 18.3807 6.25 17 6.25H9.76777C9.32231 6.25 9.09923 6.78857 9.41421 7.10355L19.1785 16.8678ZM5 11.75C5 11.1977 5.44772 10.75 6 10.75H7C7.55228 10.75 8 11.1977 8 11.75V13.75C8 14.3023 7.55228 14.75 7 14.75H6C5.44772 14.75 5 14.3023 5 13.75V11.75ZM2.90954 5.33906C3.14341 5.19542 3.4411 5.25176 3.63517 5.44583C4.00257 5.81323 3.81119 6.4898 3.41036 6.82039C2.8544 7.27892 2.5 7.97307 2.5 8.75V16.75C2.5 18.1307 3.61929 19.25 5 19.25H17C17.2563 19.25 17.5139 19.3246 17.6951 19.5058L17.7172 19.5279C18.1439 19.9545 17.9959 20.6718 17.3954 20.7307C17.2653 20.7435 17.1334 20.75 17 20.75H5C2.79086 20.75 1 18.9591 1 16.75V8.75C1 7.30709 1.764 6.04263 2.90954 5.33906Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -5,6 +5,7 @@ import StatusQ.Controls 0.1
import StatusQ.Components 0.1 import StatusQ.Components 0.1
import StatusQ.Core 0.1 import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1 import StatusQ.Core.Theme 0.1
import StatusQ.Popups 0.1
import utils 1.0 import utils 1.0
@ -17,6 +18,7 @@ Rectangle {
signal goToAccountView(var account) signal goToAccountView(var account)
signal toggleIncludeWatchOnlyAccount() signal toggleIncludeWatchOnlyAccount()
signal runRenameKeypairFlow()
QtObject { QtObject {
id: d id: d
@ -62,6 +64,69 @@ Rectangle {
icon.name: "more" icon.name: "more"
icon.color: Theme.palette.directColor1 icon.color: Theme.palette.directColor1
visible: !d.isWatchOnly visible: !d.isWatchOnly
highlighted: menuLoader.item && menuLoader.item.opened
onClicked: {
menuLoader.active = true
menuLoader.item.popup(0, height)
}
Loader {
id: menuLoader
active: false
sourceComponent: StatusMenu {
onClosed: {
menuLoader.active = false
}
StatusAction {
text: enabled? qsTr("Show encrypted QR of keys on device") : ""
enabled: !d.isProfileKeypair
icon.name: "qr"
icon.color: Theme.palette.primaryColor1
onTriggered: {
console.warn("TODO: show encrypted QR")
}
}
StatusAction {
text: model.keyPair.migratedToKeycard? qsTr("Stop using Keycard") : qsTr("Move keys to a Keycard")
icon.name: model.keyPair.migratedToKeycard? "keycard-crossed" : "keycard"
icon.color: Theme.palette.primaryColor1
onTriggered: {
if (model.keyPair.migratedToKeycard)
console.warn("TODO: stop using Keycard")
else
console.warn("TODO: move keys to a Keycard")
}
}
StatusAction {
text: enabled? qsTr("Rename keypair") : ""
enabled: !d.isProfileKeypair
icon.name: "edit"
icon.color: Theme.palette.primaryColor1
onTriggered: {
root.runRenameKeypairFlow()
}
}
StatusMenuSeparator {
visible: !d.isProfileKeypair
}
StatusAction {
text: enabled? qsTr("Remove master keys and associated accounts") : ""
enabled: !d.isProfileKeypair
type: StatusAction.Type.Danger
icon.name: "delete"
icon.color: Theme.palette.dangerColor1
onTriggered: {
console.warn("TODO: remove master keys and associated accounts")
}
}
}
}
}, },
StatusBaseText { StatusBaseText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter

View File

@ -32,7 +32,7 @@ Item {
label: qsTr("Display name") label: qsTr("Display name")
placeholderText: qsTr("Display Name") placeholderText: qsTr("Display Name")
charLimit: 24 charLimit: Constants.keypair.nameLengthMax
validators: Constants.validators.displayName validators: Constants.validators.displayName
input.tabNavItem: bioInput.input.edit input.tabNavItem: bioInput.input.edit

View File

@ -0,0 +1,153 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Core.Utils 0.1
import StatusQ.Controls 0.1
import StatusQ.Components 0.1
import StatusQ.Popups 0.1
import utils 1.0
import shared.controls 1.0
import "../stores"
StatusModal {
id: root
required property var accountsModule
required property string keyUid
required property string name
required property var accounts
headerSettings.title: qsTr("Rename keypair")
focus: visible
padding: Style.current.padding
QtObject {
id: d
property bool entryValid: false
function updateValidity() {
d.entryValid = nameInput.valid
if (!d.entryValid) {
return
}
d.entryValid = d.entryValid && nameInput.text !== root.name
if (!d.entryValid) {
nameInput.errorMessageCmp.text = qsTr("Same name")
nameInput.valid = false
return
}
d.entryValid = d.entryValid && !root.accountsModule.keypairNameExists(nameInput.text)
if (!d.entryValid) {
nameInput.errorMessageCmp.text = qsTr("Key name already in use")
nameInput.valid = false
}
}
function confirm() {
if (d.entryValid) {
root.accountsModule.renameKeypair(root.keyUid, nameInput.text)
root.close()
}
}
}
contentItem: ColumnLayout {
spacing: Style.current.halfPadding
StatusInput {
id: nameInput
Layout.preferredWidth: parent.width
Layout.preferredHeight: 120
topPadding: 8
bottomPadding: 8
label: qsTr("Key name")
charLimit: Constants.keypair.nameLengthMax
validators: Constants.validators.keypairName
input.clearable: true
input.rightPadding: 16
text: root.name
onTextChanged: {
d.updateValidity()
}
}
StatusBaseText {
Layout.preferredWidth: parent.width
Layout.topMargin: Style.current.padding
text: qsTr("Accounts derived from this key")
font.pixelSize: Style.current.primaryTextFontSize
}
Rectangle {
Layout.preferredWidth: parent.width
Layout.preferredHeight: 60
color: "transparent"
radius: 8
border.width: 1
border.color: Theme.palette.baseColor2
StatusScrollView {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: contentHeight
padding: 0
leftPadding: 16
rightPadding: 16
Row {
spacing: 10
Repeater {
model: root.accounts
delegate: StatusListItemTag {
bgColor: Utils.getColorForId(model.account.colorId)
height: Style.current.bigPadding
bgRadius: 6
tagClickable: false
closeButtonVisible: false
asset {
emoji: model.account.emoji
emojiSize: Emoji.size.verySmall
isLetterIdenticon: !!model.account.emoji
name: model.account.icon
color: Theme.palette.indirectColor1
width: 16
height: 16
}
title: model.account.name
titleText.font.pixelSize: 12
titleText.color: Theme.palette.indirectColor1
}
}
}
}
}
}
rightButtons: [
StatusFlatButton {
text: qsTr("Cancel")
type: StatusBaseButton.Type.Normal
onClicked: {
root.close()
}
},
StatusButton {
text: qsTr("Save changes")
enabled: d.entryValid
focus: true
Keys.onReturnPressed: function(event) {
d.confirm()
}
onClicked: {
d.confirm()
}
}
]
}

View File

@ -2,3 +2,4 @@ BackupSeedModal 1.0 BackupSeedModal.qml
SetupSyncingPopup 1.0 SetupSyncingPopup.qml SetupSyncingPopup 1.0 SetupSyncingPopup.qml
AddSocialLinkModal 1.0 AddSocialLinkModal.qml AddSocialLinkModal 1.0 AddSocialLinkModal.qml
ModifySocialLinkModal 1.0 ModifySocialLinkModal.qml ModifySocialLinkModal 1.0 ModifySocialLinkModal.qml
RenameKeypairPopup 1.0 RenameKeypairPopup.qml

View File

@ -105,7 +105,37 @@ Column {
includeWatchOnlyAccount: walletStore.includeWatchOnlyAccount includeWatchOnlyAccount: walletStore.includeWatchOnlyAccount
onGoToAccountView: root.goToAccountView(account) onGoToAccountView: root.goToAccountView(account)
onToggleIncludeWatchOnlyAccount: walletStore.toggleIncludeWatchOnlyAccount() onToggleIncludeWatchOnlyAccount: walletStore.toggleIncludeWatchOnlyAccount()
onRunRenameKeypairFlow: {
renameKeypairPopup.keyUid = model.keyPair.keyUid
renameKeypairPopup.name = model.keyPair.name
renameKeypairPopup.accounts = model.keyPair.accounts
renameKeypairPopup.active = true
} }
} }
} }
} }
Loader {
id: renameKeypairPopup
active: false
property string keyUid
property string name
property var accounts
sourceComponent: RenameKeypairPopup {
accountsModule: root.walletStore.accountsModule
keyUid: renameKeypairPopup.keyUid
name: renameKeypairPopup.name
accounts: renameKeypairPopup.accounts
onClosed: {
renameKeypairPopup.active = false
}
}
onLoaded: {
renameKeypairPopup.item.open()
}
}
}

View File

@ -466,7 +466,28 @@ QtObject {
readonly property int blockedContacts: 6 readonly property int blockedContacts: 6
} }
readonly property QtObject keypair: QtObject {
readonly property int nameLengthMax: 20
readonly property int nameLengthMin: 5
}
readonly property QtObject validators: QtObject { readonly property QtObject validators: QtObject {
readonly property list<StatusValidator> keypairName: [
StatusValidator {
name: "startsWithSpaceValidator"
validate: function (t) { return !t.startsWith(" ") }
errorMessage: qsTr("Keypair starting with whitespace are not allowed")
},
StatusRegularExpressionValidator {
regularExpression: /^[a-zA-Z0-9\-_ ]+$/
errorMessage: errorMessages.alphanumericalExpandedRegExp
},
StatusMinLengthValidator {
minLength: keypair.nameLengthMin
errorMessage: qsTr("Keypair must be at least %n character(s)", "", keypair.nameLengthMin)
}
]
readonly property list<StatusValidator> displayName: [ readonly property list<StatusValidator> displayName: [
StatusValidator { StatusValidator {
name: "startsWithSpaceValidator" name: "startsWithSpaceValidator"
@ -475,11 +496,11 @@ QtObject {
}, },
StatusRegularExpressionValidator { StatusRegularExpressionValidator {
regularExpression: /^[a-zA-Z0-9\-_ ]+$/ regularExpression: /^[a-zA-Z0-9\-_ ]+$/
errorMessage: qsTr("Only letters, numbers, underscores, whitespaces and hyphens allowed") errorMessage: errorMessages.alphanumericalExpandedRegExp
}, },
StatusMinLengthValidator { StatusMinLengthValidator {
minLength: 5 minLength: keypair.nameLengthMin
errorMessage: qsTr("Username must be at least 5 characters") errorMessage: qsTr("Username must be at least %1 characters").arg(keypair.nameLengthMin)
}, },
StatusValidator { StatusValidator {
name: "endsWithSpaceValidator" name: "endsWithSpaceValidator"
@ -489,8 +510,8 @@ QtObject {
// TODO: Create `StatusMaxLengthValidator` in StatusQ // TODO: Create `StatusMaxLengthValidator` in StatusQ
StatusValidator { StatusValidator {
name: "maxLengthValidator" name: "maxLengthValidator"
validate: function (t) { return t.length <= 24 } validate: function (t) { return t.length <= keypair.nameLengthMax }
errorMessage: qsTr("24 character username limit") errorMessage: qsTr("%n character(s) username limit", "", keypair.nameLengthMax)
}, },
StatusValidator { StatusValidator {
name: "endsWith-ethValidator" name: "endsWith-ethValidator"
@ -575,7 +596,7 @@ QtObject {
readonly property int enterSeedPhraseWordsHeight: 60 readonly property int enterSeedPhraseWordsHeight: 60
readonly property int keycardPinLength: 6 readonly property int keycardPinLength: 6
readonly property int keycardPukLength: 12 readonly property int keycardPukLength: 12
readonly property int keycardNameLength: 20 readonly property int keycardNameLength: keypair.nameLengthMax
readonly property int keycardNameInputWidth: 448 readonly property int keycardNameInputWidth: 448
readonly property int keycardPairingCodeInputWidth: 512 readonly property int keycardPairingCodeInputWidth: 512
readonly property int keycardPukAdditionalSpacingOnEvery4Items: 4 readonly property int keycardPukAdditionalSpacingOnEvery4Items: 4