feat(Onboarding) Implement new Login screen

- implement the new UI and frontend logic of the Login screen
- integrate it (as a separate page) into the OnboardingLayout
- add SB pages
- add an integration QML test
- add some TODOs and FIXMEs for the existing and new external flows,
which will be covered separately in followup PRs

Fixes #17057
This commit is contained in:
Lukáš Tinkl 2025-01-10 20:27:11 +01:00 committed by Lukáš Tinkl
parent 24ee6683a2
commit 638676ed0b
29 changed files with 1778 additions and 36 deletions

View File

@ -59,7 +59,7 @@ Item {
id: ctrlDisplayPromo
text: "Promo banner"
checked: true
visible: ctrlKeycardState.currentValue === Onboarding.KeycardState.InsertKeycard
visible: ctrlKeycardState.currentValue === Onboarding.KeycardState.PluginReader
}
ToolButton {
text: "<"

View File

@ -0,0 +1,184 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQuick.Window 2.15
import QtQml.Models 2.15
import StatusQ 0.1
import StatusQ.Core 0.1
import StatusQ.Controls 0.1
import StatusQ.Components 0.1
import StatusQ.Core.Theme 0.1
import Models 1.0
import Storybook 1.0
import AppLayouts.Onboarding.enums 1.0
import AppLayouts.Onboarding2.pages 1.0
import AppLayouts.Onboarding2.stores 1.0
import utils 1.0
SplitView {
id: root
orientation: Qt.Vertical
Logs { id: logs }
OnboardingStore {
id: store
// keycard
property int keycardState: Onboarding.KeycardState.NoPCSCService
property int keycardRemainingPinAttempts: ctrlUnlockWithPuk.checked ? 1 : 5
function setPin(pin: string) { // -> bool
logs.logEvent("OnboardingStore.setPin", ["pin"], arguments)
const valid = pin === ctrlPin.text
if (!valid)
keycardRemainingPinAttempts-- // SIMULATION: decrease the remaining PIN attempts
if (keycardRemainingPinAttempts <= 0) { // SIMULATION: "lock" the keycard
keycardState = Onboarding.KeycardState.Locked
keycardRemainingPinAttempts = ctrlUnlockWithPuk.checked ? 1 : 5
}
return valid
}
// password signals
signal accountLoginError(string error, bool wrongPassword)
// biometrics signals
signal obtainingPasswordSuccess(string password)
signal obtainingPasswordError(string errorDescription, string errorType /* Constants.keychain.errorType.* */, bool wrongFingerprint)
}
LoginScreen {
id: loginScreen
SplitView.fillWidth: true
SplitView.fillHeight: true
loginAccountsModel: LoginAccountsModel {}
onboardingStore: store
biometricsAvailable: ctrlBiometrics.checked
isBiometricsLogin: localAccountSettings.storeToKeychainValue === Constants.keychain.storedValue.store
onBiometricsRequested: biometricsPopup.open()
onLoginRequested: (keyUid, method, data) => {
logs.logEvent("onLoginRequested", ["keyUid", "method", "data"], arguments)
// SIMULATION: emit an error in case of wrong password
if (method === Onboarding.LoginMethod.Password && data.password !== ctrlPassword.text) {
onboardingStore.accountLoginError("The impossible has happened", Math.random() < 0.5)
}
}
onOnboardingCreateProfileFlowRequested: logs.logEvent("onOnboardingCreateProfileFlowRequested")
onOnboardingLoginFlowRequested: logs.logEvent("onOnboardingLoginFlowRequested")
onUnlockWithSeedphraseRequested: logs.logEvent("onUnlockWithSeedphraseRequested")
onUnlockWithPukRequested: logs.logEvent("onUnlockWithPukRequested")
onLostKeycard: logs.logEvent("onLostKeycard")
// mocks
QtObject {
id: localAccountSettings
readonly property string storeToKeychainValue: ctrlTouchIdUser.checked ? Constants.keychain.storedValue.store : ""
}
onSelectedProfileKeyIdChanged: biometricsPopup.visible = Qt.binding(() => ctrlBiometrics.checked && ctrlTouchIdUser.checked)
}
BiometricsPopup {
id: biometricsPopup
visible: ctrlBiometrics.checked && ctrlTouchIdUser.checked
x: root.Window.width - width
password: ctrlPassword.text
pin: ctrlPin.text
onAccountLoginError: (error, wrongPassword) => store.accountLoginError(error, wrongPassword)
onObtainingPasswordSuccess: (password) => store.obtainingPasswordSuccess(password)
onObtainingPasswordError: (errorDescription, errorType, wrongFingerprint) => store.obtainingPasswordError(errorDescription, errorType, wrongFingerprint)
}
LogsAndControlsPanel {
id: logsAndControlsPanel
SplitView.minimumHeight: 180
SplitView.preferredHeight: 180
logsView.logText: logs.logText
ColumnLayout {
anchors.fill: parent
Label {
text: "Selected user ID: %1".arg(loginScreen.selectedProfileKeyId || "N/A")
}
RowLayout {
Layout.fillWidth: true
Label {
text: "Password:\t"
}
TextField {
id: ctrlPassword
text: "0123456789"
placeholderText: "Example password"
selectByMouse: true
}
Switch {
id: ctrlBiometrics
text: "Biometrics available"
checked: true
}
Switch {
id: ctrlTouchIdUser
text: "Touch ID login"
enabled: ctrlBiometrics.checked
checked: ctrlBiometrics.checked
}
Switch {
id: ctrlUnlockWithPuk
text: "Unlock with PUK available"
checked: true
}
}
RowLayout {
Layout.fillWidth: true
Label {
text: "Keycard PIN:\t"
}
TextField {
id: ctrlPin
text: "111111"
inputMask: "999999"
selectByMouse: true
}
Label {
text: "State:"
}
ComboBox {
Layout.preferredWidth: 300
id: ctrlKeycardState
focusPolicy: Qt.NoFocus
textRole: "text"
valueRole: "value"
model: [
{ value: Onboarding.KeycardState.NoPCSCService, text: "NoPCSCService" },
{ value: Onboarding.KeycardState.PluginReader, text: "PluginReader" },
{ value: Onboarding.KeycardState.InsertKeycard, text: "InsertKeycard" },
{ value: Onboarding.KeycardState.ReadingKeycard, text: "ReadingKeycard" },
{ value: Onboarding.KeycardState.WrongKeycard, text: "WrongKeycard" },
{ value: Onboarding.KeycardState.NotKeycard, text: "NotKeycard" },
{ value: Onboarding.KeycardState.MaxPairingSlotsReached, text: "MaxPairingSlotsReached" },
{ value: Onboarding.KeycardState.Locked, text: "Locked" },
{ value: Onboarding.KeycardState.NotEmpty, text: "NotEmpty" },
{ value: Onboarding.KeycardState.Empty, text: "Empty" }
]
onActivated: store.keycardState = currentValue
Component.onCompleted: currentIndex = Qt.binding(() => indexOfValue(store.keycardState))
}
}
}
}
}
// category: Onboarding
// status: good
// https://www.figma.com/design/Lw4nPYQcZOPOwTgETiiIYo/Desktop-Onboarding-Redesign?node-id=801-42615&m=dev

View File

@ -1,8 +1,13 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQuick.Window 2.15
import StatusQ 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Core 0.1
import StatusQ.Controls 0.1
import StatusQ.Components 0.1
import AppLayouts.Onboarding.enums 1.0
import AppLayouts.Onboarding2 1.0
@ -13,6 +18,7 @@ import shared.panels 1.0
import utils 1.0
import Storybook 1.0
import Models 1.0
SplitView {
id: root
@ -26,6 +32,7 @@ SplitView {
readonly property string mnemonic: "dog dog dog dog dog dog dog dog dog dog dog dog"
readonly property var seedWords: ["apple", "banana", "cat", "cow", "catalog", "catch", "category", "cattle", "dog", "elephant", "fish", "grape"]
readonly property string pin: "111111"
readonly property string password: "somepassword"
// TODO simulation
function restart() {
@ -34,6 +41,14 @@ SplitView {
}
}
LoginAccountsModel {
id: loginAccountsModel
}
ListModel {
id: emptyModel
}
OnboardingLayout {
id: onboarding
@ -47,13 +62,18 @@ SplitView {
property int addKeyPairState: Onboarding.AddKeyPairState.InProgress
property int syncState: Onboarding.SyncState.InProgress
property int keycardRemainingPinAttempts: 5
property int keycardRemainingPinAttempts: ctrlUnlockWithPuk.checked ? 1 : 5
function setPin(pin: string) { // -> bool
logs.logEvent("OnboardingStore.setPin", ["pin"], arguments)
ctrlLoginResult.result = "🯄"
const valid = pin === mockDriver.pin
if (!valid)
keycardRemainingPinAttempts--
if (keycardRemainingPinAttempts <= 0) { // SIMULATION: "lock" the keycard
keycardState = Onboarding.KeycardState.Locked
keycardRemainingPinAttempts = ctrlUnlockWithPuk.checked ? 1 : 5
}
return valid
}
@ -95,9 +115,20 @@ SplitView {
function inputConnectionStringForBootstrapping(connectionString: string) { // -> void
logs.logEvent("OnboardingStore.inputConnectionStringForBootstrapping", ["connectionString"], arguments)
}
// password signals
signal accountLoginError(string error, bool wrongPassword)
// biometrics signals
signal obtainingPasswordSuccess(string password)
signal obtainingPasswordError(string errorDescription, string errorType /* Constants.keychain.errorType.* */, bool wrongFingerprint)
}
loginAccountsModel: ctrlLoginScreen.checked ? loginAccountsModel : emptyModel
biometricsAvailable: ctrlBiometrics.checked
isBiometricsLogin: localAccountSettings.storeToKeychainValue === Constants.keychain.storedValue.store
onBiometricsRequested: biometricsPopup.open()
onFinished: (flow, data) => {
console.warn("!!! ONBOARDING FINISHED; flow:", flow, "; data:", JSON.stringify(data))
@ -107,7 +138,27 @@ SplitView {
stack.clear()
stack.push(splashScreen, { runningProgressAnimation: true })
flow.currentKeycardState = Onboarding.KeycardState.NoPCSCService
store.keycardState = Onboarding.KeycardState.NoPCSCService
}
onLoginRequested: (keyUid, method, data) => {
logs.logEvent("onLoginRequested", ["keyUid", "method", "data"], arguments)
// SIMULATION: emit an error in case of wrong password
if (method === Onboarding.LoginMethod.Password && data.password !== mockDriver.password) {
onboardingStore.accountLoginError("The impossible has happened", Math.random() < 0.5)
ctrlLoginResult.result = "<font color='red'>⛔</font>"
} else {
ctrlLoginResult.result = "<font color='green'>✔</font>"
}
}
onReloadKeycardRequested: store.keycardState = Onboarding.KeycardState.NoPCSCService
// mocks
QtObject {
id: localAccountSettings
readonly property string storeToKeychainValue: ctrlTouchIdUser.checked ? Constants.keychain.storedValue.store : ""
}
Button {
@ -118,12 +169,20 @@ SplitView {
anchors.right: parent.right
anchors.margins: 10
visible: onboarding.stack.currentItem instanceof CreatePasswordPage
visible: onboarding.stack.currentItem instanceof CreatePasswordPage ||
(onboarding.stack.currentItem instanceof LoginScreen && !onboarding.stack.currentItem.selectedProfileIsKeycard)
onClicked: {
const password = "somepassword"
const currentItem = onboarding.stack.currentItem
const loginPassInput = StorybookUtils.findChild(
currentItem,
"loginPasswordInput")
if (!!loginPassInput) {
ClipboardUtils.setText(mockDriver.password)
loginPassInput.paste()
}
const input1 = StorybookUtils.findChild(
currentItem,
"passwordViewNewPassword")
@ -134,8 +193,8 @@ SplitView {
if (!input1 || !input2)
return
input1.text = password
input2.text = password
input1.text = mockDriver.password
input2.text = mockDriver.password
}
}
@ -169,7 +228,8 @@ SplitView {
anchors.margins: 10
visible: onboarding.stack.currentItem instanceof KeycardEnterPinPage ||
onboarding.stack.currentItem instanceof KeycardCreatePinPage
onboarding.stack.currentItem instanceof KeycardCreatePinPage ||
(onboarding.stack.currentItem instanceof LoginScreen && onboarding.stack.currentItem.selectedProfileIsKeycard)
text: "Copy valid PIN (\"%1\")".arg(mockDriver.pin)
focusPolicy: Qt.NoFocus
@ -224,6 +284,17 @@ SplitView {
}
}
BiometricsPopup {
id: biometricsPopup
visible: onboarding.stack.currentItem instanceof LoginScreen && ctrlBiometrics.checked && ctrlTouchIdUser.checked
x: root.Window.width - width
password: mockDriver.password
pin: mockDriver.pin
onAccountLoginError: (error, wrongPassword) => store.accountLoginError(error, wrongPassword)
onObtainingPasswordSuccess: (password) => store.obtainingPasswordSuccess(password)
onObtainingPasswordError: (errorDescription, errorType, wrongFingerprint) => store.obtainingPasswordError(errorDescription, errorType, wrongFingerprint)
}
Component {
id: splashScreen
@ -304,6 +375,34 @@ SplitView {
text: "Biometrics available"
checked: true
}
ToolSeparator {}
Switch {
id: ctrlLoginScreen
text: "Show login screen"
checkable: true
onToggled: onboarding.restartFlow()
}
Switch {
id: ctrlTouchIdUser
text: "Touch ID login"
enabled: ctrlBiometrics.checked
checked: ctrlBiometrics.checked
}
Switch {
id: ctrlUnlockWithPuk
text: "Unlock with PUK available"
checked: true
}
Text {
id: ctrlLoginResult
property string result: "🯄"
text: "Login result: %1".arg(result)
}
}
RowLayout {
@ -340,7 +439,10 @@ SplitView {
ButtonGroup.group: keycardStateButtonGroup
onClicked: store.keycardState = modelData.value
onClicked: {
store.keycardState = modelData.value
ctrlLoginResult.result = "🯄"
}
}
}
}

View File

@ -2,6 +2,7 @@ import QtQuick 2.15
import QtTest 1.15
import StatusQ 0.1 // ClipboardUtils
import StatusQ.Core.Theme 0.1
import AppLayouts.Onboarding2 1.0
import AppLayouts.Onboarding2.pages 1.0
@ -12,6 +13,8 @@ import shared.stores 1.0 as SharedStores
import utils 1.0
import Models 1.0
Item {
id: root
@ -29,6 +32,14 @@ Item {
readonly property string dummyNewPassword: "0123456789"
}
LoginAccountsModel {
id: loginAccountsModel
}
ListModel {
id: emptyModel
}
Component {
id: componentUnderTest
@ -39,6 +50,9 @@ Item {
biometricsAvailable: mockDriver.biometricsAvailable
keycardPinInfoPageDelay: 0
loginAccountsModel: emptyModel
isBiometricsLogin: biometricsAvailable
onboardingStore: OnboardingStore {
readonly property int keycardState: mockDriver.keycardState // enum Onboarding.KeycardState
property int keycardRemainingPinAttempts: 5
@ -73,6 +87,19 @@ Item {
return !Number.isNaN(parseInt(connectionString))
}
function inputConnectionStringForBootstrapping(connectionString: string) {}
// password signals
signal accountLoginError(string error, bool wrongPassword)
// biometrics signals
signal obtainingPasswordSuccess(string password)
signal obtainingPasswordError(string errorDescription, string errorType /* Constants.keychain.errorType.* */, bool wrongFingerprint)
}
onLoginRequested: (keyUid, method, data) => {
// SIMULATION: emit an error in case of wrong password
if (method === Onboarding.LoginMethod.Password && data.password !== mockDriver.dummyNewPassword) {
onboardingStore.accountLoginError("An error ocurred, wrong password?", Math.random() < 0.5)
}
}
}
}
@ -99,6 +126,12 @@ Item {
signalName: "finished"
}
SignalSpy {
id: loginSpy
target: controlUnderTest
signalName: "loginRequested"
}
property OnboardingLayout controlUnderTest: null
TestCase {
@ -124,6 +157,7 @@ Item {
mockDriver.existingPin = ""
dynamicSpy.cleanup()
finishedSpy.clear()
loginSpy.clear()
}
function keyClickSequence(keys) {
@ -672,7 +706,7 @@ Item {
compare(dynamicSpy.signalArguments[0][0], data.shareResult)
// PAGE 3: Log in -> Enter recovery phrase
page = getCurrentPage(stack, LoginPage)
page = getCurrentPage(stack, NewAccountLoginPage)
const btnWithSeedphrase = findChild(page, "btnWithSeedphrase")
verify(!!btnWithSeedphrase)
mouseClick(btnWithSeedphrase)
@ -765,7 +799,7 @@ Item {
compare(dynamicSpy.signalArguments[0][0], data.shareResult)
// PAGE 3: Log in
page = getCurrentPage(stack, LoginPage)
page = getCurrentPage(stack, NewAccountLoginPage)
const btnBySyncing = findChild(page, "btnBySyncing")
verify(!!btnBySyncing)
mouseClick(btnBySyncing)
@ -858,7 +892,7 @@ Item {
compare(dynamicSpy.signalArguments[0][0], data.shareResult)
// PAGE 3: Log in -> Login with Keycard
page = getCurrentPage(stack, LoginPage)
page = getCurrentPage(stack, NewAccountLoginPage)
const btnWithKeycard = findChild(page, "btnWithKeycard")
verify(!!btnWithKeycard)
mouseClick(btnWithKeycard)
@ -898,5 +932,170 @@ Item {
compare(resultData.keycardPin, mockDriver.existingPin)
compare(resultData.seedphrase, "")
}
// LOGIN SCREEN
function test_loginScreen_data() {
return [
// password based profile ("uid_1")
{ tag: "correct password", keyUid: "uid_1", password: mockDriver.dummyNewPassword, biometrics: false },
{ tag: "correct password+biometrics", keyUid: "uid_1", password: mockDriver.dummyNewPassword, biometrics: true },
{ tag: "wrong password", keyUid: "uid_1", password: "foobar", biometrics: false },
{ tag: "wrong password+biometrics", keyUid: "uid_1", password: "foobar", biometrics: true },
// keycard based profile ("uid_4")
{ tag: "correct PIN", keyUid: "uid_4", pin: "111111", biometrics: false },
{ tag: "correct PIN+biometrics", keyUid: "uid_4", pin: "111111", biometrics: true },
{ tag: "wrong PIN", keyUid: "uid_4", pin: "123321", biometrics: false },
{ tag: "wrong PIN+biometrics", keyUid: "uid_4", pin: "123321", biometrics: true },
]
}
function test_loginScreen(data) {
verify(!!controlUnderTest)
controlUnderTest.loginAccountsModel = loginAccountsModel
controlUnderTest.biometricsAvailable = data.biometrics // both available _and_ enabled for this profile
controlUnderTest.restartFlow()
mockDriver.existingPin = "111111" // let this be the correct PIN
const page = getCurrentPage(controlUnderTest.stack, LoginScreen)
const userSelector = findChild(page, "loginUserSelector")
verify(!!userSelector)
userSelector.setSelection(data.keyUid) // select the right profile, keycard or regular one (password)
tryCompare(userSelector, "selectedProfileKeyId", data.keyUid)
tryCompare(userSelector, "keycardCreatedAccount", !!data.pin && data.pin !== "")
if (!!data.password) { // regular profile, no keycard
const loginButton = findChild(page, "loginButton")
verify(!!loginButton)
tryCompare(loginButton, "visible", true)
compare(loginButton.enabled, false)
const passwordBox = findChild(page, "passwordBox")
verify(!!passwordBox)
const passwordInput = findChild(page, "loginPasswordInput")
verify(!!passwordInput)
tryCompare(passwordInput, "activeFocus", true)
if (data.biometrics) { // biometrics + password
if (data.password === mockDriver.dummyNewPassword) { // expecting correct fingerprint
// simulate the external biometrics signal
controlUnderTest.onboardingStore.obtainingPasswordSuccess(data.password)
tryCompare(passwordBox, "biometricsSuccessful", true)
tryCompare(passwordBox, "biometricsFailed", false)
tryCompare(passwordBox, "validationError", "")
// this fills the password and submits it, emits the loginRequested() signal below
tryCompare(passwordInput, "text", data.password)
} else { // expecting wrong fingerprint
// simulate the external biometrics signal
controlUnderTest.onboardingStore.obtainingPasswordError("ERROR", Constants.keychain.errorType.keychain, true)
tryCompare(passwordBox, "biometricsSuccessful", false)
tryCompare(passwordBox, "biometricsFailed", true)
tryCompare(passwordBox, "validationError", "Fingerprint not recognised. Try entering password instead.")
// this fails and switches to the password method; so just verify we have an error and can enter the pass manually
tryCompare(passwordInput, "hasError", true)
tryCompare(passwordInput, "activeFocus", true)
tryCompare(passwordInput, "text", "")
expectFail(data.tag, "Wrong fingerprint, expected to fail to login")
}
} else { // manual password
keyClickSequence(data.password)
tryCompare(passwordInput, "text", data.password)
compare(loginButton.enabled, true)
mouseClick(loginButton)
}
// verify the final "loginRequested" signal emission and params
tryCompare(loginSpy, "count", 1)
compare(loginSpy.signalArguments[0][0], data.keyUid)
compare(loginSpy.signalArguments[0][1], Onboarding.LoginMethod.Password)
const resultData = loginSpy.signalArguments[0][2]
verify(!!resultData)
compare(resultData.password, data.password)
// verify validation & pass error
tryCompare(passwordInput, "hasError", data.password !== mockDriver.dummyNewPassword)
} else if (!!data.pin) { // keycard profile
mockDriver.keycardState = Onboarding.KeycardState.NotEmpty // happy path; keycard ready
const pinInput = findChild(page, "pinInput")
verify(!!pinInput)
tryCompare(pinInput, "visible", true)
compare(pinInput.pinInput, "")
const keycardBox = findChild(page, "keycardBox")
verify(!!keycardBox)
if (data.biometrics) { // biometrics + PIN
if (data.pin === mockDriver.existingPin) { // expecting correct fingerprint
// simulate the external biometrics signal
controlUnderTest.onboardingStore.obtainingPasswordSuccess(data.pin)
tryCompare(keycardBox, "biometricsSuccessful", true)
tryCompare(keycardBox, "biometricsFailed", false)
// this fills the password and submits it, emits the loginRequested() signal below
tryCompare(pinInput, "pinInput", data.pin)
} else { // expecting wrong fingerprint
// simulate the external biometrics signal
controlUnderTest.onboardingStore.obtainingPasswordError("Fingerprint not recognized",
Constants.keychain.errorType.keychain, true)
tryCompare(keycardBox, "biometricsSuccessful", false)
tryCompare(keycardBox, "biometricsFailed", true)
// this fails and lets the user enter the PIN manually; so just verify we have an error and empty PIN
tryCompare(pinInput, "pinInput", "")
expectFail(data.tag, "Wrong fingerprint, expected to fail to login")
}
} else { // manual PIN
keyClickSequence(data.pin)
if (data.pin !== mockDriver.existingPin) {
expectFail(data.tag, "Wrong PIN entered, expected to fail to login")
}
}
// verify the final "loginRequested" signal emission and params
tryCompare(loginSpy, "count", 1)
compare(loginSpy.signalArguments[0][0], data.keyUid)
compare(loginSpy.signalArguments[0][1], Onboarding.LoginMethod.Keycard)
const resultData = loginSpy.signalArguments[0][2]
verify(!!resultData)
compare(resultData.pin, data.pin)
}
}
function test_loginScreen_launchesExternalFlow_data() {
return [
{ tag: "onboarding: create profile", delegateName: "createProfileDelegate", signalName: "onboardingCreateProfileFlowRequested", landingPageTitle: "Create profile" },
{ tag: "onboarding: log in", delegateName: "logInDelegate", signalName: "onboardingLoginFlowRequested", landingPageTitle: "Log in"},
// TODO cover also `signal unlockWithSeedphraseRequested()` and `signal lostKeycard()`
]
}
function test_loginScreen_launchesExternalFlow(data) {
verify(!!controlUnderTest)
controlUnderTest.loginAccountsModel = loginAccountsModel
controlUnderTest.restartFlow()
const page = getCurrentPage(controlUnderTest.stack, LoginScreen)
const loginUserSelector = findChild(page, "loginUserSelector")
verify(!!loginUserSelector)
mouseClick(loginUserSelector)
const dropdown = findChild(loginUserSelector, "dropdown")
verify(!!dropdown)
tryCompare(dropdown, "opened", true)
const menuDelegate = findChild(dropdown, data.delegateName)
verify(!!menuDelegate)
dynamicSpy.setup(page, data.signalName)
mouseClick(menuDelegate)
tryCompare(dynamicSpy, "count", 1)
tryCompare(controlUnderTest.stack.currentItem, "title", data.landingPageTitle)
}
}
}

View File

@ -0,0 +1,46 @@
import QtQuick 2.15
import QtQml.Models 2.15
import StatusQ.Core.Theme 0.1
ListModel {
readonly property var data: [
{
order: 2,
keycardCreatedAccount: false,
colorId: 1,
colorHash: [{colorId: 3, segmentLength: 2}, {colorId: 7, segmentLength: 1}, {colorId: 4, segmentLength: 2}],
username: "Bob",
thumbnailImage: Theme.png("collectibles/Doodles"),
keyUid: "uid_1"
},
{
order: 1,
keycardCreatedAccount: false,
colorId: 2,
colorHash: [{colorId: 9, segmentLength: 1}, {colorId: 7, segmentLength: 3}, {colorId: 10, segmentLength: 2}],
username: "John",
thumbnailImage: Theme.png("collectibles/CryptoPunks"),
keyUid: "uid_2"
},
{
order: 3,
keycardCreatedAccount: true,
colorId: 3,
colorHash: [],
username: "8⃣6⃣.eth",
thumbnailImage: "",
keyUid: "uid_4"
},
{
order: 4,
keycardCreatedAccount: true,
colorId: 4,
colorHash: [{colorId: 2, segmentLength: 4}, {colorId: 6, segmentLength: 3}, {colorId: 11, segmentLength: 1}],
username: "Very long username that should eventually elide on the right side",
thumbnailImage: Theme.png("collectibles/SuperRare"),
keyUid: "uid_3"
}
]
Component.onCompleted: append(data)
}

View File

@ -8,6 +8,7 @@ FeesModel 1.0 FeesModel.qml
FlatTokensModel 1.0 FlatTokensModel.qml
IconModel 1.0 IconModel.qml
LinkPreviewModel 1.0 LinkPreviewModel.qml
LoginAccountsModel 1.0 LoginAccountsModel.qml
MintedTokensModel 1.0 MintedTokensModel.qml
ManageCollectiblesModel 1.0 ManageCollectiblesModel.qml
RecipientModel 1.0 RecipientModel.qml

View File

@ -0,0 +1,94 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ 0.1
import StatusQ.Core 0.1
import StatusQ.Controls 0.1
import StatusQ.Components 0.1
import StatusQ.Core.Theme 0.1
import utils 1.0
Dialog {
id: root
required property string password
required property string pin
// password signals
signal accountLoginError(string error, bool wrongPassword)
// biometrics signals
signal obtainingPasswordSuccess(string password)
signal obtainingPasswordError(string errorDescription, string errorType /* Constants.keychain.errorType.* */, bool wrongFingerprint)
width: 300
margins: 40
closePolicy: Popup.NoAutoClose
contentItem: ColumnLayout {
spacing: 10
StatusIcon {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: 40
Layout.preferredHeight: 40
icon: "touch-id"
color: Theme.palette.baseColor1
}
Label {
Layout.fillWidth: true
horizontalAlignment: Qt.AlignHCenter
text: "Status Desktop"
font.pixelSize: 20
}
Label {
Layout.fillWidth: true
horizontalAlignment: Qt.AlignHCenter
text: "Status Desktop is trying to authenticate you.\n\nTouch ID or enter your password to allow this."
}
StatusButton {
Layout.alignment: Qt.AlignHCenter
type: StatusBaseButton.Type.Primary
focusPolicy: Qt.NoFocus
text: "Use password..."
onClicked: {
root.close()
root.obtainingPasswordError("Password required instead of touch ID.", Constants.keychain.errorType.keychain, false)
}
}
StatusButton {
Layout.alignment: Qt.AlignHCenter
focusPolicy: Qt.NoFocus
text: "Cancel"
onClicked: {
root.close()
root.obtainingPasswordError("Touch ID canceled, try entering password instead.", Constants.keychain.errorType.keychain, false)
}
}
Item { Layout.preferredHeight: 20 }
StatusButton {
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
type: StatusBaseButton.Type.Success
focusPolicy: Qt.NoFocus
text: "Simulate correct fingerprint"
onClicked: {
root.close()
root.obtainingPasswordSuccess(loginScreen.selectedProfileIsKeycard ? root.pin : root.password)
}
}
StatusButton {
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
type: StatusBaseButton.Type.Danger
focusPolicy: Qt.NoFocus
text: "Simulate wrong fingerprint"
onClicked: {
root.close()
root.obtainingPasswordError("Wrong fingerprint provided.", Constants.keychain.errorType.keychain, true)
}
}
}
}

View File

@ -1,3 +1,4 @@
BiometricsPopup 1.0 BiometricsPopup.qml
CheckBoxFlowSelector 1.0 CheckBoxFlowSelector.qml
CompilationErrorsBox 1.0 CompilationErrorsBox.qml
FigmaImagesProxyModel 1.0 FigmaImagesProxyModel.qml

View File

@ -32,10 +32,10 @@ QC.Popup {
id: root
dim: false
closePolicy: QC.Popup.CloseOnPressOutside | QC.Popup.CloseOnEscape
background: Rectangle {
color: Theme.palette.statusMenu.backgroundColor
radius: 8
radius: Theme.radius
border.color: "transparent"
layer.enabled: true
layer.effect: DropShadow {
@ -50,7 +50,7 @@ QC.Popup {
}
// workaround for https://bugreports.qt.io/browse/QTBUG-87804
Binding on margins{
Binding on margins {
id: workaroundBinding
when: false

View File

@ -35,6 +35,8 @@ StatusTextField {
*/
property string signingPhrase: ""
property bool hasError
QtObject {
id: d
@ -44,7 +46,6 @@ StatusTextField {
readonly property int signingPhraseWordPadding: 8
readonly property int signingPhraseWordsSpacing: 8
readonly property int signingPhraseWordsHeight: 30
}
leftPadding: d.inputTextPadding
@ -57,13 +58,12 @@ StatusTextField {
selectByMouse: true
echoMode: TextInput.Password
color: Theme.palette.directColor1
background: Rectangle {
color: Theme.palette.baseColor2
radius: d.radius
border.width: root.focus ? 1 : 0
border.color: Theme.palette.primaryColor1
border.width: root.focus || root.hasError ? 1 : 0
border.color: root.hasError ? Theme.palette.dangerColor1 : Theme.palette.primaryColor1
}
RowLayout {

View File

@ -1,5 +1,6 @@
import QtQuick 2.15
import StatusQ 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Controls.Validators 0.1
@ -99,6 +100,8 @@ Item {
*/
property int additionalSpacing: 0
signal pinEditedManually()
QtObject {
id: d
property int currentPinIndex: 0
@ -226,9 +229,15 @@ Item {
// Some component validations:
if(text.length !== d.currentPinIndex)
console.error("StatusPinInput input management error. Current pin length must be "+ text.length + "and is " + d.currentPinIndex)
console.error("StatusPinInput input management error. Current pin length must be "+ text.length + " and is " + d.currentPinIndex)
}
onFocusChanged: { if(!focus) { d.deactivateBlink () } }
onTextEdited: root.pinEditedManually()
Keys.onShortcutOverride: event.accepted = event.matches(StandardKey.Paste)
Keys.onPressed: if (event.matches(StandardKey.Paste)) {
root.setPin(ClipboardUtils.text)
}
}
// Pin input visual objects:

View File

@ -25,6 +25,11 @@ public:
LoginWithKeycard
};
enum class LoginMethod {
Password,
Keycard,
};
enum class KeycardState {
NoPCSCService,
PluginReader,
@ -56,6 +61,7 @@ public:
private:
Q_ENUM(PrimaryFlow)
Q_ENUM(SecondaryFlow)
Q_ENUM(LoginMethod)
Q_ENUM(KeycardState)
Q_ENUM(AddKeyPairState)
Q_ENUM(SyncState)

View File

@ -7,8 +7,6 @@ import StatusQ.Core.Backpressure 0.1
import AppLayouts.Onboarding2.pages 1.0
import AppLayouts.Onboarding.enums 1.0
SQUtils.QObject {
id: root
@ -33,6 +31,8 @@ SQUtils.QObject {
signal reloadKeycardRequested
signal createProfileWithoutKeycardRequested
signal mnemonicWasShown()
signal mnemonicRemovalRequested()
signal finished(bool fromBackupSeedphrase)
function init() {
@ -121,8 +121,8 @@ SQUtils.QObject {
BackupSeedphraseReveal {
seedWords: root.seedWords
onBackupSeedphraseConfirmed:
root.stackView.push(backupSeedVerifyPage)
onMnemonicWasShown: root.mnemonicWasShown()
onBackupSeedphraseConfirmed: root.stackView.push(backupSeedVerifyPage)
}
}
@ -144,8 +144,10 @@ SQUtils.QObject {
id: backupSeedOutroPage
BackupSeedphraseOutro {
onBackupSeedphraseRemovalConfirmed:
onBackupSeedphraseRemovalConfirmed: {
root.mnemonicRemovalRequested()
root.stackView.push(keycardCreatePinPage)
}
}
}

View File

@ -41,12 +41,23 @@ SQUtils.QObject {
signal keycardFactoryResetRequested
signal keyPairTransferRequested
signal mnemonicWasShown()
signal mnemonicRemovalRequested()
signal finished(int flow)
function init() {
root.stackView.push(welcomePage)
}
function startCreateProfileFlow() {
root.stackView.push(createProfilePage)
}
function startLoginFlow() {
root.stackView.push(loginPage)
}
QtObject {
id: d
@ -115,7 +126,7 @@ SQUtils.QObject {
Component {
id: loginPage
LoginPage {
NewAccountLoginPage {
networkChecksEnabled: root.networkChecksEnabled
onLoginWithSyncingRequested: logInBySyncingFlow.init()
@ -170,6 +181,7 @@ SQUtils.QObject {
onKeyPairTransferRequested: root.keyPairTransferRequested()
onKeycardPinCreated: (pin) => root.keycardPinCreated(pin)
onLoginWithKeycardRequested: loginWithKeycardFlow.init()
onKeypairAddTryAgainRequested: root.keyPairTransferRequested() // FIXME?
onCreateProfileWithoutKeycardRequested: {
const page = stackView.find(
@ -178,6 +190,9 @@ SQUtils.QObject {
stackView.replace(page, createProfilePage, StackView.PopTransition)
}
onMnemonicWasShown: root.mnemonicWasShown()
onMnemonicRemovalRequested: root.mnemonicRemovalRequested()
onSeedphraseSubmitted: (seedphrase) => root.seedphraseSubmitted(seedphrase)
onFinished: (fromBackupSeedphrase) => {

View File

@ -2,6 +2,7 @@ import QtQuick 2.15
import QtQuick.Controls 2.15
import Qt.labs.settings 1.1
import StatusQ 0.1
import StatusQ.Controls 0.1
import StatusQ.Core.Theme 0.1
@ -16,7 +17,14 @@ Page {
required property OnboardingStore onboardingStore
// [{keyUid:string, username:string, thumbnailImage:string, colorId:int, colorHash:var, order:int, keycardCreatedAccount:bool}]
// NB: this also decides whether we show the Login screen (if not empty), or the Onboarding
required property var loginAccountsModel
property bool biometricsAvailable: Qt.platform.os === Constants.mac
required property bool isBiometricsLogin // FIXME should come from the loginAccountsModel for each profile separately?
signal biometricsRequested() // emitted when the user wants to try the biometrics prompt again
property bool networkChecksEnabled: true
property alias keycardPinInfoPageDelay: onboardingFlow.keycardPinInfoPageDelay
@ -27,17 +35,24 @@ Page {
// flow: Onboarding.SecondaryFlow
signal finished(int flow, var data)
// -> "keyUid:string": User ID to login; "method:int": password or keycard (cf Onboarding.LoginMethod.*) enum;
// "data:var": contains "password" or "pin"
signal loginRequested(string keyUid, int method, var data)
signal reloadKeycardRequested()
function restartFlow() {
stack.clear()
d.resetState()
d.settings.reset()
onboardingFlow.init()
unload()
if (loginAccountsModel.ModelCount.empty)
onboardingFlow.init()
else
stack.push(loginScreenComponent)
}
function unload() {
stack.clear()
d.resetState()
d.settings.reset()
}
QtObject {
@ -155,12 +170,35 @@ Page {
onKeyPairTransferRequested: root.onboardingStore.startKeypairTransfer()
onShareUsageDataRequested: (enabled) => root.shareUsageDataRequested(enabled)
onReloadKeycardRequested: root.reloadKeycardRequested()
onMnemonicWasShown: root.onboardingStore.mnemonicWasShown()
onMnemonicRemovalRequested: root.onboardingStore.removeMnemonic()
onSyncProceedWithConnectionString: (connectionString) =>
root.onboardingStore.inputConnectionStringForBootstrapping(connectionString)
onSeedphraseSubmitted: (seedphrase) => d.seedphrase = seedphrase
onSetPasswordRequested: (password) => d.password = password
onEnableBiometricsRequested: (enabled) => d.enableBiometrics = enabled
onFinished: (flow) => d.finishFlow(flow)
onKeycardFactoryResetRequested: ; // TODO invoke external popup and finish the flow
}
Component {
id: loginScreenComponent
LoginScreen {
onboardingStore: root.onboardingStore
loginAccountsModel: root.loginAccountsModel
biometricsAvailable: root.biometricsAvailable
isBiometricsLogin: root.isBiometricsLogin
onBiometricsRequested: root.biometricsRequested()
onLoginRequested: (keyUid, method, data) => root.loginRequested(keyUid, method, data)
onOnboardingCreateProfileFlowRequested: onboardingFlow.startCreateProfileFlow()
onOnboardingLoginFlowRequested: onboardingFlow.startLoginFlow()
onUnlockWithSeedphraseRequested: console.warn("!!! FIXME onUnlockWithSeedphraseRequested")
onUnlockWithPukRequested: console.warn("!!! FIXME onUnlockWithPukRequested")
onLostKeycard: console.warn("!!! FIXME onLostKeycard flow")
}
}
Connections {
@ -175,5 +213,5 @@ Page {
}
}
Component.onCompleted: root.restartFlow()
Component.onCompleted: restartFlow()
}

View File

@ -0,0 +1,229 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ.Core 0.1
import StatusQ.Controls 0.1
import StatusQ.Controls.Validators 0.1
import StatusQ.Core.Theme 0.1
import AppLayouts.Onboarding.enums 1.0
import AppLayouts.Onboarding2.controls 1.0
Control {
id: root
required property int keycardState
property var tryToSetPinFunction: (pin) => { console.error("LoginKeycardBox::tryToSetPinFunction: IMPLEMENT ME"); return false }
required property int keycardRemainingPinAttempts
required property bool isBiometricsLogin
required property bool biometricsSuccessful
required property bool biometricsFailed
signal biometricsRequested()
signal pinEditedManually()
signal loginRequested(string pin)
signal unlockWithSeedphraseRequested()
signal unlockWithPukRequested()
function clear() {
d.wrongPin = false
pinInputField.statesInitialization()
pinInputField.forceFocus()
}
function setPin(pin: string) {
pinInputField.setPin(pin)
}
padding: 12
QtObject {
id: d
property bool wrongPin
}
background: Rectangle {
color: "transparent"
border.width: 1
border.color: Theme.palette.baseColor2
radius: Theme.radius
}
contentItem: ColumnLayout {
spacing: 12
LoginTouchIdIndicator {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Theme.halfPadding
id: touchIdIcon
visible: false
success: root.biometricsSuccessful
error: root.biometricsFailed
onClicked: root.biometricsRequested()
}
StatusBaseText {
Layout.fillWidth: true
id: infoText
horizontalAlignment: Qt.AlignHCenter
elide: Text.ElideRight
color: Theme.palette.baseColor1
}
Column {
id: lockedButtons
Layout.fillWidth: true
spacing: 12
visible: false
MaybeOutlineButton {
id: btnUnlockWithPuk
width: parent.width
visible: root.keycardRemainingPinAttempts === 1 || root.keycardRemainingPinAttempts === 2
text: qsTr("Unlock with PUK")
onClicked: root.unlockWithPukRequested()
}
MaybeOutlineButton {
id: btnUnlockWithSeedphrase
width: parent.width
text: qsTr("Unlock with recovery phrase")
onClicked: root.unlockWithSeedphraseRequested()
}
}
StatusPinInput {
Layout.alignment: Qt.AlignHCenter
id: pinInputField
objectName: "pinInput"
validator: StatusIntValidator { bottom: 0; top: 999999 }
visible: false
onPinInputChanged: {
if (pinInput.length === 6) {
if (root.tryToSetPinFunction(pinInput)) {
root.loginRequested(pinInput)
d.wrongPin = false
} else {
d.wrongPin = true
pinInputField.statesInitialization()
pinInputField.forceFocus()
}
}
}
onPinEditedManually: {
d.wrongPin = false
root.pinEditedManually()
}
}
}
states: [
// normal/intro states
State {
name: "plugin"
when: root.keycardState === Onboarding.KeycardState.PluginReader ||
root.keycardState === -1
PropertyChanges {
target: infoText
text: qsTr("Plug in Keycard reader...")
}
},
State {
name: "insert"
when: root.keycardState === Onboarding.KeycardState.InsertKeycard
PropertyChanges {
target: infoText
text: qsTr("Insert your Keycard...")
}
},
State {
name: "reading"
when: root.keycardState === Onboarding.KeycardState.ReadingKeycard
PropertyChanges {
target: infoText
text: qsTr("Reading Keycard...")
}
},
// error states
State {
name: "notKeycard"
when: root.keycardState === Onboarding.KeycardState.NotKeycard
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
text: qsTr("Oops this isnt a Keycard.<br>Remove card and insert a Keycard.")
}
},
State {
name: "wrongKeycard"
when: root.keycardState === Onboarding.KeycardState.WrongKeycard ||
root.keycardState === Onboarding.KeycardState.MaxPairingSlotsReached
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
text: qsTr("Wrong Keycard for this profile inserted.<br>Remove card and insert the correct one.")
}
},
State {
name: "noService"
when: root.keycardState === Onboarding.KeycardState.NoPCSCService
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
text: qsTr("Smartcard reader service unavailable")
}
},
State {
name: "locked"
when: root.keycardState === Onboarding.KeycardState.Locked
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
text: qsTr("Keycard locked")
}
PropertyChanges {
target: lockedButtons
visible: true
}
},
State {
name: "empty"
when: root.keycardState === Onboarding.KeycardState.Empty
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
text: qsTr("The inserted Keycard is empty.<br>Remove card and insert the correct one.")
}
},
State {
name: "wrongPin"
extend: "notEmpty"
when: root.keycardState === Onboarding.KeycardState.NotEmpty && d.wrongPin
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
text: qsTr("PIN incorrect. %n attempt(s) remaining.", "", root.keycardRemainingPinAttempts)
}
},
// exit states
State {
name: "notEmpty"
when: root.keycardState === Onboarding.KeycardState.NotEmpty && !d.wrongPin
PropertyChanges {
target: infoText
text: qsTr("Enter Keycard PIN")
}
PropertyChanges {
target: pinInputField
visible: true
}
StateChangeScript {
script: {
pinInputField.forceFocus()
}
}
PropertyChanges {
target: touchIdIcon
visible: root.isBiometricsLogin
}
}
]
}

View File

@ -0,0 +1,205 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQml.Models 2.15
import StatusQ 0.1
import StatusQ.Core 0.1
import StatusQ.Controls 0.1
import StatusQ.Core.Backpressure 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Popups 0.1
import StatusQ.Popups.Dialog 0.1
import AppLayouts.Onboarding2.controls 1.0
Control {
id: root
required property bool isBiometricsLogin
required property bool biometricsSuccessful
required property bool biometricsFailed
property string validationError
property string detailedError
onValidationErrorChanged: if (!validationError) detailedError = ""
property alias password: txtPassword.text
signal passwordEditedManually()
signal biometricsRequested()
signal loginRequested(string password)
function clear() {
txtPassword.clear()
}
function forceActiveFocus() {
txtPassword.forceActiveFocus()
}
padding: 0
background: null
spacing: Theme.halfPadding
contentItem: ColumnLayout {
spacing: root.spacing
LoginPasswordInput {
Layout.fillWidth: true
id: txtPassword
objectName: "loginPasswordInput"
isBiometricsLogin: root.isBiometricsLogin
biometricsSuccessful: root.biometricsSuccessful
biometricsFailed: root.biometricsFailed
hasError: !!root.validationError
onTextEdited: root.passwordEditedManually()
onBiometricsRequested: root.biometricsRequested()
onAccepted: root.loginRequested(text)
}
StatusBaseText {
Layout.fillWidth: true
elide: Text.ElideRight
text: root.validationError
color: Theme.palette.dangerColor1
horizontalAlignment: Qt.AlignRight
font.pixelSize: Theme.tertiaryTextFontSize
linkColor: hoveredLink ? Theme.palette.hoverColor(color) : color
HoverHandler {
cursorShape: !!parent.hoveredLink ? Qt.PointingHandCursor : undefined
}
onLinkActivated: (link) => {
if (link.startsWith("#password"))
forgottenPassInstructionsPopupComp.createObject(root).open()
else
detailedErrorPopupComp.createObject(root).open()
}
}
StatusButton {
Layout.topMargin: 20
Layout.alignment: Qt.AlignHCenter
Layout.preferredHeight: 44
objectName: "loginButton"
text: qsTr("Log In")
enabled: {
if (root.isBiometricsLogin && root.biometricsSuccessful)
return false
return txtPassword.text !== ""
}
onClicked: root.loginRequested(txtPassword.text)
}
}
Component {
id: forgottenPassInstructionsPopupComp
StatusDialog {
width: 480
padding: 20
title: qsTr("Forgot your password?")
destroyOnClose: true
standardButtons: Dialog.Ok
contentItem: ColumnLayout {
spacing: 20
StatusBaseText {
Layout.fillWidth: true
text: qsTr("To recover your password follow these steps:")
}
OnboardingFrame {
Layout.fillWidth: true
cornerRadius: Theme.radius
padding: Theme.padding
dropShadow: false
contentItem: ColumnLayout {
spacing: 4
StatusBaseText {
Layout.fillWidth: true
wrapMode: Text.Wrap
text: qsTr("1. Remove the Status app")
font.weight: Font.DemiBold
}
StatusBaseText {
Layout.fillWidth: true
Layout.leftMargin: Theme.padding
wrapMode: Text.Wrap
text: qsTr("This will erase all of your data from the device, including your password")
}
StatusBaseText {
Layout.fillWidth: true
Layout.topMargin: 20
wrapMode: Text.Wrap
text: qsTr("2. Reinstall the Status app")
font.weight: Font.DemiBold
}
StatusBaseText {
Layout.fillWidth: true
Layout.leftMargin: Theme.padding
wrapMode: Text.Wrap
text: qsTr("Re-download the app from %1 %2").arg("<a href='#'>status.app</a>").arg("🔗")
linkColor: !!hoveredLink ? Theme.palette.primaryColor1 : color
onLinkActivated: Qt.openUrlExternally("https://status.app")
HoverHandler {
cursorShape: !!parent.hoveredLink ? Qt.PointingHandCursor : undefined
}
}
StatusBaseText {
Layout.fillWidth: true
Layout.topMargin: 20
wrapMode: Text.Wrap
text:qsTr("3. Sign up with your existing keys")
font.weight: Font.DemiBold
}
StatusBaseText {
Layout.fillWidth: true
Layout.leftMargin: Theme.padding
wrapMode: Text.Wrap
text: qsTr("Access with your recovery phrase or Keycard")
}
StatusBaseText {
Layout.fillWidth: true
Layout.topMargin: 20
wrapMode: Text.Wrap
text: qsTr("4. Create a new password")
font.weight: Font.DemiBold
}
StatusBaseText {
Layout.fillWidth: true
Layout.leftMargin: Theme.padding
wrapMode: Text.Wrap
text: qsTr("Enter a new password and youre all set! You will be able to use your new password")
}
}
}
}
}
}
Component {
id: detailedErrorPopupComp
StatusSimpleTextPopup {
objectName: "passwordDetailsPopup"
title: qsTr("Login failed")
width: 480
destroyOnClose: true
content.color: Theme.palette.dangerColor1
content.text: root.detailedError
footer: StatusDialogFooter {
spacing: Theme.padding
rightButtons: ObjectModel {
StatusFlatButton {
icon.name: "copy"
text: qsTr("Copy error message")
onClicked: {
icon.name = "tiny/checkmark"
ClipboardUtils.setText(root.detailedError)
Backpressure.debounce(this, 1500, () => icon.name = "copy")()
}
}
StatusButton {
text: qsTr("Close")
onClicked: close()
}
}
}
}
}
}

View File

@ -1,3 +1,5 @@
NewsCarousel 1.0 NewsCarousel.qml
SeedphraseVerifyInput 1.0 SeedphraseVerifyInput.qml
StepIndicator 1.0 StepIndicator.qml
LoginKeycardBox 1.0 LoginKeycardBox.qml
LoginPasswordBox 1.0 LoginPasswordBox.qml

View File

@ -0,0 +1,65 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ.Core 0.1
import StatusQ.Controls 0.1
import StatusQ.Core.Theme 0.1
StatusPasswordInput {
id: root
required property bool isBiometricsLogin
required property bool biometricsSuccessful
required property bool biometricsFailed
signal biometricsRequested()
rightPadding: iconsLayout.width + iconsLayout.anchors.rightMargin
placeholderText: qsTr("Password")
echoMode: d.showPassword ? TextInput.Normal : TextInput.Password
QtObject {
id: d
property bool showPassword
}
RowLayout {
id: iconsLayout
anchors.right: parent.right
anchors.rightMargin: Theme.halfPadding
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.halfPadding
StatusIcon {
id: showPasswordButton
visible: root.text !== ""
icon: d.showPassword ? "hide" : "show"
color: hhandler.hovered ? Theme.palette.primaryColor1 : Theme.palette.baseColor1
HoverHandler {
id: hhandler
cursorShape: hovered ? Qt.PointingHandCursor : undefined
}
TapHandler {
onSingleTapped: d.showPassword = !d.showPassword
}
StatusToolTip {
text: d.showPassword ? qsTr("Hide password") : qsTr("Reveal password")
visible: hhandler.hovered
}
}
Rectangle {
Layout.preferredWidth: 1
Layout.preferredHeight: 28
color: Theme.palette.directColor7
visible: showPasswordButton.visible && touchIdIndicator.visible
}
LoginTouchIdIndicator {
id: touchIdIndicator
visible: root.isBiometricsLogin
success: root.biometricsSuccessful
error: root.biometricsFailed
onClicked: root.biometricsRequested()
}
}
}

View File

@ -0,0 +1,33 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import StatusQ.Core 0.1
import StatusQ.Controls 0.1
import StatusQ.Core.Theme 0.1
StatusIcon {
id: root
property bool success
property bool error
signal clicked()
icon: "touch-id"
color: root.success ? Theme.palette.successColor1
: root.error ? Theme.palette.dangerColor1
: hhandler.hovered ? Theme.palette.primaryColor1 : Theme.palette.baseColor1
HoverHandler {
id: hhandler
cursorShape: hovered ? Qt.PointingHandCursor : undefined
}
TapHandler {
onSingleTapped: root.clicked()
}
StatusToolTip {
text: root.success ? qsTr("Biometrics successful")
: root.error ? qsTr("Biometrics failed")
: qsTr("Request biometrics prompt again")
visible: hhandler.hovered
}
}

View File

@ -0,0 +1,158 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ 0.1
import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Controls 0.1
import StatusQ.Popups 0.1
import SortFilterProxyModel 0.2
Control {
id: root
// [{keyUid:string, username:string, thumbnailImage:string, colorId:int, colorHash:var, order:int, keycardCreatedAccount:bool}]
required property var model
required property bool currentKeycardLocked
readonly property string selectedProfileKeyId: currentEntry.value
readonly property bool keycardCreatedAccount: currentEntry.available ? currentEntry.item.keycardCreatedAccount : false
signal onboardingCreateProfileFlowRequested()
signal onboardingLoginFlowRequested()
function setSelection(keyUid: string) {
currentEntry.value = keyUid === "" ? (proxyModel.get(0, "keyUid") ?? "") : keyUid
}
QtObject {
id: d
readonly property int maxPopupHeight: 300
readonly property int delegateHeight: 64
}
ModelEntry {
id: currentEntry
sourceModel: root.model
key: "keyUid"
value: ""
}
contentItem: LoginUserSelectorDelegate {
id: userSelectorButton
states: [
State {
when: currentEntry.available
PropertyChanges {
target: userSelectorButton
label: currentEntry.item.username
image: currentEntry.item.thumbnailImage
colorHash: currentEntry.item.colorHash
colorId: currentEntry.item.colorId
keycardCreatedAccount: currentEntry.item.keycardCreatedAccount
keycardLocked: root.currentKeycardLocked
}
}
]
background: Rectangle {
color: userSelectorButton.hovered ? Theme.palette.baseColor2 : "transparent"
border.width: 1
border.color: Theme.palette.baseColor2
radius: Theme.radius
}
rightPadding: spacing + Theme.padding + chevronIcon.width
StatusIcon {
id: chevronIcon
anchors.right: parent.right
anchors.rightMargin: Theme.padding
anchors.verticalCenter: parent.verticalCenter
icon: "chevron-down"
color: Theme.palette.baseColor1
}
onClicked: dropdown.opened ? dropdown.close() : dropdown.open()
}
StatusDropdown {
id: dropdown
objectName: "dropdown"
closePolicy: Popup.CloseOnPressOutsideParent | Popup.CloseOnEscape
y: parent.height + 2
width: root.width
verticalPadding: Theme.halfPadding
horizontalPadding: 0
contentItem: ColumnLayout {
spacing: 0
StatusListView {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.maximumHeight: d.maxPopupHeight
id: userSelectorPanel
model: SortFilterProxyModel {
id: proxyModel
sourceModel: root.model
sorters: RoleSorter {
roleName: "order"
}
filters: ValueFilter { // don't show the currently selected item
roleName: "keyUid"
value: root.selectedProfileKeyId
inverted: true
}
}
implicitHeight: contentHeight
spacing: 0
delegate: LoginUserSelectorDelegate {
width: ListView.view.width
height: d.delegateHeight
label: model.username
image: model.thumbnailImage
colorId: model.colorId
colorHash: model.colorHash
keycardCreatedAccount: model.keycardCreatedAccount
onClicked: {
dropdown.close()
root.setSelection(model.keyUid)
}
}
}
StatusMenuSeparator {
Layout.fillWidth: true
}
LoginUserSelectorDelegate {
Layout.fillWidth: true
Layout.preferredHeight: d.delegateHeight
objectName: "createProfileDelegate"
label: qsTr("Create profile")
image: "add"
isAction: true
onClicked: {
dropdown.close()
root.onboardingCreateProfileFlowRequested()
}
}
LoginUserSelectorDelegate {
Layout.fillWidth: true
Layout.preferredHeight: d.delegateHeight
objectName: "logInDelegate"
label: qsTr("Log in")
image: "profile"
isAction: true
onClicked: {
dropdown.close()
root.onboardingLoginFlowRequested()
}
}
}
}
}

View File

@ -0,0 +1,81 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Components 0.1
import StatusQ.Popups 0.1
import StatusQ.Core.Utils 0.1 as StatusQUtils
import shared.controls.chat 1.0
import utils 1.0
ItemDelegate {
id: root
property string label
property int colorId
property var colorHash
property string image
property bool keycardCreatedAccount
property bool keycardLocked
property bool isAction
verticalPadding: 12
leftPadding: Theme.padding
rightPadding: Theme.bigPadding
spacing: Theme.padding
background: Rectangle {
color: root.hovered || root.highlighted ? Theme.palette.statusSelect.menuItemHoverBackgroundColor
: "transparent"
}
contentItem: RowLayout {
spacing: root.spacing
Loader {
id: userImageOrIcon
sourceComponent: root.isAction ? actionIcon : userImage
}
Component {
id: actionIcon
StatusRoundIcon {
asset.name: root.image
}
}
Component {
id: userImage
UserImage {
name: root.label
image: root.image
colorId: root.colorId
colorHash: root.colorHash
imageHeight: Constants.onboarding.userImageHeight
imageWidth: Constants.onboarding.userImageWidth
}
}
StatusBaseText {
Layout.fillWidth: true
text: StatusQUtils.Emoji.parse(root.label)
color: root.isAction ? Theme.palette.primaryColor1 : Theme.palette.directColor1
elide: Text.ElideRight
}
Loader {
id: keycardIcon
active: root.keycardCreatedAccount
sourceComponent: StatusIcon {
icon: "keycard"
color: root.keycardLocked ? Theme.palette.dangerColor1 : Theme.palette.baseColor1
}
}
}
HoverHandler {
cursorShape: root.enabled ? Qt.PointingHandCursor : undefined
}
}

View File

@ -3,3 +3,7 @@ OnboardingButtonFrame 1.0 OnboardingButtonFrame.qml
OnboardingFrame 1.0 OnboardingFrame.qml
ListItemButton 1.0 ListItemButton.qml
MaybeOutlineButton 1.0 MaybeOutlineButton.qml
LoginUserSelector 1.0 LoginUserSelector.qml
LoginUserSelectorDelegate 1.0 LoginUserSelectorDelegate.qml
LoginPasswordInput 1.0 LoginPasswordInput.qml
LoginTouchIdIndicator 1.0 LoginTouchIdIndicator.qml

View File

@ -15,6 +15,7 @@ OnboardingPage {
required property var seedWords
signal mnemonicWasShown()
signal backupSeedphraseConfirmed()
QtObject {
@ -100,7 +101,10 @@ OnboardingPage {
icon.name: "show"
type: StatusBaseButton.Type.Primary
visible: !d.seedphraseRevealed
onClicked: d.seedphraseRevealed = true
onClicked: {
d.seedphraseRevealed = true
root.mnemonicWasShown()
}
}
}

View File

@ -15,7 +15,7 @@ import utils 1.0
OnboardingPage {
id: root
title: qsTr("Create your profile")
title: qsTr("Create profile")
signal createProfileWithPasswordRequested()
signal createProfileWithSeedphraseRequested()

View File

@ -0,0 +1,249 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Qt.labs.settings 1.1
import StatusQ.Core 0.1
import StatusQ.Controls 0.1
import StatusQ.Components 0.1
import StatusQ.Core.Theme 0.1
import AppLayouts.Onboarding.enums 1.0
import AppLayouts.Onboarding2.stores 1.0
import AppLayouts.Onboarding2.controls 1.0
import AppLayouts.Onboarding2.components 1.0
import utils 1.0
OnboardingPage {
id: root
required property OnboardingStore onboardingStore
// [{keyUid:string, username:string, thumbnailImage:string, colorId:int, colorHash:var, order:int, keycardCreatedAccount:bool}]
required property var loginAccountsModel
property bool biometricsAvailable: Qt.platform.os === Constants.mac
required property bool isBiometricsLogin // FIXME should come from the loginAccountsModel for each profile separately?
readonly property string selectedProfileKeyId: loginUserSelector.selectedProfileKeyId
readonly property bool selectedProfileIsKeycard: d.currentProfileIsKeycard
// emitted when the user wants to try the biometrics prompt again
signal biometricsRequested()
// -> "keyUid:string": User ID to login; "method:int": password or keycard (cf Onboarding.LoginMethod.*) enum;
// "data:var": contains "password" or "pin"
signal loginRequested(string keyUid, int method, var data)
// "internal" onboarding signals, starting other flows
signal onboardingCreateProfileFlowRequested()
signal onboardingLoginFlowRequested()
signal unlockWithSeedphraseRequested()
signal unlockWithPukRequested()
signal lostKeycard()
QtObject {
id: d
property bool biometricsSuccessful
property bool biometricsFailed
readonly property bool currentProfileIsKeycard: loginUserSelector.keycardCreatedAccount
readonly property Settings settings: Settings {
category: "Login"
property string lastKeyUid
}
function resetBiometricsResult() {
d.biometricsSuccessful = false
d.biometricsFailed = false
}
function doPasswordLogin(password: string) {
if (password.length === 0)
return
root.loginRequested(d.settings.lastKeyUid, Onboarding.LoginMethod.Password, {"password": password})
}
function doKeycardLogin(pin: string) {
if (pin.length === 0)
return
root.loginRequested(d.settings.lastKeyUid, Onboarding.LoginMethod.Keycard, {"pin": pin})
}
}
Component.onCompleted: {
loginUserSelector.setSelection(d.settings.lastKeyUid)
if (!d.currentProfileIsKeycard)
passwordBox.forceActiveFocus()
}
Connections {
target: root.onboardingStore
// (password) login
function onAccountLoginError(error: string, wrongPassword: bool) {
if (error) {
if (!d.currentProfileIsKeycard) { // PIN validation done separately
// SQLITE_NOTADB: "file is not a database"
if (error.includes("file is not a database") || wrongPassword) {
passwordBox.validationError = qsTr("Password incorrect. %1").arg("<a href='#password'>" + qsTr("Forgot password?") + "</a>")
passwordBox.detailedError = ""
} else {
passwordBox.validationError = qsTr("Login failed. %1").arg("<a href='#details'>" + qsTr("Show details.") + "</a>")
passwordBox.detailedError = error
}
passwordBox.clear()
passwordBox.forceActiveFocus()
}
}
}
// biometrics
function onObtainingPasswordError(errorDescription: string, errorType: string, wrongFingerprint: bool) {
if (errorType === Constants.keychain.errorType.authentication) {
// We are notifying user only about keychain errors.
return
}
d.biometricsSuccessful = false
d.biometricsFailed = wrongFingerprint
if (d.currentProfileIsKeycard) {
keycardBox.clear()
} else {
passwordBox.validationError = wrongFingerprint ? qsTr("Fingerprint not recognised. Try entering password instead.")
: errorDescription
passwordBox.clear()
passwordBox.forceActiveFocus()
}
}
function onObtainingPasswordSuccess(password: string) {
if (!root.isBiometricsLogin)
return
d.biometricsSuccessful = true
d.biometricsFailed = false
if (d.currentProfileIsKeycard) {
keycardBox.setPin(password) // automatic login, emits loginRequested() already
} else {
passwordBox.validationError = ""
passwordBox.password = password
d.doPasswordLogin(password)
}
}
}
padding: 40
contentItem: Item {
ColumnLayout {
width: Math.min(340, parent.width)
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 200
anchors.bottom: parent.bottom
spacing: Theme.padding
StatusImage {
Layout.preferredWidth: 90
Layout.preferredHeight: 90
Layout.alignment: Qt.AlignHCenter
source: Theme.png("status")
mipmap: true
}
StatusBaseText {
id: headerText
Layout.fillWidth: true
text: qsTr("Welcome back")
font.pixelSize: 22
font.bold: true
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
LoginUserSelector {
id: loginUserSelector
objectName: "loginUserSelector"
Layout.topMargin: 20
Layout.fillWidth: true
Layout.preferredHeight: 64
model: root.loginAccountsModel
currentKeycardLocked: root.onboardingStore.keycardState === Onboarding.KeycardState.Locked
onSelectedProfileKeyIdChanged: {
d.resetBiometricsResult()
d.settings.lastKeyUid = selectedProfileKeyId
if (d.currentProfileIsKeycard) {
keycardBox.clear()
} else {
passwordBox.validationError = ""
passwordBox.clear()
passwordBox.forceActiveFocus()
}
}
onOnboardingCreateProfileFlowRequested: root.onboardingCreateProfileFlowRequested()
onOnboardingLoginFlowRequested: root.onboardingLoginFlowRequested()
}
LoginPasswordBox {
Layout.fillWidth: true
id: passwordBox
objectName: "passwordBox"
visible: !d.currentProfileIsKeycard
enabled: !!loginUserSelector.selectedProfileKeyId
isBiometricsLogin: root.biometricsAvailable && root.isBiometricsLogin
biometricsSuccessful: d.biometricsSuccessful
biometricsFailed: d.biometricsFailed
onPasswordEditedManually: {
// reset state when typing the pass manually; not to break the bindings inside the component
validationError = ""
d.resetBiometricsResult()
}
onBiometricsRequested: root.biometricsRequested()
onLoginRequested: (password) => d.doPasswordLogin(password)
}
LoginKeycardBox {
Layout.fillWidth: true
id: keycardBox
objectName: "keycardBox"
visible: d.currentProfileIsKeycard
isBiometricsLogin: root.biometricsAvailable && root.isBiometricsLogin
biometricsSuccessful: d.biometricsSuccessful
biometricsFailed: d.biometricsFailed
keycardState: root.onboardingStore.keycardState
tryToSetPinFunction: root.onboardingStore.setPin
keycardRemainingPinAttempts: root.onboardingStore.keycardRemainingPinAttempts
onUnlockWithSeedphraseRequested: root.unlockWithSeedphraseRequested()
onUnlockWithPukRequested: root.unlockWithPukRequested()
onPinEditedManually: {
// reset state when typing the PIN manually; not to break the bindings inside the component
d.resetBiometricsResult()
}
onBiometricsRequested: root.biometricsRequested()
onLoginRequested: (pin) => d.doKeycardLogin(pin)
}
Item { Layout.fillHeight: true }
StatusButton {
Layout.alignment: Qt.AlignHCenter
size: StatusBaseButton.Size.Small
visible: d.currentProfileIsKeycard
normalColor: "transparent"
borderWidth: 1
borderColor: Theme.palette.baseColor2
text: qsTr("Lost this Keycard?")
onClicked: root.lostKeycard()
}
}
}
}

View File

@ -14,8 +14,9 @@ KeycardEnterPinPage 1.0 KeycardEnterPinPage.qml
KeycardIntroPage 1.0 KeycardIntroPage.qml
KeycardNotEmptyPage 1.0 KeycardNotEmptyPage.qml
KeycardAddKeyPairPage 1.0 KeycardAddKeyPairPage.qml
LoginPage 1.0 LoginPage.qml
NewAccountLoginPage 1.0 NewAccountLoginPage.qml
LoginBySyncingPage 1.0 LoginBySyncingPage.qml
SeedphrasePage 1.0 SeedphrasePage.qml
WelcomePage 1.0 WelcomePage.qml
SyncProgressPage 1.0 SyncProgressPage.qml
LoginScreen 1.0 LoginScreen.qml

View File

@ -6,14 +6,22 @@ import AppLayouts.Onboarding.enums 1.0
QtObject {
id: root
signal appLoaded()
readonly property QtObject d: StatusQUtils.QObject {
id: d
readonly property var onboardingModuleInst: onboardingModule
readonly property var conn: Connections {
target: d.onboardingModuleInst
onAppLoaded: root.appLoaded()
Component.onCompleted: {
onboardingModuleInst.appLoaded.connect(root.appLoaded)
onboardingModuleInst.accountLoginError.connect(root.accountLoginError)
onboardingModuleInst.obtainingPasswordSuccess.connect(root.obtainingPasswordSuccess)
onboardingModuleInst.obtainingPasswordError.connect(root.obtainingPasswordError)
}
}
}
@ -35,10 +43,16 @@ QtObject {
}
// password
signal accountLoginError(string error, bool wrongPassword)
function getPasswordStrengthScore(password: string) { // -> int
return d.onboardingModuleInst.getPasswordStrengthScore(password, "") // The second argument is username
}
// biometrics
signal obtainingPasswordSuccess(string password)
signal obtainingPasswordError(string errorDescription, string errorType /* Constants.keychain.errorType.* */, bool wrongFingerprint)
// seedphrase/mnemonic
function validMnemonic(mnemonic: string) { // -> bool
return d.onboardingModuleInst.validMnemonic(mnemonic)