mirror of
https://github.com/status-im/status-desktop.git
synced 2025-02-24 20:48:50 +00:00
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:
parent
24ee6683a2
commit
638676ed0b
@ -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: "<"
|
||||
|
184
storybook/pages/LoginScreenPage.qml
Normal file
184
storybook/pages/LoginScreenPage.qml
Normal 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
|
@ -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 = "🯄"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
46
storybook/src/Models/LoginAccountsModel.qml
Normal file
46
storybook/src/Models/LoginAccountsModel.qml
Normal 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)
|
||||
}
|
@ -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
|
||||
|
94
storybook/src/Storybook/BiometricsPopup.qml
Normal file
94
storybook/src/Storybook/BiometricsPopup.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
BiometricsPopup 1.0 BiometricsPopup.qml
|
||||
CheckBoxFlowSelector 1.0 CheckBoxFlowSelector.qml
|
||||
CompilationErrorsBox 1.0 CompilationErrorsBox.qml
|
||||
FigmaImagesProxyModel 1.0 FigmaImagesProxyModel.qml
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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()
|
||||
}
|
||||
|
229
ui/app/AppLayouts/Onboarding2/components/LoginKeycardBox.qml
Normal file
229
ui/app/AppLayouts/Onboarding2/components/LoginKeycardBox.qml
Normal 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 isn’t 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
205
ui/app/AppLayouts/Onboarding2/components/LoginPasswordBox.qml
Normal file
205
ui/app/AppLayouts/Onboarding2/components/LoginPasswordBox.qml
Normal 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 you’re 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
158
ui/app/AppLayouts/Onboarding2/controls/LoginUserSelector.qml
Normal file
158
ui/app/AppLayouts/Onboarding2/controls/LoginUserSelector.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,7 @@ import utils 1.0
|
||||
OnboardingPage {
|
||||
id: root
|
||||
|
||||
title: qsTr("Create your profile")
|
||||
title: qsTr("Create profile")
|
||||
|
||||
signal createProfileWithPasswordRequested()
|
||||
signal createProfileWithSeedphraseRequested()
|
||||
|
249
ui/app/AppLayouts/Onboarding2/pages/LoginScreen.qml
Normal file
249
ui/app/AppLayouts/Onboarding2/pages/LoginScreen.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user