- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
11755 lines
450 KiB
QML
11755 lines
450 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Services.Pam
|
|
import qs.Commons
|
|
import qs.Ui
|
|
import "BitwardenModel.js" as Model
|
|
|
|
Panel {
|
|
id: root
|
|
moduleName: "io.github.elevate08.qs-bitwarden-cli"
|
|
ipcTarget: "io.github.elevate08.qs-bitwarden-cli"
|
|
manageIpc: false
|
|
|
|
implicitWidth: button.implicitWidth
|
|
implicitHeight: button.implicitHeight
|
|
|
|
// Configuration settings from shell.json. The numbers go through the schema
|
|
// on the way in as well as on the way out -- nothing validates shell.json,
|
|
// and a bad minute count does not fail loudly, it just stops the vault ever
|
|
// locking itself. See intSetting() in BitwardenModel.js.
|
|
readonly property int autoLockMinutes: Model.intSetting("autoLockMinutes", setting("autoLockMinutes"))
|
|
readonly property int clearClipboardSec: Model.intSetting("clearClipboardSec", setting("clearClipboardSec"))
|
|
readonly property bool lockOnScreenLock: Model.boolSetting("lockOnScreenLock", setting("lockOnScreenLock", true))
|
|
readonly property bool lockOnSuspend: Model.boolSetting("lockOnSuspend", setting("lockOnSuspend", true))
|
|
readonly property bool rememberSession: Model.boolSetting("rememberSession", setting("rememberSession", true))
|
|
readonly property int autoCopyTotpSec: Model.intSetting("autoCopyTotpSec", setting("autoCopyTotpSec"))
|
|
readonly property bool closeOnCopy: Model.boolSetting("closeOnCopy", setting("closeOnCopy", true))
|
|
readonly property bool colorizeIcon: Model.boolSetting("colorizeIcon", setting("colorizeIcon", false))
|
|
readonly property bool suggestOnOpen: Model.boolSetting("suggestOnOpen", setting("suggestOnOpen", true))
|
|
readonly property bool fingerprintUnlock: Model.boolSetting("fingerprintUnlock", setting("fingerprintUnlock", false))
|
|
readonly property bool pinUnlock: Model.boolSetting("pinUnlock", setting("pinUnlock", false))
|
|
// The SSH agent is opt-in. Nothing starts a helper, creates a socket, or
|
|
// touches a FIFO while this is false.
|
|
readonly property bool sshAgentEnabled: Model.boolSetting("sshAgentEnabled", setting("sshAgentEnabled", false))
|
|
readonly property bool sshAgentUnlockOnDemand: Model.boolSetting("sshAgentUnlockOnDemand", setting("sshAgentUnlockOnDemand", false))
|
|
readonly property bool sshAgentApprovalPopup: Model.boolSetting("sshAgentApprovalPopup", setting("sshAgentApprovalPopup", true))
|
|
readonly property int sshAgentApprovalWindowSec: Model.intSetting("sshAgentApprovalWindowSec", setting("sshAgentApprovalWindowSec"))
|
|
|
|
// The SSH sections' own section header. PanelSectionHeader comes from the
|
|
// Omarchy shell, and its defaults are the global theme's -- `Color.foreground`
|
|
// and `Style.font.family` -- while everything around it here follows the bar's
|
|
// own foreground and font family. Stating them once keeps the headers matching
|
|
// the captions beneath them, and keeps `textFormat` explicit, which this
|
|
// panel requires of every text element whether or not its text is constant
|
|
// today.
|
|
component SshSectionHeader: PanelSectionHeader {
|
|
textFormat: Text.PlainText
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
}
|
|
|
|
component SshCaption: Text {
|
|
textFormat: Text.PlainText
|
|
width: parent ? parent.width : 0
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// One of the three vault filters at the foot of the list, collapsed to its
|
|
// current value. Declared once so the three cannot drift apart and start
|
|
// reading as different kinds of control.
|
|
//
|
|
// The button names its filter as well as showing its value. The glyphs alone
|
|
// do not carry it: the three sit together reading "All", "All", "All" for as
|
|
// long as nothing is filtered, which is exactly when the value says least and
|
|
// the name says most. So the name stays, and the row is allowed to take a
|
|
// second line on the rarer occasions all three are set to something long.
|
|
//
|
|
// The value is still clipped. `Ui.Button` has no elide, so a folder named
|
|
// after a whole client engagement would make one button wider than the whole
|
|
// panel -- and a row that wraps can move a button to the next line but can
|
|
// never make one narrower than the panel it is in.
|
|
component VaultFilterButton: Button {
|
|
required property string group
|
|
required property string glyph
|
|
required property string name
|
|
required property string value
|
|
required property string shortcut
|
|
|
|
// Clipped first, then neutralized: plainLabel may return a <span>, and
|
|
// slicing that would cut the tag in half.
|
|
text: Model.plainLabel(name + ": " + Model.clipLabel(value, 20))
|
|
iconText: root.openFilterGroup === group ? "" : glyph
|
|
selected: root.openFilterGroup === group
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
horizontalPadding: Style.space(10)
|
|
// The full value, unclipped, is still one hover away -- and the tooltip is
|
|
// drawn by the kit's own auto-detecting Text, so it is neutralized too.
|
|
tooltipText: Model.plainLabel(name + " filter (" + shortcut + "): " + value)
|
|
onClicked: root.toggleFilterGroup(group)
|
|
}
|
|
|
|
// State
|
|
// status: "checking" | "unauthenticated" | "locked" | "unlocked"
|
|
property string status: "checking"
|
|
property string userEmail: ""
|
|
property string session: ""
|
|
property string masterPassword: ""
|
|
|
|
// Login form state
|
|
property string loginMethod: "email" // "email" | "apikey"
|
|
property string loginEmail: ""
|
|
property string loginPassword: ""
|
|
property string login2faCode: ""
|
|
property string loginServerRegion: "us" // "us" | "eu" | "custom"
|
|
property string loginServerUrl: ""
|
|
property string loginClientId: ""
|
|
property string loginClientSecret: ""
|
|
property bool show2faField: false
|
|
// Whether the login attempt now running carries --code. It is the only way
|
|
// to tell a rejected two-step code from a new-device-verification challenge;
|
|
// see loginNeedsDeviceVerification() in BitwardenModel.js.
|
|
property bool loginAttemptHadCode: false
|
|
// Set once Bitwarden has asked to verify this device with an emailed OTP.
|
|
// bw can only answer that interactively, so the panel stops asking for a
|
|
// code it cannot use and points at the terminal login instead.
|
|
property bool loginDeviceVerification: false
|
|
|
|
// Which two-step method this login tells bw to use, or -1 for "let bw
|
|
// decide", which is right whenever the account has exactly one. See
|
|
// TWO_FACTOR_METHODS in BitwardenModel.js.
|
|
property int login2faMethod: rememberedTwoFactorMethod
|
|
// Whether that method came from the user picking it in this login rather
|
|
// than from the remembered setting. A remembered method can be stale -- it
|
|
// is not scoped to an account -- so an unconfirmed one is dropped and
|
|
// retried without, where a confirmed one is reported as not configured.
|
|
property bool login2faMethodConfirmed: false
|
|
property bool show2faMethodPicker: false
|
|
// New-device verification collects its code in its own stage, because it is
|
|
// answered on a different path from a two-step code and must not be mistaken
|
|
// for one. See deviceVerificationLoginCommand() in BitwardenModel.js.
|
|
property string loginDeviceCode: ""
|
|
property bool showDeviceCodeField: false
|
|
// Set while the one login that runs with bw's prompts enabled is in flight,
|
|
// so both its environment and its result are read differently.
|
|
property bool deviceVerificationAttempt: false
|
|
property bool deviceVerificationPending: false
|
|
// When the login reached a stage that is waiting on a second factor, as
|
|
// epoch ms, or 0 if it is not. A closed panel keeps that login alive for
|
|
// SECOND_FACTOR_WINDOW_MS, because an emailed code cannot be read without
|
|
// leaving the panel. See secondFactorWindowOpen() in BitwardenModel.js.
|
|
property double secondFactorStartedAt: 0
|
|
// Whether this login has already spent its one automatic retry at handing
|
|
// the password to bw. See onAuthPasswordWriterExited().
|
|
property bool loginPasswordRetryUsed: false
|
|
// The email login is four stages deep now: credentials, the method question
|
|
// when bw asks it, the two-step code, and new-device verification. Only one
|
|
// is ever on screen.
|
|
readonly property bool loginCredentialsStage:
|
|
!show2faField && !show2faMethodPicker && !showDeviceCodeField
|
|
readonly property string login2faMethodLabel: Model.twoFactorMethodLabel(login2faMethod)
|
|
// The method the last attempt actually sent, so its answer can be read
|
|
// against it.
|
|
property int loginAttemptMethod: -1
|
|
// Keyed by login address, so two vaults on one machine each keep their own
|
|
// answer. Tracks loginEmail as it is typed, which is what makes the method
|
|
// apply the moment the address is complete.
|
|
readonly property var twoFactorMethodStore: setting("twoFactorMethods", null)
|
|
readonly property int rememberedTwoFactorMethod:
|
|
Model.rememberedTwoFactorMethodFor(twoFactorMethodStore, loginEmail)
|
|
|
|
// When the panel last launched a terminal login, as epoch ms, or 0 if it
|
|
// never did. A session key left in the runtime directory is only adopted in
|
|
// the minutes after this; see sessionHandoffReadCommand().
|
|
property double terminalLoginStartedAt: 0
|
|
|
|
// Screens: "main" | "detail" | "edit" | "locked" | "login" | "settings" | "setup"
|
|
property string currentScreen: "main"
|
|
property string screenBeforeSettings: "main"
|
|
|
|
// Dependency / setup state
|
|
property var dependencies: ({ items: [], hasOmarchy: true })
|
|
property bool depsChecked: false
|
|
property bool setupDismissed: false
|
|
property string listReadMode: "sanitized"
|
|
property var sshCapability: Model.defaultSshCapability()
|
|
// True while the panel should be showing setup rather than probing `bw`.
|
|
// See setupGateActive() in BitwardenModel.js for why the gate exists.
|
|
readonly property bool setupGated: Model.setupGateActive(dependencies, depsChecked, setupDismissed)
|
|
// Whether the first `bw status` has been started. The probe waits behind the
|
|
// dependency check on a fresh install, so something has to remember that it
|
|
// still owes the vault a look once the tools arrive.
|
|
property bool statusProbeStarted: false
|
|
// Set the moment a required tool is seen missing, cleared once the probe
|
|
// that follows the install has run. It is what turns "the install finished
|
|
// in a terminal we do not own" into a panel that moves on by itself.
|
|
property bool setupWasGated: false
|
|
property string settingsFlash: ""
|
|
property int settingsIndex: 0
|
|
readonly property var settingsEntries: Model.visibleSettings(dependencies, depsChecked)
|
|
|
|
// Vault data
|
|
property var items: []
|
|
// `bw list items` costs seconds on a large vault, so a reopen reuses what is
|
|
// already in memory until it goes stale. Any mutation reloads unconditionally.
|
|
property double itemsLoadedAt: 0
|
|
property double orgsLoadedAt: 0
|
|
property double foldersLoadedAt: 0
|
|
readonly property int itemsFreshMs: 60000
|
|
// Organizations and folders outlive an item refresh many times over.
|
|
readonly property int metaFreshMs: 600000
|
|
property var filteredItems: []
|
|
property var organizations: []
|
|
property string selectedOrg: "all" // "all" | "personal" | orgId
|
|
property var folders: []
|
|
property string selectedFolder: "all" // "all" | "none" | folderId
|
|
// Which bottom filter group is open: "" | "folders" | "organizations" | "types".
|
|
// Only one at a time, so the panel grows by one list at most.
|
|
property string openFilterGroup: ""
|
|
property int filterOptionIndex: 0
|
|
|
|
readonly property int filterRowHeight: Style.space(30)
|
|
readonly property int filterVisibleRows: 5
|
|
readonly property var currentFilterOptions: openFilterGroup === "" ? [] : filterOptions(openFilterGroup)
|
|
readonly property int currentFilterVisibleRows: openFilterGroup === "types" ? currentFilterOptions.length : filterVisibleRows
|
|
// The drawer's own height. The panel adds this to its cap so the window
|
|
// opens downward like a drawer instead of squeezing the item list.
|
|
readonly property int filterDrawerHeight: openFilterGroup === ""
|
|
? 0
|
|
: Style.space(30) + Math.min(currentFilterVisibleRows, currentFilterOptions.length) * filterRowHeight + Style.space(8)
|
|
property string formFolderId: ""
|
|
property string newFolderName: ""
|
|
// Which picker in the item form is expanded: "" | "folder" | "organization"
|
|
property string formPicker: ""
|
|
property var formCollections: []
|
|
property var formCollectionIds: []
|
|
property bool formCollectionsLoading: false
|
|
property bool creatingFolder: false
|
|
property string searchQuery: ""
|
|
property string selectedCategory: "all"
|
|
property int selectedIndex: 0
|
|
|
|
// Selected item detail
|
|
property var detailItem: null
|
|
property string detailPassword: ""
|
|
// Which sensitive fields on the open item are currently shown, by field key.
|
|
//
|
|
// One flag used to serve all of them, which was invisible while a login had
|
|
// exactly one secret to hide. A card has two and an identity three, and
|
|
// revealing a card number also uncovered its security code -- and, on an
|
|
// identity, the social security, passport and licence numbers at once. The
|
|
// eye on each field now speaks only for that field.
|
|
property var revealedFields: ({})
|
|
|
|
function isFieldRevealed(key) { return Boolean(revealedFields[key]) }
|
|
|
|
function toggleFieldReveal(key) {
|
|
var next = {}
|
|
for (var k in revealedFields) next[k] = revealedFields[k]
|
|
if (next[key]) delete next[key]
|
|
else next[key] = true
|
|
revealedFields = next
|
|
}
|
|
|
|
// What `v` reaches: the one secret the open item is mostly about. A card has
|
|
// a number, a login has a password. An identity has three identifiers and no
|
|
// principal one, so `v` leaves it alone rather than picking arbitrarily --
|
|
// each field carries its own eye.
|
|
readonly property string primaryRevealKey:
|
|
detailIsCard ? "cardNumber" : (detailIsLoginLike ? "password" : "")
|
|
|
|
// Which detail blocks the open item is entitled to. The login fields --
|
|
// username, password, TOTP, website -- used to be gated on "not an SSH
|
|
// key", which was the same question while logins and notes were the only
|
|
// other types. A card answers "not an SSH key" too, and would have drawn
|
|
// an empty password row under its number.
|
|
readonly property int detailTypeCode: detailItem ? Number(detailItem.typeCode || 1) : 1
|
|
readonly property bool detailIsLoginLike: detailTypeCode === 1 || detailTypeCode === 2
|
|
readonly property bool detailIsCard: detailTypeCode === 3
|
|
readonly property bool detailIsIdentity: detailTypeCode === 4
|
|
|
|
readonly property var detailCard: detailItem ? (detailItem.card || null) : null
|
|
readonly property var detailIdentity: detailItem ? (detailItem.identity || null) : null
|
|
|
|
// Expiry reads as one value, so it is composed once here rather than in the
|
|
// binding that draws it. A card with only one half filled in shows that
|
|
// half rather than a stray slash.
|
|
readonly property string detailCardExpiry: {
|
|
if (!detailCard) return ""
|
|
var m = String(detailCard.expMonth || "").trim()
|
|
var y = String(detailCard.expYear || "").trim()
|
|
if (m && y) return m + " / " + y
|
|
return m || y
|
|
}
|
|
|
|
readonly property string detailIdentityName: detailIdentity ? Model.identityFullName(detailIdentity) : ""
|
|
|
|
// The postal parts, in the order an envelope wants them, with the empty
|
|
// lines left out instead of drawn as blanks.
|
|
readonly property string detailIdentityAddress: {
|
|
if (!detailIdentity) return ""
|
|
var street = [detailIdentity.address1, detailIdentity.address2, detailIdentity.address3]
|
|
.map(function(part) { return String(part || "").trim() })
|
|
.filter(function(part) { return part !== "" })
|
|
var locality = [detailIdentity.city, detailIdentity.state, detailIdentity.postalCode]
|
|
.map(function(part) { return String(part || "").trim() })
|
|
.filter(function(part) { return part !== "" })
|
|
.join(" ")
|
|
var country = String(detailIdentity.country || "").trim()
|
|
return street.concat(locality ? [locality] : []).concat(country ? [country] : []).join("\n")
|
|
}
|
|
property string liveTotp: ""
|
|
property int totpSecRemaining: 30
|
|
property string totpRequestItemId: ""
|
|
property string totpQueuedItemId: ""
|
|
property int totpQueuedEpoch: -1
|
|
property bool totpRestartPending: false
|
|
property string totpCopyItemId: ""
|
|
property string passwordCopyItemId: ""
|
|
|
|
// Attachment downloads. One `bw get attachment` runs at a time and the rest
|
|
// wait in the queue, so "Save all" on an item with six files does not fire
|
|
// six CLI bootstraps at once. `attachmentSaved` maps an attachment id to the
|
|
// path it landed on, which is what turns the row's Download button into Open
|
|
// and Show in folder; it is cleared whenever a different item is opened.
|
|
property var attachmentQueue: []
|
|
property string attachmentBusyId: ""
|
|
property var attachmentSaved: ({})
|
|
|
|
// Follow-up TOTP sequential copy state (Enter -> Password -> Enter -> TOTP)
|
|
property var totpFollowupItem: null
|
|
property string totpFollowupCode: ""
|
|
property bool totpFollowupActive: false
|
|
|
|
// The save currently in flight, or null. Holds what the list showed before
|
|
// it, and the form that produced it, so a failure can put both back.
|
|
property var pendingSave: null
|
|
// The delete currently in flight, or null. Holds the row it removed so a
|
|
// refusal can put it back.
|
|
property var pendingDelete: null
|
|
|
|
// A save that came back refused. The list has been restored to what the
|
|
// vault actually holds; this is what the user typed, kept so it can be
|
|
// reopened rather than retyped.
|
|
property var failedSave: null
|
|
|
|
// Add / Edit Form State
|
|
property bool formIsEditing: false
|
|
property string formItemId: ""
|
|
property int formTypeCode: 1 // 1: Login, 2: Secure Note
|
|
property string formName: ""
|
|
property string formUsername: ""
|
|
property string formPassword: ""
|
|
property string formTotp: ""
|
|
property string formUri: ""
|
|
property string formNotes: ""
|
|
property bool formFavorite: false
|
|
property string formOrgId: ""
|
|
property bool formPasswordRevealed: false
|
|
property bool showDeleteConfirm: false
|
|
|
|
// Card and identity boxes. Flat strings rather than one object per type,
|
|
// because that is what every other field on this form is and what the
|
|
// TextField two-way binding above expects; formTypeFields() gathers them
|
|
// back into the shape the payload builders want.
|
|
property string formCardholderName: ""
|
|
property string formCardBrand: ""
|
|
property string formCardNumber: ""
|
|
property string formCardExpMonth: ""
|
|
property string formCardExpYear: ""
|
|
property string formCardCode: ""
|
|
|
|
property string formIdTitle: ""
|
|
property string formIdFirstName: ""
|
|
property string formIdMiddleName: ""
|
|
property string formIdLastName: ""
|
|
property string formIdUsername: ""
|
|
property string formIdCompany: ""
|
|
property string formIdEmail: ""
|
|
property string formIdPhone: ""
|
|
property string formIdSsn: ""
|
|
property string formIdPassport: ""
|
|
property string formIdLicense: ""
|
|
property string formIdAddress1: ""
|
|
property string formIdAddress2: ""
|
|
property string formIdAddress3: ""
|
|
property string formIdCity: ""
|
|
property string formIdState: ""
|
|
property string formIdPostalCode: ""
|
|
property string formIdCountry: ""
|
|
|
|
// When the current auto-lock window started, in wall-clock terms, so a
|
|
// suspend cannot hide from the countdown. See the autoLockWatchdog Timer.
|
|
property double autoLockArmedAt: 0
|
|
|
|
// The vault generation. Moves on whenever the vault changes hands -- locked,
|
|
// logged out of, unlocked again -- and every `bw` reader records the one it
|
|
// started under, so an answer from a vault that is no longer open can be
|
|
// recognised as such when it arrives. See vaultReadIsStale().
|
|
property int vaultEpoch: 0
|
|
property var readEpochs: ({})
|
|
|
|
// Processes whose collectors still have to be emptied after a lock. Anything
|
|
// that was running at the time stays here until it finishes. See
|
|
// scrubSecretBuffers().
|
|
property var scrubPending: []
|
|
|
|
// Status & indicators
|
|
property bool isLoading: false
|
|
property bool isUnlocking: false
|
|
property bool isSyncing: false
|
|
property bool metadataLoadPending: false
|
|
property bool metadataForceRefresh: false
|
|
property bool statusRefreshAfterItems: false
|
|
property bool statusCheckAuthoritative: true
|
|
// Whether this unlocked session has already tried to repair an unsynced
|
|
// vault. See the lastSync check in onStatusFinished().
|
|
property bool initialSyncAttempted: false
|
|
property bool syncReloadPending: false
|
|
property string errorMessage: ""
|
|
property string flashMessage: ""
|
|
property bool cursorActive: false
|
|
|
|
// Fingerprint unlock state.
|
|
// PAM only proves presence, so a verified finger is used as the gate on
|
|
// reading the master password back out of the login keyring.
|
|
property bool fingerprintAvailable: false // PAM stack + reader + enrolled finger
|
|
property bool fingerprintStored: false // master password present in keyring
|
|
property bool fingerprintScanning: false
|
|
property bool fingerprintAuthorized: false // a live PAM success may consume one keyring lookup
|
|
property string fingerprintMessage: ""
|
|
property string pendingUnlockPassword: "" // held only until the unlock lands
|
|
// Authentication processes are started before submission and wait on a
|
|
// private FIFO. These flags distinguish that harmless waiting state from an
|
|
// attempt whose password has actually been delivered.
|
|
property bool unlockSubmitted: false
|
|
property bool loginSubmitted: false
|
|
property bool loginSubmitAfterPrewarmStop: false
|
|
property bool loginPrepareAfterPrewarmStop: false
|
|
property string loginPrewarmSignature: ""
|
|
property string authPasswordWriteTarget: ""
|
|
property string authPasswordWriteValue: ""
|
|
// The value the keyring store process reads. Set from whichever path is
|
|
// storing: the explicit setup form, or the automatic refresh after unlock.
|
|
property string masterToStore: ""
|
|
// Item JSON on its way to `bw encode`. Held here so the create/edit processes
|
|
// can pass it in the environment instead of on the command line.
|
|
property string itemPayloadJson: ""
|
|
property bool fpSetupActive: false
|
|
property string fpSetupMaster: ""
|
|
property string fpError: ""
|
|
property bool fpBusy: false
|
|
// Which credential source drove the in-flight unlock, so a stale stored
|
|
// secret can be discarded rather than retried forever. "" | "fingerprint" | "pin"
|
|
property string pendingUnlockFrom: ""
|
|
|
|
// Send state
|
|
property var sends: []
|
|
property bool sendsLoading: false
|
|
property string sendMode: "list" // "list" | "create"
|
|
property string sendPayloadJson: ""
|
|
property bool sendBusy: false
|
|
property string sendError: ""
|
|
property string sendFormName: ""
|
|
property string sendFormText: ""
|
|
property bool sendFormHidden: false
|
|
property int sendFormDays: 7
|
|
property int sendFormMaxAccess: 0
|
|
property string sendFormPassword: ""
|
|
property int sendIndex: 0
|
|
|
|
// Generator state (session-scoped, mirroring the browser extension's options)
|
|
property var genOpts: Model.generatorDefaults()
|
|
property string genValue: ""
|
|
property bool genBusy: false
|
|
property bool genRegeneratePending: false
|
|
property string genRequestSignature: ""
|
|
// `bw serve` state. Ready means the loopback generator answered; failed
|
|
// means we stopped trying and the CLI carries the feature instead -- most
|
|
// likely because something else already holds the port, in which case we
|
|
// must not talk to it: a "generated password" from a stranger's server is
|
|
// a password they know.
|
|
property bool generateServeReady: false
|
|
property bool generateServeStarting: false
|
|
property bool generateServeFailed: false
|
|
// Set while we are the ones shutting the server down, so its exit is not
|
|
// mistaken for the bind failure that gives up on the port.
|
|
property bool generateServeStopping: false
|
|
property bool generateCliStopping: false
|
|
property bool generateServeRequestStopping: false
|
|
property bool generateServeRequestPending: false
|
|
property var generateServeRequestPendingOptions: null
|
|
property var generateServeRequestPendingCallback: null
|
|
// Where Back and Esc go, and whether the generator can hand its value
|
|
// somewhere. Opened from the item form it fills the password field in and
|
|
// returns; opened on its own it is just the generator. One screen either
|
|
// way, so the item form offers Bitwarden's own generator rather than a
|
|
// second, weaker one of its own.
|
|
property string generatorReturnScreen: "main"
|
|
readonly property bool generatorFeedsForm: generatorReturnScreen === "edit"
|
|
|
|
// PIN unlock state
|
|
property bool pinConfigured: false // ciphertext present in the keyring
|
|
property string pinEntry: "" // locked-screen input
|
|
property int pinAttempts: 0
|
|
readonly property int pinMaxAttempts: 5
|
|
property string pinError: ""
|
|
property string pinSetupPin: ""
|
|
property string pinSetupConfirm: ""
|
|
property string pinSetupMaster: ""
|
|
property bool pinBusy: false
|
|
property bool pinUnlockSubmitted: false
|
|
readonly property bool pinReady: pinUnlock && pinConfigured
|
|
// Long enough to save, short enough to be a bad idea. Drives the red state
|
|
// on the PIN field during setup; see pinWeakWarning() in BitwardenModel.js.
|
|
readonly property bool pinSetupWeak: Model.isPinWeak(pinSetupPin)
|
|
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME") || ""
|
|
readonly property bool fingerprintReady: fingerprintUnlock && fingerprintAvailable && fingerprintStored
|
|
|
|
// Contextual suggestions state
|
|
property var activeWindowData: null
|
|
property var detectedContext: null
|
|
property var suggestedItems: []
|
|
property bool suggestionsDismissed: false
|
|
property var associations: ({ version: 1, keys: {} })
|
|
property var learnedIds: ({})
|
|
property string pendingAssociationsJson: ""
|
|
property bool associationsWritePending: false
|
|
property bool associationsClearPending: false
|
|
property int associationsEpoch: 0
|
|
property int associationsReadEpoch: -1
|
|
property bool sessionStorePending: false
|
|
property bool sessionClearPending: false
|
|
property bool pinClearPending: false
|
|
property bool masterClearPending: false
|
|
property bool allCredentialsClearPending: false
|
|
property bool logoutPending: false
|
|
property bool logoutCliDone: false
|
|
property bool logoutCredentialsDone: false
|
|
property int logoutExitCode: 0
|
|
property int logoutCredentialsExitCode: 0
|
|
readonly property bool logoutCleanupFailed: logoutPending && logoutCredentialsDone
|
|
&& logoutCredentialsExitCode !== 0
|
|
|
|
// Visual styles
|
|
readonly property color fg: bar ? bar.foreground : Color.foreground
|
|
readonly property color urgent: bar ? bar.urgent : Color.urgent
|
|
readonly property color accent: Color.accent
|
|
readonly property color dim: Qt.darker(fg, 1.5)
|
|
readonly property color barIconColor: {
|
|
var base = bar ? bar.barForeground : Color.foreground
|
|
if (status === "unlocked") return Color.accent
|
|
if (status === "locked" || status === "checking") return base
|
|
return bar ? bar.urgent : Color.urgent
|
|
}
|
|
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
|
|
|
Component.onCompleted: {
|
|
// The dependency probe goes first, and the status probe follows from it in
|
|
// onDependenciesChecked. On a machine that already has `bw` the two are a
|
|
// few milliseconds apart; on a fresh install the order is the difference
|
|
// between opening on the setup screen and opening on a login form that
|
|
// cannot succeed.
|
|
root.checkDependencies()
|
|
root.loadAssociations()
|
|
// Explicit as well as bound: onSshAgentSupervisableChanged carries every
|
|
// later change, but a shell that starts with the feature already enabled
|
|
// evaluates that binding to true once, at creation, with nothing yet
|
|
// listening.
|
|
root.syncSshAgentSupervision()
|
|
// Everything above is the startup value, not a user action. Only changes
|
|
// after this point are transitions worth reacting to.
|
|
root.sshAgentSettingsReady = true
|
|
if (root.sshAgentEnabled) root.inspectSshAgentHelper()
|
|
root.inspectUwsmFragment()
|
|
}
|
|
|
|
readonly property var categories: [
|
|
{ id: "all", label: "All", icon: "" },
|
|
{ id: "login", label: "Logins", icon: "" },
|
|
{ id: "secureNote", label: "Notes", icon: "" },
|
|
{ id: "card", label: "Cards", icon: "" },
|
|
{ id: "identity", label: "Identities", icon: "" },
|
|
{ id: "sshKey", label: "SSH Keys", icon: "" },
|
|
{ id: "favorite", label: "Favorites", icon: "" }
|
|
]
|
|
|
|
// SSH keys need a CLI that can decrypt them. Until the probe confirms one,
|
|
// the type filter that can only ever come back empty is not offered.
|
|
readonly property bool sshUiAvailable: Model.sshUiAvailable(dependencies, depsChecked)
|
|
readonly property var visibleCategories: sshUiAvailable
|
|
? categories
|
|
: categories.filter(function(category) { return category.id !== "sshKey" })
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SSH companion supervision
|
|
// -------------------------------------------------------------------------
|
|
//
|
|
// The decisions live in Model.sshAgentReduce(); this side owns the Process,
|
|
// the clock and the timers. Every event goes through applySshAgentEvent(),
|
|
// which is the only place the state object is replaced, so the mirrored
|
|
// properties below and the real state can never drift apart.
|
|
//
|
|
// Nothing here is on the path of an ordinary vault operation. A helper that
|
|
// will not start, will not handshake, or crashes repeatedly leaves login,
|
|
// unlock, list, copy, sync, edit, Send and the generator exactly as they
|
|
// are; it only closes the signing gate and parks in an error state.
|
|
|
|
// Resolved from Panel.qml's own URL, so the helper is launched by an
|
|
// absolute path inside the plugin directory rather than off PATH.
|
|
readonly property string sshAgentPluginDir: Model.pluginDirFromUrl(String(Qt.resolvedUrl(".")))
|
|
readonly property string sshAgentRuntimeDir: Quickshell.env("XDG_RUNTIME_DIR") || ""
|
|
// What the shipped helper turned out to be. Checked once when the feature
|
|
// is enabled, and again whenever the plugin directory changes, because a
|
|
// plugin update can replace the binary under a running shell.
|
|
property var sshAgentHelper: ({ state: "unknown", source: "", version: "",
|
|
protocol: 0, checksum: "unchecked", selfTest: "", message: "" })
|
|
|
|
readonly property bool sshAgentSupervisable: sshAgentEnabled
|
|
&& sshAgentPluginDir !== "" && sshAgentRuntimeDir !== ""
|
|
// A helper that fails inspection disables this feature and nothing else:
|
|
// no supervisor, so no socket, no FIFO, and no agent branch in the vault
|
|
// read. The rest of the plugin never sees it.
|
|
&& Model.sshAgentHelperReady(sshAgentHelper)
|
|
|
|
function inspectSshAgentHelper() {
|
|
if (sshAgentHelperProc.running) return
|
|
sshAgentHelperProc.command = Model.sshAgentHelperInspectCommand(root.sshAgentPluginDir)
|
|
sshAgentHelperProc.running = true
|
|
}
|
|
|
|
function onSshAgentHelperInspected(raw) {
|
|
root.sshAgentHelper = Model.parseSshAgentHelperInspection(raw)
|
|
}
|
|
|
|
property var sshAgentState: Model.sshAgentInitialState()
|
|
// Mirrors of sshAgentState. QML cannot bind through a plain JS object, and
|
|
// the handshake timeout and backoff timers have to be driven by bindings
|
|
// rather than by anything that waits.
|
|
property string sshAgentPhase: "disabled"
|
|
property bool sshAgentGateOpen: false
|
|
property string sshAgentSocketPath: ""
|
|
property string sshAgentFifoPath: ""
|
|
property string sshAgentVersion: ""
|
|
property string sshAgentErrorCode: ""
|
|
property string sshAgentErrorMessage: ""
|
|
|
|
function applySshAgentEvent(event) {
|
|
var step = Model.sshAgentReduce(root.sshAgentState, event)
|
|
root.sshAgentState = step.state
|
|
root.sshAgentPhase = step.state.phase
|
|
root.sshAgentGateOpen = step.state.gateOpen
|
|
root.sshAgentSocketPath = step.state.socketPath
|
|
root.sshAgentFifoPath = step.state.fifoPath
|
|
root.sshAgentVersion = step.state.agentVersion
|
|
root.sshAgentErrorCode = step.state.errorCode
|
|
root.sshAgentErrorMessage = step.state.errorMessage
|
|
|
|
// The state above is committed before any of this runs, because stopping
|
|
// the Process can re-enter this function with the child's exit before the
|
|
// outer call returns. That order is what makes the re-entry safe: the
|
|
// inner reduction sees the phase it should, and no action set here is one
|
|
// the inner call also sets.
|
|
var action = step.action
|
|
// Cancel before scheduling: a stop that arrives while a restart is armed
|
|
// must not leave the timer running against a helper nobody asked for.
|
|
if (action.cancelRestart) sshAgentRestartTimer.stop()
|
|
if (action.stop) stopSshAgentHelper()
|
|
if (action.writeHello && sshAgentProc.running) sshAgentProc.write(Model.sshAgentHelloLine())
|
|
if (action.restartInMs >= 0) {
|
|
sshAgentRestartTimer.interval = action.restartInMs
|
|
sshAgentRestartTimer.restart()
|
|
}
|
|
if (action.start) startSshAgentHelper()
|
|
if (action.message) root.onSshAgentMessage(action.message)
|
|
}
|
|
|
|
function startSshAgentHelper() {
|
|
sshAgentTerminateTimer.stop()
|
|
// A previous stop closed this. The control channel is the helper's only
|
|
// input, so it has to be open again before the handshake is written.
|
|
sshAgentProc.stdinEnabled = true
|
|
sshAgentProc.running = true
|
|
}
|
|
|
|
// Stopping the helper is a request, not a signal. Its designed shutdown is
|
|
// the control channel closing: it drops its keys, unlinks its socket and
|
|
// FIFO, and exits. SIGTERM -- which is all `running = false` does -- skips
|
|
// every one of those, leaving a socket and FIFO behind for the next start
|
|
// to clean up. So ask, then terminate only if it does not go.
|
|
function stopSshAgentHelper() {
|
|
if (!sshAgentProc.running) {
|
|
sshAgentTerminateTimer.stop()
|
|
return
|
|
}
|
|
if (sshAgentProc.stdinEnabled) {
|
|
sshAgentProc.write(Model.sshAgentShutdownLine())
|
|
sshAgentProc.stdinEnabled = false
|
|
}
|
|
sshAgentTerminateTimer.restart()
|
|
}
|
|
|
|
// Live companion events. Task 10 supervises the channel; the vault
|
|
// lifecycle, approval UI and key loading that consume these arrive with
|
|
// Tasks 12-14. Until then an unhandled event is deliberately inert rather
|
|
// than an error: it is a valid v1 message the panel simply has no use for
|
|
// yet.
|
|
// -------------------------------------------------------------------------
|
|
// Signing authorization
|
|
// -------------------------------------------------------------------------
|
|
//
|
|
// One prompt at a time, never over a locked screen, and never claiming more
|
|
// about the requesting process than the companion actually checked.
|
|
|
|
// What is actually on screen. A live signing request outranks navigation:
|
|
// the panel's own flows reset currentScreen freely -- opening the panel,
|
|
// finishing an unlock -- and each of those would otherwise drop a prompt
|
|
// that a blocked client is waiting on. Screen visibility binds to this
|
|
// rather than to currentScreen, so no later assignment can hide a prompt.
|
|
readonly property string activeScreen: sshPrompt !== null && !sshAgentApprovalPopup ? "sshApproval" : currentScreen
|
|
|
|
property var sshPrompt: null // the approval_required being shown
|
|
property var sshPromptQueue: [] // FIFO queue of approval_required messages waiting to be shown
|
|
property var sshUnlockRequest: null // the unlock_required being shown
|
|
property var sshUnlockRaw: null // its original message, to promote from
|
|
property var sshUnlockQueue: [] // FIFO queue of unlock_required messages waiting
|
|
readonly property int sshPendingCount: Model.sshAgentPendingCount(sshPrompt, sshPromptQueue)
|
|
readonly property int sshUnlockPendingCount: Model.sshAgentPendingCount(sshUnlockRequest, sshUnlockQueue)
|
|
readonly property int sshTotalPendingCount: sshPendingCount + sshUnlockPendingCount
|
|
readonly property bool sshApprovalPopupOpen: sshAgentApprovalPopup
|
|
&& (sshPrompt !== null || sshUnlockRequest !== null)
|
|
// Password, PIN, and fingerprint completion handlers must accept the
|
|
// transient overlay as a real authentication surface even while the
|
|
// anchored panel stays closed.
|
|
readonly property bool sshAuthSurfaceActive: opened || sshApprovalPopupOpen
|
|
// What the companion last announced, and the live view of it. The
|
|
// announcement is a snapshot; the view is that snapshot re-derived against
|
|
// a ticking clock, so a grant counts down on screen and disappears when it
|
|
// lapses instead of waiting for the next thing to happen.
|
|
property var sshGrantsAnnounced: []
|
|
property double sshGrantTick: 0
|
|
readonly property var sshGrants: Model.sshAgentGrantsAt(sshGrantsAnnounced, sshGrantTick)
|
|
property var sshCooldown: Model.sshAgentCooldownInitial()
|
|
// Whether the current cooldown has already been announced. Reset when it
|
|
// lapses, so a later one is announced again but the same one is not
|
|
// repeated on every refused request.
|
|
property bool sshCooldownAnnounced: false
|
|
readonly property var sshCooldownStatus: Model.sshAgentCooldownStatus(sshCooldown, sshCooldownTick)
|
|
// A one-second tick so the remaining time in the status actually counts
|
|
// down; bindings on Date.now() would never re-evaluate on their own.
|
|
property double sshCooldownTick: 0
|
|
property double sshPromptStartedMs: 0
|
|
property int sshPromptRemainingSec: 0
|
|
property string screenBeforeSshApproval: "main"
|
|
// Whether the signing request is what put the panel on screen. If it was,
|
|
// answering hands the desktop back; if the user already had the panel open,
|
|
// it is theirs and they are returned to what they were doing.
|
|
property bool sshPromptOpenedPanel: false
|
|
|
|
function sshAgentWrite(line) {
|
|
if (line === "") return
|
|
if (sshAgentProc.running && sshAgentProc.stdinEnabled) sshAgentProc.write(line)
|
|
}
|
|
|
|
// Whether a request may raise UI at all. A locked screen never does, and a
|
|
// process that has had two refusals in a row is put on a cooldown so it
|
|
// cannot keep reopening the panel.
|
|
// Called wherever the cooldown may have just started. The announcement is
|
|
// the only thing that tells a user why their SSH command suddenly fails.
|
|
function noteSshCooldown() {
|
|
root.sshCooldownTick = Date.now()
|
|
var status = Model.sshAgentCooldownStatus(root.sshCooldown, Date.now())
|
|
if (status.active && !root.sshCooldownAnnounced) {
|
|
root.sshCooldownAnnounced = true
|
|
flashNotification("SSH signing paused: too many unanswered prompts")
|
|
} else if (!status.active) {
|
|
root.sshCooldownAnnounced = false
|
|
}
|
|
}
|
|
|
|
// The only way out of a running cooldown other than waiting it out. It has
|
|
// to be explicit: the cooldown suppresses the prompts an approval would
|
|
// answer, so nothing the requesting process does can end it, and nothing it
|
|
// does should. A person pressing this is the signal that the requests are
|
|
// wanted after all.
|
|
function resumeSshSigning() {
|
|
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "resumed", Date.now())
|
|
noteSshCooldown()
|
|
}
|
|
|
|
function sshAgentMayPrompt() {
|
|
// An unknown screen state counts as locked. The poll runs every few
|
|
// seconds while the agent is serving, so a reading older than this means
|
|
// the poll is not running and the panel cannot tell -- and the cost of
|
|
// guessing wrong is a credential prompt on a locked desktop.
|
|
var fresh = root.screenLockCheckedAt > 0
|
|
&& (Date.now() - root.screenLockCheckedAt) < (Model.screenLockPollMs() * 4)
|
|
if (!Model.sshAgentShouldPrompt(fresh ? { screenLocked: root.screenIsLocked } : null)) return false
|
|
return !Model.sshAgentCooldownActive(root.sshCooldown, Date.now())
|
|
}
|
|
|
|
function showSshApproval(message) {
|
|
root.sshPrompt = Model.sshAgentPromptView(message, root.sshAgentApprovalWindowSec)
|
|
root.sshPromptStartedMs = Date.now()
|
|
root.sshPromptRemainingSec = Math.ceil(Model.sshAgentRequestDeadlineMs() / 1000)
|
|
if (root.sshAgentApprovalPopup) {
|
|
root.sshPromptOpenedPanel = false
|
|
return
|
|
}
|
|
if (root.currentScreen !== "sshApproval") root.screenBeforeSshApproval = root.currentScreen
|
|
// Recorded before opening, because open() is what makes it true.
|
|
if (!root.sshUnlockRaw) root.sshPromptOpenedPanel = !root.opened
|
|
// Open first. Opening runs onPanelOpened(), which sends an unlocked panel
|
|
// to the item list, so claiming the screen before that would simply be
|
|
// undone -- the prompt would be live with nothing on screen.
|
|
if (!root.opened) root.open()
|
|
root.currentScreen = "sshApproval"
|
|
}
|
|
|
|
// shell.json hot-reloads. If the preference changes while a client is
|
|
// blocked, move the same request to the newly selected surface rather than
|
|
// making it invisible until its deadline expires.
|
|
onSshAgentApprovalPopupChanged: {
|
|
if (!(root.sshPrompt || root.sshUnlockRequest)) return
|
|
if (root.sshAgentApprovalPopup) {
|
|
var requestOpenedPanel = root.sshPromptOpenedPanel
|
|
root.sshPromptOpenedPanel = false
|
|
if (requestOpenedPanel && root.opened) root.close()
|
|
return
|
|
}
|
|
|
|
root.sshPromptOpenedPanel = !root.opened
|
|
if (!root.opened) root.open()
|
|
if (root.sshPrompt) root.currentScreen = "sshApproval"
|
|
}
|
|
|
|
function dismissSshApproval() {
|
|
var openedForThis = root.sshPromptOpenedPanel
|
|
var popupWasUsed = root.sshApprovalPopupOpen
|
|
root.sshPrompt = null
|
|
root.sshPromptQueue = []
|
|
root.sshPromotedOldId = null
|
|
root.sshUnlockRequest = null
|
|
root.sshUnlockRaw = null
|
|
root.sshUnlockQueue = []
|
|
root.sshPromptOpenedPanel = false
|
|
if (root.currentScreen === "sshApproval") {
|
|
root.currentScreen = root.screenBeforeSshApproval === "sshApproval"
|
|
? "main" : root.screenBeforeSshApproval
|
|
}
|
|
if (popupWasUsed) clearSshPopupUnlockState()
|
|
// Answered -- approved or denied alike -- so give the desktop back if the
|
|
// request is what took it. A panel the user opened themselves stays open
|
|
// on whatever screen they were using.
|
|
if (openedForThis && root.opened) root.close()
|
|
}
|
|
|
|
function advanceSshPrompt() {
|
|
var res = Model.sshAgentDequeuePrompt(root.sshPromptQueue)
|
|
root.sshPromptQueue = res.remaining
|
|
if (res.next) {
|
|
showSshApproval(res.next)
|
|
return
|
|
}
|
|
dismissSshApproval()
|
|
}
|
|
|
|
function advanceSshUnlock() {
|
|
var res = Model.sshAgentDequeuePrompt(root.sshUnlockQueue)
|
|
root.sshUnlockQueue = res.remaining
|
|
if (res.next) {
|
|
root.sshUnlockRaw = res.next
|
|
root.sshUnlockRequest = Model.sshAgentPromptView(res.next, 0)
|
|
root.sshPromptStartedMs = Date.now()
|
|
root.sshPromptRemainingSec = Math.ceil(Model.sshAgentRequestDeadlineMs() / 1000)
|
|
return
|
|
}
|
|
dismissSshApproval()
|
|
}
|
|
|
|
// The popup is deliberately short lived. Do not let a dismissed or expired
|
|
// request leave a password, PIN, PAM conversation, or prewarmed CLI behind.
|
|
function clearSshPopupUnlockState() {
|
|
cancelFingerprintUnlock()
|
|
cancelAuthPrewarm()
|
|
if (pinUnlockProc.running) pinUnlockProc.running = false
|
|
root.pinUnlockSubmitted = false
|
|
root.pinBusy = false
|
|
root.masterPassword = ""
|
|
root.pendingUnlockPassword = ""
|
|
root.pendingUnlockFrom = ""
|
|
root.pinEntry = ""
|
|
root.pinError = ""
|
|
root.fingerprintMessage = ""
|
|
root.errorMessage = ""
|
|
}
|
|
|
|
function approveSshRequest(grantSeconds) {
|
|
if (!sshPrompt) return
|
|
sshAgentWrite(Model.sshAgentApproveLine(sshPrompt.requestId, grantSeconds))
|
|
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "approved", Date.now())
|
|
noteSshCooldown()
|
|
advanceSshPrompt()
|
|
}
|
|
|
|
function denySshRequest() {
|
|
if (sshUnlockRequest) {
|
|
sshAgentWrite(Model.sshAgentUnlockCancelledLine(sshUnlockRequest.requestId))
|
|
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "denied", Date.now())
|
|
noteSshCooldown()
|
|
advanceSshUnlock()
|
|
return
|
|
}
|
|
if (sshPrompt) {
|
|
sshAgentWrite(Model.sshAgentDenyLine(sshPrompt.requestId))
|
|
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "denied", Date.now())
|
|
noteSshCooldown()
|
|
advanceSshPrompt()
|
|
return
|
|
}
|
|
dismissSshApproval()
|
|
}
|
|
|
|
function denyAllSshRequests() {
|
|
if (sshPrompt) {
|
|
sshAgentWrite(Model.sshAgentDenyLine(sshPrompt.requestId))
|
|
}
|
|
for (var i = 0; i < root.sshPromptQueue.length; i++) {
|
|
if (root.sshPromptQueue[i] && root.sshPromptQueue[i].requestId) {
|
|
sshAgentWrite(Model.sshAgentDenyLine(root.sshPromptQueue[i].requestId))
|
|
}
|
|
}
|
|
if (sshUnlockRequest) {
|
|
sshAgentWrite(Model.sshAgentUnlockCancelledLine(sshUnlockRequest.requestId))
|
|
}
|
|
for (var j = 0; j < root.sshUnlockQueue.length; j++) {
|
|
if (root.sshUnlockQueue[j] && root.sshUnlockQueue[j].requestId) {
|
|
sshAgentWrite(Model.sshAgentUnlockCancelledLine(root.sshUnlockQueue[j].requestId))
|
|
}
|
|
}
|
|
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "denied", Date.now())
|
|
noteSshCooldown()
|
|
dismissSshApproval()
|
|
}
|
|
|
|
// The companion expires the request; this only stops the panel showing a
|
|
// question whose answer would now be rejected anyway.
|
|
function expireSshRequest() {
|
|
if (!sshPrompt && !sshUnlockRequest) return
|
|
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "timeout", Date.now())
|
|
noteSshCooldown()
|
|
dismissSshApproval()
|
|
}
|
|
|
|
// Git SSH signing needs paths, so the validated public set is projected to
|
|
// files. Only what the companion vouched for is written, and only its
|
|
// public form -- sshExportIdentities() refuses anything that is not an
|
|
// OpenSSH public line.
|
|
function exportSshPublicKeys() {
|
|
var payload = Model.sshExportPayload(root.sshPendingPublicKeys)
|
|
root.sshPendingPublicKeys = []
|
|
if (sshExportProc.running) return
|
|
sshExportProc.running = true
|
|
sshExportProc.write(payload)
|
|
sshExportProc.stdinEnabled = false
|
|
}
|
|
|
|
// Logout, account change and disabling remove the projection. A lock does
|
|
// not: public identities stay advertised while locked, so their files stay
|
|
// with them.
|
|
function clearSshPublicKeys() {
|
|
root.sshPendingPublicKeys = []
|
|
root.sshPendingPublicEpoch = -1
|
|
if (sshExportClearProc.running) return
|
|
sshExportClearProc.running = true
|
|
}
|
|
|
|
function onSshExportFinished(exitCode, stdout) {
|
|
var result = Model.parseSshExportResult(exitCode, stdout)
|
|
root.sshExportError = result.ok ? "" : result.message
|
|
}
|
|
|
|
property string sshExportError: ""
|
|
|
|
function revokeSshGrant(grantId) {
|
|
sshAgentWrite(Model.sshAgentRevokeGrantLine(grantId))
|
|
}
|
|
|
|
function revokeAllSshGrants() {
|
|
sshAgentWrite(Model.sshAgentRevokeGrantsLine())
|
|
}
|
|
|
|
property var sshPromotedOldId: null
|
|
|
|
function adoptSshPrompt(message) {
|
|
if (root.sshPromotedOldId !== null && root.sshPrompt) {
|
|
root.sshPrompt.requestId = message.requestId
|
|
root.sshPromotedOldId = null
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function onSshAgentMessage(message) {
|
|
if (message.type === "approval_required") {
|
|
// A request that cannot raise UI is refused rather than left hanging:
|
|
// the client gets its answer now instead of waiting out the deadline.
|
|
if (!sshAgentMayPrompt()) {
|
|
sshAgentWrite(Model.sshAgentDenyLine(message.requestId))
|
|
return
|
|
}
|
|
if (adoptSshPrompt(message)) return
|
|
if (root.sshPrompt !== null) {
|
|
root.sshPromptQueue = Model.sshAgentEnqueuePrompt(root.sshPromptQueue, message, 4)
|
|
return
|
|
}
|
|
showSshApproval(message)
|
|
return
|
|
}
|
|
if (message.type === "unlock_required") {
|
|
if (!sshAgentMayPrompt()) {
|
|
sshAgentWrite(Model.sshAgentUnlockCancelledLine(message.requestId))
|
|
return
|
|
}
|
|
if (root.sshUnlockRequest !== null) {
|
|
root.sshUnlockQueue = Model.sshAgentEnqueuePrompt(root.sshUnlockQueue, message, 4)
|
|
return
|
|
}
|
|
root.sshUnlockRaw = message
|
|
root.sshUnlockRequest = Model.sshAgentPromptView(message, 0)
|
|
root.sshPromptStartedMs = Date.now()
|
|
root.sshPromptRemainingSec = Math.ceil(Model.sshAgentRequestDeadlineMs() / 1000)
|
|
if (root.sshAgentApprovalPopup) {
|
|
root.sshPromptOpenedPanel = false
|
|
return
|
|
}
|
|
root.sshPromptOpenedPanel = !root.opened
|
|
if (!root.opened) root.open()
|
|
return
|
|
}
|
|
if (message.type === "request_cancelled") {
|
|
// The request was cancelled by the client, timed out, or released on unlock.
|
|
var live = root.sshPrompt || root.sshUnlockRequest
|
|
if (live && live.requestId === message.requestId) {
|
|
if (message.reason !== "released") {
|
|
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "timeout", Date.now())
|
|
noteSshCooldown()
|
|
} else {
|
|
return
|
|
}
|
|
if (root.sshPrompt && root.sshPromptQueue.length > 0) advanceSshPrompt()
|
|
else if (root.sshUnlockRequest && root.sshUnlockQueue.length > 0) advanceSshUnlock()
|
|
else dismissSshApproval()
|
|
return
|
|
}
|
|
if (root.sshPromptQueue.length > 0) {
|
|
root.sshPromptQueue = Model.sshAgentRemovePrompt(root.sshPromptQueue, message.requestId)
|
|
}
|
|
if (root.sshUnlockQueue.length > 0) {
|
|
root.sshUnlockQueue = Model.sshAgentRemovePrompt(root.sshUnlockQueue, message.requestId)
|
|
}
|
|
return
|
|
}
|
|
if (message.type === "grants_changed") {
|
|
root.sshGrantsAnnounced = Model.sshAgentGrantViews(message.grants, Date.now())
|
|
root.sshGrantTick = Date.now()
|
|
return
|
|
}
|
|
if (message.type === "public_key") {
|
|
// A new epoch starts a new set rather than adding to the last one.
|
|
if (root.sshPendingPublicEpoch !== message.epoch) {
|
|
root.sshPendingPublicEpoch = message.epoch
|
|
root.sshPendingPublicKeys = []
|
|
}
|
|
root.sshPendingPublicKeys = root.sshPendingPublicKeys.concat([message])
|
|
return
|
|
}
|
|
if (message.type === "keys_loaded") {
|
|
root.sshAgentKeyCount = Math.max(0, Math.floor(Number(message.keyCount)) || 0)
|
|
root.sshAgentKeysLoadedAt = Date.now()
|
|
// The set is complete: every public_key for this epoch arrived ahead of
|
|
// this message.
|
|
if (root.sshPendingPublicEpoch === message.epoch) exportSshPublicKeys()
|
|
return
|
|
}
|
|
if (message.type === "locked") {
|
|
// The companion has denied signing, dropped its grants and private keys,
|
|
// and kept only the public projection. That is what the kill timer was
|
|
// waiting for.
|
|
sshAgentLockAckTimer.stop()
|
|
return
|
|
}
|
|
if (message.type === "state_changed") {
|
|
root.sshAgentKeyCount = Math.max(0, Math.floor(Number(message.keyCount)) || 0)
|
|
return
|
|
}
|
|
// unlock_required, approval_required and grants_changed are the signing
|
|
// UX, and arrive with Task 14. Ignoring a valid v1 message is deliberate
|
|
// here; an unknown *type* is a protocol failure and never reaches this.
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Key loading (the agent branch of the shared vault read)
|
|
// -------------------------------------------------------------------------
|
|
//
|
|
// The companion's keystore requires a strictly increasing epoch per load, so
|
|
// this counter only ever goes up. It survives helper restarts harmlessly: a
|
|
// restarted companion begins again at 0, and every value the panel sends is
|
|
// still greater than that.
|
|
property int sshAgentEpoch: 0
|
|
property string sshAgentLoadId: ""
|
|
property bool sshAgentLoadActive: false
|
|
// Whether the read now running carries the agent branch, and whether it has
|
|
// already been retried without it. The retry exists so an optional feature
|
|
// can never cost the user their item list.
|
|
property bool listAgentBranchActive: false
|
|
property bool listRetriedWithoutAgent: false
|
|
|
|
// A nonce is generated ahead of the load that will use it. Reading
|
|
// /dev/urandom is fast, but it is still a process, and the ordinary item
|
|
// list must never wait on the agent feature -- so a load that finds no
|
|
// nonce ready simply runs without the branch and primes one for next time.
|
|
property string sshAgentNextLoadId: ""
|
|
// What the companion last reported it was serving. Public metadata only --
|
|
// a count, not the keys -- and it is what tells the panel whether a locked
|
|
// companion still has a public cache to answer identity listings from.
|
|
property int sshAgentKeyCount: 0
|
|
// The validated public identities the companion reported for the epoch
|
|
// currently loading. Accumulated per key, because a single message carrying
|
|
// all of them would exceed the control-line ceiling at the key limit.
|
|
property var sshPendingPublicKeys: []
|
|
property int sshPendingPublicEpoch: -1
|
|
property double sshAgentKeysLoadedAt: 0
|
|
// The vault epoch a key load has already been started for. dropVaultState()
|
|
// advances vaultEpoch on every lock and logout, so this is what tells a
|
|
// startup load apart from one that has already happened for this session.
|
|
property int sshAgentLoadedForVaultEpoch: -1
|
|
|
|
function primeSshAgentLoadId() {
|
|
if (loadIdProc.running || sshAgentNextLoadId !== "") return
|
|
loadIdProc.running = true
|
|
}
|
|
|
|
function onSshAgentLoadIdRead(raw) {
|
|
var candidate = String(raw || "").trim()
|
|
root.sshAgentNextLoadId = Model.isValidLoadId(candidate) ? candidate : ""
|
|
}
|
|
|
|
// Close an open load window. Called on success, on failure, and on a lock
|
|
// that cancels the read underneath it. The companion holds every candidate
|
|
// unpublished until this arrives, and discards it on a failed status, so a
|
|
// window that is never closed is the one outcome to avoid.
|
|
function endSshAgentLoad(ok) {
|
|
if (!sshAgentLoadActive) return
|
|
sshAgentLoadActive = false
|
|
sshAgentLoadId = ""
|
|
if (sshAgentProc.running && sshAgentProc.stdinEnabled) {
|
|
sshAgentProc.write(Model.sshAgentLoadEndLine(sshAgentEpoch, ok))
|
|
}
|
|
primeSshAgentLoadId()
|
|
}
|
|
|
|
// A lock abandons the current loadId and stops the whole read. The pipeline
|
|
// runs as its own process group, so terminating the wrapper reaps `bw`, the
|
|
// caps, `tee` and both `jq` stages with it.
|
|
function cancelSshAgentLoad() {
|
|
if (listProc.running) listProc.running = false
|
|
endSshAgentLoad(false)
|
|
listAgentBranchActive = false
|
|
listRetriedWithoutAgent = false
|
|
}
|
|
|
|
// Every vault transition reaches the companion through here, so the ordering
|
|
// rules live in one place: deny first, cancel work in flight, then let the
|
|
// panel get on with its own lock. Nothing below ever waits on the helper.
|
|
function applySshAgentLifecycle(event) {
|
|
var action = Model.sshAgentLifecycleTransition(event, {
|
|
enabled: root.sshAgentEnabled,
|
|
helperReady: root.sshAgentGateOpen,
|
|
loggedIn: root.status !== "unauthenticated",
|
|
unlocked: root.status === "unlocked",
|
|
loading: root.sshAgentLoadActive,
|
|
hasPublicCache: root.sshAgentKeyCount > 0,
|
|
epoch: root.sshAgentEpoch
|
|
})
|
|
|
|
if (action.cancelLoad) cancelSshAgentLoad()
|
|
for (var i = 0; i < action.controlLines.length; i++) {
|
|
if (sshAgentProc.running && sshAgentProc.stdinEnabled) sshAgentProc.write(action.controlLines[i])
|
|
}
|
|
if (action.clearPublic) {
|
|
root.sshAgentKeyCount = 0
|
|
root.sshAgentKeysLoadedAt = 0
|
|
clearSshPublicKeys()
|
|
}
|
|
// The acknowledgment is a courtesy the panel gives the companion two
|
|
// seconds to return. It is not a precondition for locking: `bw lock` has
|
|
// already been launched by the caller, and a companion that cannot
|
|
// confirm a lock is one that must not keep running.
|
|
if (action.awaitLockAck) sshAgentLockAckTimer.restart()
|
|
if (action.stopHelper) stopSshAgentHelper()
|
|
if (action.startLoad && !listProc.running) loadItems(false)
|
|
}
|
|
|
|
function syncSshAgentSupervision() {
|
|
applySshAgentEvent({ kind: "enabled", value: root.sshAgentSupervisable, nowMs: Date.now() })
|
|
}
|
|
|
|
onSshAgentSupervisableChanged: syncSshAgentSupervision()
|
|
|
|
function sendSshAgentOptions() {
|
|
sshAgentWrite(Model.sshAgentOptionsLine(root.sshAgentUnlockOnDemand))
|
|
}
|
|
|
|
onSshAgentUnlockOnDemandChanged: sendSshAgentOptions()
|
|
|
|
onSshAgentGateOpenChanged: {
|
|
if (sshAgentGateOpen) sendSshAgentOptions()
|
|
if (!sshAgentGateOpen) {
|
|
endSshAgentLoad(false)
|
|
// The keystore lives in the helper's memory. Whatever it held went with
|
|
// it, so the panel must stop claiming those keys are still served.
|
|
root.sshAgentKeyCount = 0
|
|
return
|
|
}
|
|
// A new helper is empty even when the vault epoch has not moved -- the
|
|
// epoch tracks the vault, not the process. Clearing this is what makes a
|
|
// restarted or re-enabled helper eligible for a load, instead of leaving
|
|
// it keyless until something unrelated happens to bump the epoch.
|
|
root.sshAgentLoadedForVaultEpoch = -1
|
|
primeSshAgentLoadId()
|
|
// Startup is not evidence that the vault is locked: rememberSession can
|
|
// restore a session key, so the panel can already be unlocked when the
|
|
// companion finishes its handshake with an empty keystore. Deferred by a
|
|
// beat so the nonce that was just primed is actually ready.
|
|
sshAgentStartupLoadTimer.restart()
|
|
}
|
|
|
|
Timer {
|
|
id: sshAgentStartupLoadTimer
|
|
interval: 250
|
|
repeat: false
|
|
onTriggered: root.maybeStartupLoad()
|
|
}
|
|
|
|
// Two things have to be true before a startup load makes sense -- the helper
|
|
// is serving, and the vault is actually unlocked -- and on a shell restart
|
|
// they arrive in either order: the handshake can easily beat the first
|
|
// `bw status`. So both edges call this, and the vault epoch keeps it to one
|
|
// load rather than one per edge.
|
|
function maybeStartupLoad() {
|
|
if (!sshAgentGateOpen || root.status !== "unlocked") return
|
|
// A read already running is the common case at startup: the panel's first
|
|
// item read is launched before the helper has finished handshaking, so it
|
|
// carries no agent branch. onListFinished() calls back here once it lands.
|
|
if (sshAgentLoadActive || listProc.running) return
|
|
if (sshAgentLoadedForVaultEpoch === root.vaultEpoch) return
|
|
// Marked before the attempt, not after it, so one failed attempt cannot
|
|
// turn into a read that relaunches itself.
|
|
sshAgentLoadedForVaultEpoch = root.vaultEpoch
|
|
applySshAgentLifecycle("startup")
|
|
}
|
|
|
|
onStatusChanged: {
|
|
promoteUnlockToApproval()
|
|
maybeStartupLoad()
|
|
}
|
|
|
|
// The vault is unlocked but its keys are still being read. Ask now rather
|
|
// than after: approving needs the key's identity and the requesting
|
|
// program, and both are already known. The companion records the approval
|
|
// and applies it the moment the keys land, re-checking that the approved
|
|
// key is actually present before it signs.
|
|
function promoteUnlockToApproval() {
|
|
if (root.status !== "unlocked" || !root.sshUnlockRaw || root.sshPrompt) return
|
|
// A listing is satisfied by the load itself; there is no signature to
|
|
// authorise, so it stays a wait rather than becoming an approval.
|
|
if (root.sshUnlockRaw.reason === "list-identities") return
|
|
var raw = root.sshUnlockRaw
|
|
root.sshPromotedOldId = raw.requestId
|
|
root.sshUnlockRequest = null
|
|
root.sshUnlockRaw = null
|
|
root.sshUnlockQueue = []
|
|
showSshApproval(raw)
|
|
}
|
|
|
|
// The bound on the companion's lock acknowledgment. A helper that cannot
|
|
// confirm it has dropped its keys is a helper that must not keep running.
|
|
Timer {
|
|
id: sshAgentLockAckTimer
|
|
interval: Model.sshAgentLockAckTimeoutMs()
|
|
repeat: false
|
|
onTriggered: if (sshAgentProc.running) sshAgentProc.running = false
|
|
}
|
|
|
|
// Disabled / enabled / error, as the design's table defines them. Derived,
|
|
// never stored: it can only ever say what the supervisor is actually doing.
|
|
readonly property var sshAgentSetup: Model.sshAgentSetupState({
|
|
enabled: sshAgentEnabled,
|
|
supervisable: sshAgentSupervisable,
|
|
phase: sshAgentPhase,
|
|
errorCode: sshAgentErrorCode
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Client routing (advisory)
|
|
// -------------------------------------------------------------------------
|
|
//
|
|
// Where SSH_AUTH_SOCK points decides nothing above. The companion binds a
|
|
// deterministic path and never reads it; this is only about whether the
|
|
// user's *clients* will find that socket. The panel sees the graphical
|
|
// session's environment and nothing else, so everything here is phrased as
|
|
// a hint with a check the user can run in the terminal they actually use.
|
|
readonly property string sshAuthSock: Quickshell.env("SSH_AUTH_SOCK") || ""
|
|
readonly property var sshRouting: Model.sshAuthSockDiagnostic(sshAuthSock, sshAgentRuntimeDir)
|
|
|
|
property var uwsmFragment: ({ state: "unknown", removable: false, message: "" })
|
|
readonly property var sshRoutingNotice: Model.sshAgentRoutingNotice(uwsmFragment, sshRouting)
|
|
property bool uwsmBusy: false
|
|
property string uwsmFlash: ""
|
|
// Set when the session already points at another agent. Writing the fragment
|
|
// would make Bitwarden the primary agent at the next login, which is not
|
|
// something to do silently on one click.
|
|
property bool uwsmConfirmPending: false
|
|
|
|
function inspectUwsmFragment() {
|
|
if (uwsmInspectProc.running) return
|
|
uwsmInspectProc.running = true
|
|
}
|
|
|
|
function beginUwsmSetup() {
|
|
if (uwsmBusy) return
|
|
if (sshRouting.state === "elsewhere" && !uwsmConfirmPending) {
|
|
uwsmConfirmPending = true
|
|
return
|
|
}
|
|
uwsmConfirmPending = false
|
|
uwsmBusy = true
|
|
uwsmFlash = ""
|
|
uwsmWriteProc.running = true
|
|
}
|
|
|
|
// Clearing everything the plugin stored outside its own folder. Confirmed
|
|
// rather than absorbed by the first click: it drops a stored master
|
|
// password and every learned suggestion, and none of it comes back.
|
|
property bool pluginDataConfirmPending: false
|
|
property bool pluginDataBusy: false
|
|
property string pluginDataFlash: ""
|
|
|
|
function beginPluginDataRemoval() {
|
|
if (pluginDataBusy) return
|
|
if (!pluginDataConfirmPending) {
|
|
pluginDataConfirmPending = true
|
|
return
|
|
}
|
|
pluginDataConfirmPending = false
|
|
pluginDataBusy = true
|
|
pluginDataFlash = ""
|
|
pluginDataRemoveProc.running = true
|
|
}
|
|
|
|
function cancelPluginDataRemoval() {
|
|
pluginDataConfirmPending = false
|
|
}
|
|
|
|
function onPluginDataRemoved(exitCode, stdout) {
|
|
var result = Model.parsePluginDataRemoval(exitCode, stdout)
|
|
root.pluginDataBusy = false
|
|
root.pluginDataFlash = result.message
|
|
// The keyring entry is part of what was just deleted, so what the panel
|
|
// believes about a stored master password must not be kept.
|
|
if (result.ok) root.fingerprintStored = false
|
|
}
|
|
|
|
function cancelUwsmSetup() {
|
|
uwsmConfirmPending = false
|
|
}
|
|
|
|
// Safe to call unconditionally: the script removes the file only when it is
|
|
// byte-for-byte the one this plugin writes, and refuses a symlink outright.
|
|
function removeUwsmFragment() {
|
|
if (uwsmBusy) return
|
|
uwsmConfirmPending = false
|
|
uwsmBusy = true
|
|
uwsmFlash = ""
|
|
uwsmRemoveProc.running = true
|
|
}
|
|
|
|
function onUwsmActionFinished(exitCode, stdout) {
|
|
var result = Model.parseUwsmActionResult(exitCode, stdout)
|
|
root.uwsmBusy = false
|
|
root.uwsmFlash = result.message
|
|
root.inspectUwsmFragment()
|
|
}
|
|
|
|
// Turning the agent off takes the routing file with it, but only if it is
|
|
// the exact file this plugin wrote. Anything the user manages by hand is
|
|
// left alone with instructions rather than deleted on a toggle.
|
|
//
|
|
// Gated on startup having finished, because this must fire on a real
|
|
// transition and not on the initial evaluation of the binding. Without the
|
|
// guard, every shell start with the feature off would delete a routing file
|
|
// the user never touched -- a filesystem change nobody asked for.
|
|
property bool sshAgentSettingsReady: false
|
|
|
|
onSshAgentEnabledChanged: {
|
|
if (sshAgentEnabled) inspectSshAgentHelper()
|
|
inspectUwsmFragment()
|
|
if (!sshAgentSettingsReady) return
|
|
if (!sshAgentEnabled) {
|
|
// Stopping the helper goes through the supervisor, which knows nothing
|
|
// about the public projection. Without this, the files of a feature
|
|
// that is no longer running are left behind on disk.
|
|
applySshAgentLifecycle("disable")
|
|
removeUwsmFragment()
|
|
return
|
|
}
|
|
// And turning it back on puts the file back, because taking it away on
|
|
// one toggle and not restoring it on the other is a trap: SSH_AUTH_SOCK
|
|
// is fixed at login, so the session that flips the setting keeps working
|
|
// either way and the damage only appears at the next boot, long past the
|
|
// point where anyone would connect the two. The inspection above is
|
|
// asynchronous, so the decision waits for its answer.
|
|
uwsmRestorePending = true
|
|
}
|
|
|
|
// Only ever set by re-enabling the agent, and cleared by the first
|
|
// inspection that follows. It restores what disabling removed; it never
|
|
// routes a session that was not already routed, and it never overrules a
|
|
// file this plugin did not write.
|
|
property bool uwsmRestorePending: false
|
|
|
|
function applyUwsmRestore() {
|
|
if (!uwsmRestorePending) return
|
|
uwsmRestorePending = false
|
|
if (!sshAgentEnabled || uwsmBusy) return
|
|
// "absent" only: a foreign file, a symlink, an unreadable one or no HOME
|
|
// are all cases the plugin refuses to touch, and it must keep refusing
|
|
// here. An agent already owning SSH_AUTH_SOCK is a decision the user
|
|
// makes at the button, with the conflict named.
|
|
if (uwsmFragment.state !== "absent" || sshRouting.state === "elsewhere") return
|
|
beginUwsmSetup()
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Lifecycle & Open / Close
|
|
// -------------------------------------------------------------------------
|
|
|
|
function open() {
|
|
errorMessage = ""
|
|
flashMessage = ""
|
|
revealedFields = ({})
|
|
cursorActive = true
|
|
showDeleteConfirm = false
|
|
totpFollowupActive = false
|
|
isUnlocking = false
|
|
suggestionsDismissed = false
|
|
fingerprintMessage = ""
|
|
|
|
// controller.show() flips `opened`, which runs onPanelOpened via
|
|
// onOpenedChanged. Only drive it directly when the panel was already open
|
|
// and that signal will not fire -- otherwise every open did its startup
|
|
// work twice, including two `bw status` calls at ~3s each.
|
|
var wasOpen = opened
|
|
root.controller.show()
|
|
if (wasOpen) onPanelOpened()
|
|
}
|
|
|
|
function close() {
|
|
errorMessage = ""
|
|
revealedFields = ({})
|
|
showDeleteConfirm = false
|
|
totpFollowupActive = false
|
|
isUnlocking = false
|
|
cancelAuthPrewarm()
|
|
if (pendingSecondFactorLogin()) suspendPendingLogin()
|
|
else abandonAuthSecrets()
|
|
// Closing a setup form is cancellation even if its keyring writer has
|
|
// already started; its completion handler will clear a stale write.
|
|
abandonPinSetup()
|
|
abandonFingerprintSetup()
|
|
cancelFingerprintUnlock()
|
|
cancelAttachmentDownloads()
|
|
stopGeneratorServe()
|
|
root.controller.hide()
|
|
}
|
|
|
|
function toggle() {
|
|
if (opened) close()
|
|
else open()
|
|
}
|
|
|
|
function detectActiveWindowContext() {
|
|
if (!suggestOnOpen) return
|
|
activeWindowProc.command = Model.activeWindowCommand()
|
|
activeWindowProc.running = true
|
|
}
|
|
|
|
function loadAssociations() {
|
|
if (associationsReadProc.running) return
|
|
associationsReadEpoch = associationsEpoch
|
|
associationsReadProc.command = Model.associationsReadCommand()
|
|
associationsReadProc.running = true
|
|
}
|
|
|
|
function onAssociationsLoaded(raw) {
|
|
if (associationsReadEpoch !== associationsEpoch) return
|
|
associations = Model.parseAssociations(raw)
|
|
if (activeWindowData) handleActiveWindowDetected(activeWindowData)
|
|
}
|
|
|
|
function saveAssociations(next) {
|
|
associations = next
|
|
pendingAssociationsJson = Model.serializeAssociations(next)
|
|
if (associationsWriteProc.running) {
|
|
associationsWritePending = true
|
|
return
|
|
}
|
|
associationsWritePending = false
|
|
associationsWriteProc.running = true
|
|
}
|
|
|
|
// Called whenever the user acts on an item while a window context is active.
|
|
// Silent by design: teaching happens as a side effect of normal use.
|
|
function learnFromPick(item) {
|
|
if (!suggestOnOpen || !item || !item.id || !detectedContext || !Model.isLoginItem(item)) return
|
|
if (Model.isAssociated(associations, detectedContext, item.id)) return
|
|
saveAssociations(Model.recordAssociation(associations, detectedContext, item.id, new Date().toISOString()))
|
|
}
|
|
|
|
// Explicit pin/unpin from the detail view.
|
|
function toggleAssociation(item) {
|
|
if (!item || !item.id || !detectedContext || !Model.isLoginItem(item)) return
|
|
if (Model.isAssociated(associations, detectedContext, item.id)) {
|
|
saveAssociations(Model.forgetAssociation(associations, detectedContext, item.id))
|
|
flashNotification("No longer suggested for " + detectedContext.displayName)
|
|
} else {
|
|
saveAssociations(Model.recordAssociation(associations, detectedContext, item.id, new Date().toISOString()))
|
|
flashNotification("Always suggested for " + detectedContext.displayName)
|
|
}
|
|
if (activeWindowData) handleActiveWindowDetected(activeWindowData)
|
|
}
|
|
|
|
function handleActiveWindowDetected(data) {
|
|
activeWindowData = data
|
|
if (!suggestOnOpen) {
|
|
suggestedItems = []
|
|
detectedContext = null
|
|
rebuildFilter()
|
|
return
|
|
}
|
|
if (items.length === 0) {
|
|
return
|
|
}
|
|
var res = Model.findContextualMatches(items, data, associations)
|
|
detectedContext = res.context
|
|
suggestedItems = res.matches
|
|
learnedIds = res.learnedIds || ({})
|
|
rebuildFilter()
|
|
}
|
|
|
|
// Every field on the login screen, and every field on the unlock screen.
|
|
// focusAppropriateField() consults these before it moves the cursor.
|
|
function loginFieldHasFocus() {
|
|
return emailField.activeFocus || loginPassField.activeFocus
|
|
|| code2faField.activeFocus || deviceCodeField.activeFocus
|
|
|| serverUrlField.activeFocus
|
|
|| apiClientIdField.activeFocus || apiClientSecretField.activeFocus
|
|
|| apiMasterField.activeFocus
|
|
}
|
|
|
|
function unlockFieldHasFocus() {
|
|
return passField.activeFocus || pinField.activeFocus
|
|
}
|
|
|
|
// Put the cursor somewhere sensible when a screen appears -- not hold it
|
|
// there. Those are the same thing right up until something announces a
|
|
// screen the user is already typing on, and something does: a logout sets
|
|
// the status itself and then runs `bw status` to confirm it, which takes a
|
|
// few seconds and arrives to say "unauthenticated" in the middle of the
|
|
// master password being typed. Re-focusing on that news moved the cursor
|
|
// from the password field to the email field mid-word, so the rest of the
|
|
// password went into an unmasked field that was about to be submitted as an
|
|
// email address.
|
|
//
|
|
// So a screen that already holds the cursor keeps it. Moving between screens
|
|
// still focuses, because the field holding focus then belongs to the screen
|
|
// being left rather than the one arriving.
|
|
function focusAppropriateField() {
|
|
if (sshApprovalPopupOpen) return
|
|
Qt.callLater(function() {
|
|
// Setup has no field to type into, and the ones this would reach for are
|
|
// on screens that are not showing.
|
|
if (currentScreen === "setup") return
|
|
if (status === "unlocked" && currentScreen === "main") {
|
|
if (!searchField.activeFocus) searchField.forceActiveFocus()
|
|
} else if (status === "locked" || status === "checking") {
|
|
if (unlockFieldHasFocus()) return
|
|
if (pinReady) pinField.forceActiveFocus()
|
|
else passField.forceActiveFocus()
|
|
} else if (status === "unauthenticated") {
|
|
if (loginFieldHasFocus()) return
|
|
// A login resumed on a challenge opens on the field that is waiting,
|
|
// not back at the top of the form.
|
|
if (showDeviceCodeField) deviceCodeField.forceActiveFocus()
|
|
else if (show2faField) code2faField.forceActiveFocus()
|
|
else if (!show2faMethodPicker) emailField.forceActiveFocus()
|
|
}
|
|
})
|
|
}
|
|
|
|
onOpenedChanged: {
|
|
if (opened) onPanelOpened()
|
|
else {
|
|
cancelFingerprintUnlock()
|
|
cancelAuthPrewarm()
|
|
if (pendingSecondFactorLogin()) suspendPendingLogin()
|
|
else abandonAuthSecrets()
|
|
// A closed panel must not keep a field focused, or the next open would
|
|
// count as "already typing here" and skip the field the screen opens on.
|
|
keyCatcher.forceActiveFocus()
|
|
}
|
|
}
|
|
|
|
function onPanelOpened() {
|
|
// A pending login that outlived its window is gone, not resumed.
|
|
if (secondFactorStartedAt > 0
|
|
&& !Model.secondFactorWindowOpen(secondFactorStartedAt, Date.now())) {
|
|
abandonAuthSecrets()
|
|
}
|
|
focusAppropriateField()
|
|
detectActiveWindowContext()
|
|
refreshFingerprintAvailability()
|
|
|
|
// A signing request outranks the item list: it is the reason the panel
|
|
// opened, and a client is blocked on the answer.
|
|
if (sshPrompt) {
|
|
currentScreen = "sshApproval"
|
|
return
|
|
}
|
|
if (status === "unlocked") {
|
|
currentScreen = "main"
|
|
ensureItemsFresh()
|
|
} else if (status === "locked") {
|
|
// Still check for a handed-over session: a terminal login leaves the
|
|
// panel locked, which is precisely when the handoff matters.
|
|
refreshStatus()
|
|
prepareUnlock()
|
|
startFingerprintUnlock()
|
|
} else {
|
|
refreshStatus()
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Status & Keyring Handlers
|
|
// -------------------------------------------------------------------------
|
|
|
|
function refreshStatus() {
|
|
errorMessage = ""
|
|
if (logoutPending) return
|
|
// The dependency probe owns the first status transition. Opening the
|
|
// panel before that short probe returns must wait rather than trying to
|
|
// execute a CLI that a first-run install may not have yet.
|
|
if (!depsChecked) {
|
|
checkDependencies()
|
|
return
|
|
}
|
|
// Nothing to ask while a required tool is missing. Every caller reaches
|
|
// here on some ordinary event -- a panel open, an IPC nudge -- and none of
|
|
// them should be able to walk the user past setup into a login form that
|
|
// has no CLI behind it.
|
|
if (setupGated) {
|
|
currentScreen = "setup"
|
|
return
|
|
}
|
|
// Past the gate, so the vault has been asked about. Recorded here rather
|
|
// than at the one call site that waits on the dependency probe, so a panel
|
|
// opened before that probe reports does not earn a second `bw status` --
|
|
// three seconds each, and the first open is where they are felt.
|
|
statusProbeStarted = true
|
|
// A terminal login may have left a session waiting. Check before anything
|
|
// else, including the locked-with-no-session short circuit below, since
|
|
// that is exactly the state a terminal login leaves the panel in.
|
|
//
|
|
// Only a login this panel actually launched, and only for as long as one
|
|
// could still be in progress. Outside that window the file is removed
|
|
// rather than read: nobody is expecting a key, so nothing adopts it, and
|
|
// leaving a live one in the runtime directory is the worse outcome.
|
|
if (sessionHandoffProc.running) return
|
|
var expecting = Model.handoffWindowOpen(terminalLoginStartedAt, Date.now())
|
|
if (!expecting) terminalLoginStartedAt = 0
|
|
beginEpochOperation("sessionHandoff")
|
|
sessionHandoffProc.command = Model.sessionHandoffReadCommand(expecting)
|
|
sessionHandoffProc.running = true
|
|
}
|
|
|
|
function onSessionHandoff(raw) {
|
|
if (epochOperationIsStale("sessionHandoff")) return
|
|
var handed = Model.extractSessionToken(String(raw || "").trim())
|
|
if (handed) {
|
|
cancelAuthPrewarm()
|
|
abandonAuthSecrets()
|
|
// Consumed, so the window shuts behind it rather than staying open for
|
|
// whatever is written there next.
|
|
terminalLoginStartedAt = 0
|
|
session = handed
|
|
vaultEpoch += 1
|
|
storeCurrentSession()
|
|
|
|
// bw minted this key moments ago, so trust it and start loading rather
|
|
// than spending another `bw status` (~3.3s) to be told what we know.
|
|
// The status check still runs, but alongside the loads instead of in
|
|
// front of them -- it only fills in the account email.
|
|
status = "unlocked"
|
|
currentScreen = "main"
|
|
itemsLoadedAt = 0
|
|
statusRefreshAfterItems = true
|
|
beginInitialVaultLoad(true, false)
|
|
resetAutoLockTimer()
|
|
focusAppropriateField()
|
|
flashNotification("Signed in from the terminal")
|
|
return
|
|
}
|
|
|
|
if (status === "locked" && !session) return
|
|
|
|
if (session) {
|
|
runStatusCheck()
|
|
} else if (rememberSession && status !== "locked") {
|
|
beginEpochOperation("keyringLookup")
|
|
keyringLookupProc.command = Model.keyringLookupCommand()
|
|
keyringLookupProc.running = true
|
|
} else {
|
|
runStatusCheck()
|
|
}
|
|
}
|
|
|
|
function onKeyringLookupFinished(rawToken) {
|
|
if (epochOperationIsStale("keyringLookup")) return
|
|
var token = String(rawToken || "").trim()
|
|
if (token) {
|
|
session = token
|
|
vaultEpoch += 1
|
|
}
|
|
runStatusCheck()
|
|
}
|
|
|
|
function runStatusCheck(authoritative) {
|
|
if (statusProc.running) return
|
|
statusCheckAuthoritative = authoritative !== false
|
|
beginEpochOperation("status")
|
|
statusProc.command = Model.statusCommand()
|
|
statusProc.running = true
|
|
}
|
|
|
|
// An authentication the user has actually submitted, still running.
|
|
function authAttemptInFlight() {
|
|
return loginSubmitted || unlockSubmitted
|
|
}
|
|
|
|
function onStatusFinished(rawJson) {
|
|
if (epochOperationIsStale("status")) return
|
|
// A `bw status` answers about the world as it was when it started, and it
|
|
// takes seconds. Landing mid-login, that answer is "unauthenticated" --
|
|
// truthfully, for the moment it was asked -- and acting on it cancelled the
|
|
// login in flight: SIGTERM to a process the user had just submitted, the
|
|
// button dropping back out of "Verifying...", and nothing shown at all. The
|
|
// attempt is the newer news; it will set the state itself when it lands.
|
|
if (authAttemptInFlight()) {
|
|
return
|
|
}
|
|
isLoading = false
|
|
var authoritative = statusCheckAuthoritative
|
|
statusCheckAuthoritative = true
|
|
var st = Model.parseStatus(rawJson)
|
|
if (!authoritative) {
|
|
if (st && st.userEmail) {
|
|
userEmail = st.userEmail
|
|
if (!loginEmail) loginEmail = st.userEmail
|
|
}
|
|
return
|
|
}
|
|
if (!st) {
|
|
cancelAuthPrewarm()
|
|
if (vaultStatePresent()) {
|
|
if (session) requestSessionCredentialClear()
|
|
dropVaultState()
|
|
}
|
|
status = "unauthenticated"
|
|
currentScreen = "login"
|
|
focusAppropriateField()
|
|
return
|
|
}
|
|
|
|
userEmail = st.userEmail
|
|
if (st.userEmail && !loginEmail) {
|
|
loginEmail = st.userEmail
|
|
}
|
|
|
|
if (st.unlocked) {
|
|
cancelAuthPrewarm()
|
|
abandonAuthSecrets()
|
|
status = "unlocked"
|
|
currentScreen = "main"
|
|
ensureItemsFresh()
|
|
resetAutoLockTimer()
|
|
focusAppropriateField()
|
|
// A vault that has never synced holds no ciphers, so the item list is
|
|
// empty and correct -- which looks exactly like a vault with nothing in
|
|
// it. `bw login` is supposed to have synced by now, and reports success
|
|
// whether or not it managed to: it calls fullSync() without
|
|
// allowThrowOnError, so a sync that throws is swallowed, lastSync is
|
|
// never set, and the session it prints is a working session onto an
|
|
// empty local vault. That is not a state to render as an empty vault,
|
|
// so repair it once and reload.
|
|
if (!st.lastSync && session && !initialSyncAttempted && !isSyncing) {
|
|
initialSyncAttempted = true
|
|
syncVault()
|
|
}
|
|
} else if (st.locked) {
|
|
if (vaultStatePresent()) {
|
|
if (session) requestSessionCredentialClear()
|
|
dropVaultState()
|
|
}
|
|
status = "locked"
|
|
currentScreen = "locked"
|
|
focusAppropriateField()
|
|
if (sshAuthSurfaceActive) prepareUnlock()
|
|
if (sshAuthSurfaceActive) startFingerprintUnlock()
|
|
} else {
|
|
cancelAuthPrewarm()
|
|
if (vaultStatePresent()) {
|
|
if (session) requestSessionCredentialClear()
|
|
dropVaultState()
|
|
}
|
|
status = "unauthenticated"
|
|
currentScreen = "login"
|
|
focusAppropriateField()
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// In-Plugin Login & Authentication
|
|
// -------------------------------------------------------------------------
|
|
|
|
function emailLoginSignature() {
|
|
return String(loginEmail || "").trim() + "\n"
|
|
+ resolvedLoginServerUrl() + "\n"
|
|
+ (String(login2faCode || "").trim() ? "2fa" : "plain") + "\n"
|
|
+ String(login2faMethod)
|
|
}
|
|
|
|
function resolvedLoginServerUrl() {
|
|
return Model.loginServerUrlFor(loginServerRegion, loginServerUrl)
|
|
}
|
|
|
|
function selectLoginServerRegion(region) {
|
|
if (loginServerRegion === region) return
|
|
loginServerRegion = region
|
|
errorMessage = ""
|
|
resetEmailLoginSecondFactor()
|
|
invalidateEmailLoginPrewarm()
|
|
}
|
|
|
|
function invalidateEmailLoginPrewarm() {
|
|
if (loginSubmitted) return
|
|
if (loginSubmitAfterPrewarmStop) isLoading = false
|
|
loginSubmitAfterPrewarmStop = false
|
|
loginPrepareAfterPrewarmStop = false
|
|
loginPrewarmSignature = ""
|
|
if (loginProc.running) loginProc.running = false
|
|
}
|
|
|
|
function resetEmailLoginSecondFactor() {
|
|
show2faField = false
|
|
login2faCode = ""
|
|
loginDeviceVerification = false
|
|
show2faMethodPicker = false
|
|
login2faMethodConfirmed = false
|
|
showDeviceCodeField = false
|
|
loginDeviceCode = ""
|
|
// Back to the remembered method, not to nothing: a fresh attempt should
|
|
// start from what worked last time.
|
|
login2faMethod = rememberedTwoFactorMethod
|
|
syncLoginFieldsToState()
|
|
}
|
|
|
|
// The user answering bw's provider question. The pick is not trusted yet --
|
|
// it is sent on its own first, without a code, which makes bw either mail
|
|
// the code (Email), accept it silently (Authenticator, YubiKey), or say the
|
|
// account does not have it. So a wrong pick costs nothing typed.
|
|
function chooseTwoFactorMethod(method) {
|
|
if (!Model.isTwoFactorMethod(method)) return
|
|
errorMessage = ""
|
|
login2faMethod = method
|
|
login2faMethodConfirmed = true
|
|
show2faMethodPicker = false
|
|
show2faField = false
|
|
login2faCode = ""
|
|
submitLogin()
|
|
}
|
|
|
|
// Answering bw's new-device prompt, which is the only challenge it will not
|
|
// take from a flag. The code the user just typed goes to the command's
|
|
// environment, the password down the usual FIFO, and bw runs with its
|
|
// prompts enabled for this one call.
|
|
function submitDeviceVerification() {
|
|
if (loginSubmitted) return
|
|
var code = String(loginDeviceCode || "").trim()
|
|
if (!code) {
|
|
errorMessage = "Enter the code Bitwarden emailed you."
|
|
Qt.callLater(function() { deviceCodeField.forceActiveFocus() })
|
|
return
|
|
}
|
|
if (!String(loginPassword || "")) {
|
|
errorMessage = "Your master password is needed again for this step."
|
|
resetEmailLoginSecondFactor()
|
|
Qt.callLater(function() { loginPassField.forceActiveFocus() })
|
|
return
|
|
}
|
|
errorMessage = ""
|
|
isLoading = true
|
|
// A prewarmed process was started for the ordinary login and cannot answer
|
|
// this; stop it and start the interactive one when it is gone.
|
|
if (loginProc.running) {
|
|
deviceVerificationPending = true
|
|
loginSubmitAfterPrewarmStop = false
|
|
loginPrepareAfterPrewarmStop = false
|
|
loginProc.running = false
|
|
return
|
|
}
|
|
startDeviceVerificationLogin()
|
|
}
|
|
|
|
function startDeviceVerificationLogin() {
|
|
deviceVerificationPending = false
|
|
loginPrewarmSignature = ""
|
|
loginAttemptHadCode = false
|
|
loginAttemptMethod = login2faMethod
|
|
// Set before the process starts, because both the environment binding and
|
|
// the exit handler read it.
|
|
deviceVerificationAttempt = true
|
|
loginProc.command = Model.deviceVerificationLoginCommand(
|
|
String(loginEmail || "").trim(), resolvedLoginServerUrl(), login2faMethod)
|
|
loginProc.running = true
|
|
loginSubmitted = true
|
|
writeAuthPassword("login", loginPassword)
|
|
}
|
|
|
|
// A login stopped on a challenge it cannot answer without leaving the panel.
|
|
// Only these survive a close, only while the window is open, and only while
|
|
// there is still a password to submit with the answer.
|
|
function pendingSecondFactorLogin() {
|
|
if (status !== "unauthenticated" || loginMethod !== "email") return false
|
|
if (!show2faField && !showDeviceCodeField && !show2faMethodPicker) return false
|
|
if (!String(loginPassword || "")) return false
|
|
return Model.secondFactorWindowOpen(secondFactorStartedAt, Date.now())
|
|
}
|
|
|
|
// Typing into a TextField assigns to its own `text`, which breaks the binding
|
|
// back to the property behind it. After that the two are independent, and
|
|
// clearing the property alone leaves the field showing what was typed --
|
|
// while every submit reads the property. That is exactly how a login came to
|
|
// be sent with no code at all while the user was looking at a filled-in
|
|
// field: bw answered "Code is required.", the panel reported the code as
|
|
// rejected, and retyping it repaired the property so the next click worked.
|
|
//
|
|
// So a field is never cleared by clearing what is behind it. These go
|
|
// together, always.
|
|
function syncLoginFieldsToState() {
|
|
code2faField.text = login2faCode
|
|
deviceCodeField.text = loginDeviceCode
|
|
loginPassField.text = loginPassword
|
|
apiMasterField.text = loginPassword
|
|
apiClientIdField.text = loginClientId
|
|
apiClientSecretField.text = loginClientSecret
|
|
}
|
|
|
|
// Closing on a challenge keeps the stage and the password, and drops the
|
|
// code -- whatever was half-typed before going to look it up is not the code
|
|
// that is about to be read.
|
|
function suspendPendingLogin() {
|
|
login2faCode = ""
|
|
loginDeviceCode = ""
|
|
loginSubmitted = false
|
|
isLoading = false
|
|
syncLoginFieldsToState()
|
|
}
|
|
|
|
// What a stopped login process owes whoever stopped it. `mayScrub` is false
|
|
// when the run that just ended was itself the scrub, so one cannot schedule
|
|
// another.
|
|
function resumeDeferredLogin(mayScrub) {
|
|
if (deviceVerificationPending) {
|
|
deviceVerificationPending = false
|
|
Qt.callLater(startDeviceVerificationLogin)
|
|
} else if (loginSubmitAfterPrewarmStop) {
|
|
loginSubmitAfterPrewarmStop = false
|
|
Qt.callLater(submitLogin)
|
|
} else if (loginPrepareAfterPrewarmStop) {
|
|
loginPrepareAfterPrewarmStop = false
|
|
Qt.callLater(prepareEmailLogin)
|
|
} else if (mayScrub) {
|
|
clearProcessCollectorSoon(loginProc)
|
|
}
|
|
}
|
|
|
|
function markSecondFactorStage() {
|
|
secondFactorStartedAt = Date.now()
|
|
}
|
|
|
|
function reopenTwoFactorMethodPicker() {
|
|
errorMessage = ""
|
|
show2faField = false
|
|
login2faCode = ""
|
|
show2faMethodPicker = true
|
|
markSecondFactorStage()
|
|
}
|
|
|
|
function emailLoginButtonText() {
|
|
if (logoutCleanupFailed) return "Retry Logout Cleanup"
|
|
if (logoutPending) return "Finishing logout..."
|
|
if (isLoading) return show2faField ? "Verifying..." : "Logging in..."
|
|
return show2faField ? "Verify & Unlock" : "Log In & Unlock"
|
|
}
|
|
|
|
function prepareEmailLogin() {
|
|
if (logoutPending || !opened || status !== "unauthenticated" || loginMethod !== "email" || isLoading) return
|
|
var email = String(loginEmail || "").trim()
|
|
var serverUrl = resolvedLoginServerUrl()
|
|
if (!email || Model.validateServerUrl(serverUrl)) return
|
|
// Configuring a custom server changes bw's persistent global state. Do it
|
|
// only after explicit submission, never merely because the password field
|
|
// received focus. Default-cloud logins still get the full prewarm win.
|
|
if (serverUrl) return
|
|
|
|
var signature = emailLoginSignature()
|
|
if (loginProc.running) {
|
|
if (loginPrewarmSignature === signature) return
|
|
loginPrepareAfterPrewarmStop = true
|
|
loginProc.running = false
|
|
return
|
|
}
|
|
|
|
loginPrepareAfterPrewarmStop = false
|
|
loginPrewarmSignature = signature
|
|
loginSubmitted = false
|
|
deviceVerificationAttempt = false
|
|
loginAttemptHadCode = String(login2faCode || "").trim().length > 0
|
|
loginAttemptMethod = login2faMethod
|
|
loginProc.command = Model.emailLoginPrewarmCommand(
|
|
email, loginAttemptHadCode, serverUrl, login2faMethod)
|
|
loginProc.running = true
|
|
}
|
|
|
|
function prepareUnlock() {
|
|
if (!sshAuthSurfaceActive || status !== "locked" || unlockProc.running) return
|
|
unlockSubmitted = false
|
|
unlockProc.command = Model.unlockPrewarmCommand()
|
|
unlockProc.running = true
|
|
}
|
|
|
|
function cancelAuthPrewarm() {
|
|
authPasswordWriteTarget = ""
|
|
authPasswordWriteValue = ""
|
|
unlockSubmitted = false
|
|
loginSubmitted = false
|
|
loginSubmitAfterPrewarmStop = false
|
|
loginPrepareAfterPrewarmStop = false
|
|
loginPrewarmSignature = ""
|
|
if (authPasswordWriterProc.running) authPasswordWriterProc.running = false
|
|
if (unlockProc.running) unlockProc.running = false
|
|
if (loginProc.running) loginProc.running = false
|
|
}
|
|
|
|
function abandonAuthSecrets() {
|
|
masterPassword = ""
|
|
loginPassword = ""
|
|
loginClientId = ""
|
|
loginClientSecret = ""
|
|
login2faCode = ""
|
|
show2faField = false
|
|
loginDeviceVerification = false
|
|
loginAttemptHadCode = false
|
|
show2faMethodPicker = false
|
|
login2faMethodConfirmed = false
|
|
login2faMethod = rememberedTwoFactorMethod
|
|
loginAttemptMethod = -1
|
|
showDeviceCodeField = false
|
|
loginDeviceCode = ""
|
|
deviceVerificationAttempt = false
|
|
deviceVerificationPending = false
|
|
secondFactorStartedAt = 0
|
|
loginPasswordRetryUsed = false
|
|
pendingUnlockPassword = ""
|
|
pendingUnlockFrom = ""
|
|
authPasswordWriteValue = ""
|
|
pinEntry = ""
|
|
pinUnlockSubmitted = false
|
|
fingerprintAuthorized = false
|
|
syncLoginFieldsToState()
|
|
}
|
|
|
|
function writeAuthPassword(channel, password) {
|
|
authPasswordWriteTarget = channel
|
|
authPasswordWriteValue = String(password === undefined || password === null ? "" : password)
|
|
authPasswordWriterProc.command = Model.authPasswordWriteCommand(channel)
|
|
authPasswordWriterProc.running = true
|
|
}
|
|
|
|
function onAuthPasswordWriterExited(exitCode) {
|
|
var target = authPasswordWriteTarget
|
|
authPasswordWriteTarget = ""
|
|
authPasswordWriteValue = ""
|
|
if (exitCode === 0) {
|
|
loginPasswordRetryUsed = false
|
|
return
|
|
}
|
|
if (!target) return
|
|
|
|
if (target === "unlock") {
|
|
unlockSubmitted = false
|
|
isUnlocking = false
|
|
if (unlockProc.running) unlockProc.running = false
|
|
errorMessage = "Could not deliver the password to Bitwarden. Please try again."
|
|
Qt.callLater(prepareUnlock)
|
|
} else if (target === "login") {
|
|
loginSubmitted = false
|
|
isLoading = false
|
|
if (loginProc.running) loginProc.running = false
|
|
// The writer polls for bw's FIFO and gives up if bw has not opened it in
|
|
// time, which a cold start after the panel has been closed can outrun.
|
|
// Unlock has always re-armed itself here; login left the button for the
|
|
// user to press again, which is what having to click Verify twice was.
|
|
// Once, so a genuinely broken delivery still reports rather than looping.
|
|
if (!loginPasswordRetryUsed) {
|
|
loginPasswordRetryUsed = true
|
|
var retryDevice = deviceVerificationAttempt
|
|
deviceVerificationAttempt = false
|
|
Qt.callLater(retryDevice ? submitDeviceVerification : submitLogin)
|
|
return
|
|
}
|
|
errorMessage = "Could not deliver the password to Bitwarden. Please try again."
|
|
}
|
|
}
|
|
|
|
function submitLogin() {
|
|
if (loginSubmitted) return
|
|
errorMessage = ""
|
|
if (logoutPending) {
|
|
errorMessage = "Finishing logout. Please wait a moment."
|
|
return
|
|
}
|
|
|
|
// Checked before either branch, because both send the master password to
|
|
// whatever this names. See validateServerUrl() for what it refuses.
|
|
var serverUrl = resolvedLoginServerUrl()
|
|
var serverProblem = Model.validateServerUrl(serverUrl)
|
|
if (serverProblem) {
|
|
errorMessage = serverProblem
|
|
return
|
|
}
|
|
|
|
if (loginMethod === "email") {
|
|
var email = String(loginEmail || "").trim()
|
|
var pass = String(loginPassword === undefined || loginPassword === null ? "" : loginPassword)
|
|
if (!email) {
|
|
errorMessage = "Email address is required"
|
|
return
|
|
}
|
|
if (!pass) {
|
|
errorMessage = "Master password is required"
|
|
return
|
|
}
|
|
if (show2faMethodPicker) {
|
|
errorMessage = "Choose a two-step method to continue."
|
|
return
|
|
}
|
|
if (show2faField && !String(login2faCode || "").trim()) {
|
|
errorMessage = "Two-step verification code is required"
|
|
Qt.callLater(function() { code2faField.forceActiveFocus() })
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
deviceVerificationAttempt = false
|
|
var signature = emailLoginSignature()
|
|
if (loginProc.running && loginPrewarmSignature !== signature) {
|
|
loginPrepareAfterPrewarmStop = false
|
|
loginSubmitAfterPrewarmStop = true
|
|
loginProc.running = false
|
|
return
|
|
}
|
|
if (!loginProc.running) {
|
|
loginPrewarmSignature = signature
|
|
loginAttemptHadCode = login2faCode.trim().length > 0
|
|
loginAttemptMethod = login2faMethod
|
|
loginProc.command = Model.emailLoginPrewarmCommand(
|
|
email, loginAttemptHadCode, serverUrl, login2faMethod)
|
|
loginProc.running = true
|
|
}
|
|
loginSubmitted = true
|
|
writeAuthPassword("login", pass)
|
|
} else {
|
|
var id = String(loginClientId || "").trim()
|
|
var secret = String(loginClientSecret || "").trim()
|
|
var pass2 = String(loginPassword === undefined || loginPassword === null ? "" : loginPassword)
|
|
|
|
if (!id) {
|
|
errorMessage = "API Client ID is required"
|
|
return
|
|
}
|
|
if (!secret) {
|
|
errorMessage = "API Client Secret is required"
|
|
return
|
|
}
|
|
if (!pass2) {
|
|
errorMessage = "Master password is required to unlock vault"
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
if (loginProc.running) {
|
|
loginPrepareAfterPrewarmStop = false
|
|
loginSubmitAfterPrewarmStop = true
|
|
loginProc.running = false
|
|
return
|
|
}
|
|
// Client ID, client secret and password all travel in the environment.
|
|
loginSubmitted = true
|
|
loginPrewarmSignature = ""
|
|
loginAttemptHadCode = false
|
|
loginAttemptMethod = -1
|
|
loginProc.command = Model.apiKeyLoginCommand(serverUrl)
|
|
loginProc.running = true
|
|
}
|
|
}
|
|
|
|
// Every exit from onLoginOutput says which branch it took. Read with:
|
|
// quickshell log -f | grep qs-bitwarden
|
|
function logLogin(branch, out, err, exitCode) {
|
|
console.log("qs-bitwarden login " + Model.loginDiagnostic(out, err, exitCode, branch))
|
|
}
|
|
|
|
function onLoginOutput(stdoutText, stderrText, exitCode) {
|
|
isLoading = false
|
|
loginPrewarmSignature = ""
|
|
var out = String(stdoutText || "").trim()
|
|
var err = String(stderrText || "").trim()
|
|
var wasDeviceAttempt = deviceVerificationAttempt
|
|
deviceVerificationAttempt = false
|
|
|
|
// The interactive login answers for itself. Its output is a prompt session
|
|
// rather than one of bw's one-line refusals, so none of the detectors
|
|
// below should be allowed to read it.
|
|
if (wasDeviceAttempt && !(exitCode === 0 && out.length > 10)) {
|
|
var detail = Model.sanitizeInteractiveStderr(err, loginDeviceCode)
|
|
loginDeviceCode = ""
|
|
loginDeviceVerification = true
|
|
// 124 is `timeout`; the prompt error is inquirer finding nothing left to
|
|
// read. Both mean bw wanted something this login could not give it, and
|
|
// a terminal is the only thing that can.
|
|
if (exitCode === 124 || Model.loginPromptRanOutOfInput(out, err)) {
|
|
showDeviceCodeField = false
|
|
logLogin("device-unanswerable", out, err, exitCode)
|
|
errorMessage = "This login asked for something the panel could not answer. "
|
|
+ "Finish it in a terminal instead."
|
|
return
|
|
}
|
|
logLogin("device-code-rejected", out, err, exitCode)
|
|
showDeviceCodeField = true
|
|
markSecondFactorStage()
|
|
errorMessage = detail
|
|
? "Device verification failed: " + detail
|
|
: "That verification code was not accepted. Use the newest email and try again."
|
|
Qt.callLater(function() { deviceCodeField.forceActiveFocus() })
|
|
return
|
|
}
|
|
|
|
// Checked before the second-factor branch, which matches the same sentence.
|
|
// A code went out and bw still says a code is required, so this is the
|
|
// new-device challenge -- asking for the code again would loop forever on
|
|
// one bw cannot be given. The terminal login can answer it.
|
|
if (Model.loginNeedsDeviceVerification(out, err, loginAttemptHadCode)) {
|
|
resetEmailLoginSecondFactor()
|
|
loginDeviceVerification = true
|
|
showDeviceCodeField = true
|
|
markSecondFactorStage()
|
|
errorMessage = "Bitwarden needs to verify this device. Enter the code it emailed you."
|
|
logLogin("device-verification", out, err, exitCode)
|
|
Qt.callLater(function() { deviceCodeField.forceActiveFocus() })
|
|
return
|
|
}
|
|
|
|
// No --method can answer this one and no terminal helps: the account's
|
|
// two-step methods are ones the CLI cannot perform at all.
|
|
if (Model.loginHasNoUsableProvider(out, err)) {
|
|
resetEmailLoginSecondFactor()
|
|
logLogin("no-usable-provider", out, err, exitCode)
|
|
errorMessage = "This account's two-step method is one the Bitwarden CLI cannot use, "
|
|
+ "such as a passkey or Duo. Log in with an API key instead."
|
|
return
|
|
}
|
|
|
|
// bw asking which two-step method to use. Answering it by guessing is what
|
|
// costs a real failed attempt, so the panel puts the question to the user.
|
|
if (Model.loginNeedsMethodChoice(out, err)) {
|
|
// A method that was only remembered, never confirmed against this
|
|
// account, is the likeliest thing to be wrong here -- shell.json holds
|
|
// one method for whichever account logged in last. Drop it and let the
|
|
// untargeted attempt say what this account actually needs. The method
|
|
// only ever goes from set to unset here, so this cannot loop.
|
|
if (Model.isTwoFactorMethod(loginAttemptMethod) && !login2faMethodConfirmed) {
|
|
forgetTwoFactorMethod()
|
|
login2faMethod = -1
|
|
loginAttemptMethod = -1
|
|
logLogin("method-stale-retry", out, err, exitCode)
|
|
Qt.callLater(submitLogin)
|
|
return
|
|
}
|
|
var rejectedMethod = login2faMethodConfirmed
|
|
? Model.twoFactorMethodLabel(loginAttemptMethod) : ""
|
|
show2faField = false
|
|
login2faCode = ""
|
|
login2faMethod = -1
|
|
login2faMethodConfirmed = false
|
|
show2faMethodPicker = true
|
|
markSecondFactorStage()
|
|
errorMessage = rejectedMethod
|
|
? "Bitwarden does not have " + rejectedMethod + " set up for this account. "
|
|
+ "Choose another method."
|
|
: "This account has more than one two-step method. Choose the one you use."
|
|
logLogin("method-choice", out, err, exitCode)
|
|
return
|
|
}
|
|
|
|
if (Model.loginNeedsSecondFactor(out, err)) {
|
|
// A code must never be sent without the method it belongs to. bw only
|
|
// puts the token on the wire when a provider came with it, so without
|
|
// --method the first request is a bare password grant -- and for an
|
|
// email provider the server answers that by issuing a fresh code,
|
|
// invalidating the one the user is about to type. Confirmed against
|
|
// bw 2026.2.0: the same command with --method succeeds and without it
|
|
// returns "Two-step token is invalid."
|
|
//
|
|
// The method cannot be inferred, so it is asked for once per account
|
|
// before any code is collected. An authenticator would survive being
|
|
// asked in the wrong order; an emailed code would not.
|
|
if (!Model.isTwoFactorMethod(login2faMethod)) {
|
|
show2faField = false
|
|
login2faCode = ""
|
|
show2faMethodPicker = true
|
|
markSecondFactorStage()
|
|
syncLoginFieldsToState()
|
|
errorMessage = "Two-step verification is required. Choose the method this account uses."
|
|
logLogin("second-factor-needs-method", out, err, exitCode)
|
|
return
|
|
}
|
|
var secondFactorWasVisible = show2faField
|
|
show2faMethodPicker = false
|
|
show2faField = true
|
|
markSecondFactorStage()
|
|
logLogin("second-factor", out, err, exitCode)
|
|
errorMessage = secondFactorWasVisible
|
|
? "That two-step verification code was not accepted. Please try again."
|
|
: "Two-step verification is required. Enter your code to continue."
|
|
Qt.callLater(function() { code2faField.forceActiveFocus() })
|
|
return
|
|
}
|
|
|
|
if (exitCode === 0 && out.length > 10) {
|
|
rememberTwoFactorMethod(login2faMethod)
|
|
loginPassword = ""
|
|
login2faCode = ""
|
|
logLogin("success", out, err, exitCode)
|
|
onUnlockSuccess(out)
|
|
return
|
|
}
|
|
|
|
if (err) {
|
|
logLogin("bw-error", out, err, exitCode)
|
|
errorMessage = err
|
|
} else if (exitCode !== 0) {
|
|
logLogin("failed-no-stderr", out, err, exitCode)
|
|
errorMessage = "Login failed. Please check your credentials."
|
|
} else {
|
|
// bw exited cleanly and said nothing at all. Handing that to the unlock
|
|
// path was silent by construction: prepareUnlock() refuses it because
|
|
// the vault is not locked, so the password went to a FIFO nobody had
|
|
// created and failed two seconds later, after the next click had already
|
|
// cleared the message. Say what happened instead.
|
|
logLogin("clean-exit-no-session", out, err, exitCode)
|
|
errorMessage = "Bitwarden reported no error but returned no session. "
|
|
+ "Please try again, or use the terminal login."
|
|
}
|
|
}
|
|
|
|
function launchTerminalLogin() {
|
|
if (logoutPending) {
|
|
errorMessage = "Finishing logout. Please wait a moment."
|
|
return
|
|
}
|
|
// The panel knows whether this is a login or an unlock, so the terminal
|
|
// does not have to spend a `bw status` round trip working it out.
|
|
var mode = (status === "locked") ? "unlock" : "login"
|
|
var serverUrl = mode === "login" ? resolvedLoginServerUrl() : ""
|
|
var serverProblem = Model.validateServerUrl(serverUrl)
|
|
if (serverProblem) {
|
|
errorMessage = serverProblem
|
|
return
|
|
}
|
|
close()
|
|
// Opens the window in which a handed-over session key is accepted. See
|
|
// refreshStatus().
|
|
terminalLoginStartedAt = Date.now()
|
|
Quickshell.execDetached(Model.terminalLoginCommand(mode, serverUrl))
|
|
}
|
|
|
|
function logoutAccount() {
|
|
if (logoutPending) return
|
|
logoutPending = true
|
|
logoutCliDone = false
|
|
logoutCredentialsDone = false
|
|
logoutExitCode = 0
|
|
logoutCredentialsExitCode = 0
|
|
terminalLoginStartedAt = 0
|
|
lockVault()
|
|
// Stronger than the lock above: logout takes the public projection with
|
|
// it, so a new account cannot inherit the last one's identities.
|
|
applySshAgentLifecycle("logout")
|
|
forgetStoredCredentials()
|
|
pendingUnlockPassword = ""
|
|
logoutProc.command = Model.logoutCommand()
|
|
logoutProc.running = true
|
|
status = "unauthenticated"
|
|
currentScreen = "login"
|
|
userEmail = ""
|
|
}
|
|
|
|
function onLogoutCliFinished(exitCode) {
|
|
if (!logoutPending) return
|
|
logoutExitCode = exitCode
|
|
logoutCliDone = true
|
|
finishLogoutIfReady()
|
|
}
|
|
|
|
function onLogoutCredentialsFinished(exitCode) {
|
|
if (!logoutPending) return
|
|
logoutCredentialsExitCode = exitCode
|
|
logoutCredentialsDone = true
|
|
finishLogoutIfReady()
|
|
}
|
|
|
|
function finishLogoutIfReady() {
|
|
if (!logoutPending || !logoutCliDone || !logoutCredentialsDone) return
|
|
if (logoutCredentialsExitCode !== 0) {
|
|
errorMessage = "Could not clear stored credentials. Retry logout cleanup before signing in."
|
|
return
|
|
}
|
|
logoutPending = false
|
|
status = "unauthenticated"
|
|
currentScreen = "login"
|
|
if (logoutExitCode === 0) flashNotification("Logged out")
|
|
else errorMessage = "Bitwarden logout did not complete cleanly. Please try again."
|
|
focusAppropriateField()
|
|
}
|
|
|
|
function retryLogoutCleanup() {
|
|
if (!logoutCleanupFailed) return
|
|
errorMessage = ""
|
|
logoutCredentialsDone = false
|
|
logoutCredentialsExitCode = 0
|
|
requestAllCredentialClear()
|
|
}
|
|
|
|
function storeCurrentSession() {
|
|
if (logoutPending) {
|
|
sessionStorePending = false
|
|
return
|
|
}
|
|
if (!rememberSession || !session) {
|
|
sessionStorePending = false
|
|
return
|
|
}
|
|
if (keyringStoreProc.running || keyringClearProc.running) {
|
|
sessionStorePending = true
|
|
return
|
|
}
|
|
sessionStorePending = false
|
|
beginEpochOperation("sessionStore")
|
|
keyringStoreProc.running = true
|
|
}
|
|
|
|
function onSessionStored(exitCode) {
|
|
if (epochOperationIsStale("sessionStore") || status !== "unlocked" || !session) {
|
|
sessionStorePending = rememberSession && status === "unlocked" && !!session
|
|
requestSessionCredentialClear()
|
|
return
|
|
}
|
|
sessionStorePending = false
|
|
if (exitCode !== 0) {
|
|
console.warn("qs-bitwarden-cli: could not store session in keyring (exit " + exitCode + ")")
|
|
}
|
|
}
|
|
|
|
function requestSessionCredentialClear() {
|
|
if (keyringClearProc.running) {
|
|
sessionClearPending = true
|
|
return
|
|
}
|
|
sessionClearPending = false
|
|
keyringClearProc.running = true
|
|
}
|
|
|
|
function requestPinCredentialClear() {
|
|
if (keyringClearPinProc.running) {
|
|
pinClearPending = true
|
|
return
|
|
}
|
|
pinClearPending = false
|
|
keyringClearPinProc.running = true
|
|
}
|
|
|
|
function requestMasterCredentialClear() {
|
|
if (keyringClearMasterProc.running) {
|
|
masterClearPending = true
|
|
return
|
|
}
|
|
masterClearPending = false
|
|
keyringClearMasterProc.running = true
|
|
}
|
|
|
|
function credentialStoresRunning() {
|
|
return keyringStoreProc.running || pinStoreProc.running || keyringStoreMasterProc.running
|
|
}
|
|
|
|
function requestAllCredentialClear() {
|
|
if (keyringClearAllProc.running) {
|
|
allCredentialsClearPending = true
|
|
return
|
|
}
|
|
// A clear that wins the race against an older store is not cleanup: that
|
|
// store can recreate the credential immediately afterward. Logout remains
|
|
// pending until every writer has exited and this final sweep has run.
|
|
if (credentialStoresRunning()) {
|
|
allCredentialsClearPending = true
|
|
return
|
|
}
|
|
allCredentialsClearPending = false
|
|
keyringClearAllProc.running = true
|
|
}
|
|
|
|
// Logging out takes the keyring with it. Two of the entries there are the
|
|
// master password -- fingerprint unlock keeps it as it is, PIN unlock keeps
|
|
// it encrypted -- and both are written to the default collection so they
|
|
// survive a reboot, which is exactly why a logout has to be the end of them.
|
|
//
|
|
// Nothing here asks whether we think an entry exists. `fingerprintStored`
|
|
// and `pinConfigured` describe what the settings screen last saw, and both
|
|
// go false for reasons that leave the keyring untouched: an unplugged
|
|
// reader, an uninstalled fprintd, a dependency probe that has not answered
|
|
// yet. Gating the clear on them is how a master password came to outlive the
|
|
// account it belonged to. See keyringClearAllCommand() for why asking
|
|
// unconditionally is free.
|
|
function forgetStoredCredentials() {
|
|
requestAllCredentialClear()
|
|
// The learned-suggestion store is this account's data too -- which domains
|
|
// and apps it holds logins for, and when each was last used -- and unlike
|
|
// everything else here it is a plain file with no expiry. It goes with the
|
|
// account rather than waiting for the next user of this machine to read it.
|
|
associationsEpoch += 1
|
|
pendingAssociationsJson = ""
|
|
associationsWritePending = false
|
|
if (associationsWriteProc.running) {
|
|
associationsClearPending = true
|
|
associationsWriteProc.running = false
|
|
} else {
|
|
associationsClearPending = false
|
|
associationsClearProc.running = true
|
|
}
|
|
associations = Model.emptyAssociations()
|
|
suggestedItems = []
|
|
detectedContext = null
|
|
activeWindowData = null
|
|
cancelFingerprintUnlock()
|
|
fingerprintStored = false
|
|
fingerprintMessage = ""
|
|
pinConfigured = false
|
|
pinEntry = ""
|
|
pinAttempts = 0
|
|
pinError = ""
|
|
if (pinUnlock) writeSetting("pinUnlock", false, "bool")
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Fingerprint Unlock
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Secrets go to secret-tool through the environment, never argv. See
|
|
// keyringStoreScript() in BitwardenModel.js for why stdin is not usable.
|
|
function associationsEnv() {
|
|
var env = {}
|
|
env[Model.associationsEnvVar()] = String(pendingAssociationsJson || "")
|
|
return env
|
|
}
|
|
|
|
// BW_SESSION rather than --session: bw reads it natively, and it keeps the
|
|
// token out of /proc/<pid>/cmdline, which any local user can read.
|
|
function bwEnv(extra) {
|
|
var env = {}
|
|
if (session) env[Model.sessionEnvVar()] = String(session)
|
|
if (extra) for (var k in extra) env[k] = extra[k]
|
|
return env
|
|
}
|
|
|
|
// Authentication credentials enter short-lived processes through the
|
|
// environment. Direct password flows move BW_PASSWORD from the writer into
|
|
// bw's private FIFO; API login reads BW_PASSWORD, BW_CLIENTID and
|
|
// BW_CLIENTSECRET natively. None reaches an argv -- neither bw's nor that of
|
|
// the shell wrapping it.
|
|
// /proc/<pid>/cmdline is world-readable on a default install; environ is not.
|
|
//
|
|
// Read as a binding by loginProc and unlockProc, so it always reflects the
|
|
// fields as they are when the process starts.
|
|
function authEnv(password, clientId, clientSecret, code) {
|
|
var env = bwEnv()
|
|
env[Model.noInteractionEnvVar()] = "true"
|
|
if (password) env[Model.passwordEnvVar()] = String(password)
|
|
if (clientId) env[Model.clientIdEnvVar()] = String(clientId)
|
|
if (clientSecret) env[Model.clientSecretEnvVar()] = String(clientSecret)
|
|
// The only one bw has no environment option for; see the comment on
|
|
// TWOFACTOR_CODE_ENV in BitwardenModel.js.
|
|
if (code) env[Model.twoFactorCodeEnvVar()] = String(code)
|
|
return env
|
|
}
|
|
|
|
function loginProcessEnv() {
|
|
if (loginMethod === "apikey") {
|
|
// This is a live Process binding. Keep fields out of its retained value
|
|
// until an actual API login starts, instead of duplicating credentials
|
|
// into both the form and the process object while the user is typing.
|
|
if (!loginSubmitted) return authEnv("", "", "", "")
|
|
return authEnv(loginPassword,
|
|
String(loginClientId || "").trim(),
|
|
String(loginClientSecret || "").trim(),
|
|
String(login2faCode || "").trim())
|
|
}
|
|
// The one login allowed to prompt. BW_NOINTERACTION is left out rather
|
|
// than set to anything, since bw tests it against the literal "true", and
|
|
// the code goes in for the command's own printf to read -- authEnv() is
|
|
// not used here precisely because it would put the flag back.
|
|
if (deviceVerificationAttempt) {
|
|
var deviceEnv = bwEnv()
|
|
deviceEnv[Model.deviceCodeEnvVar()] = String(loginDeviceCode || "").trim()
|
|
return deviceEnv
|
|
}
|
|
// Email/password login reads its password from the FIFO writer. Keeping it
|
|
// out of the long-lived prewarmed process also keeps partial typing out of
|
|
// that process's environment.
|
|
return authEnv("", "", "", String(login2faCode || "").trim())
|
|
}
|
|
|
|
function itemEnv() {
|
|
var e = {}
|
|
e[Model.itemEnvVar()] = String(itemPayloadJson || "")
|
|
return bwEnv(e)
|
|
}
|
|
|
|
function folderEnv() {
|
|
var e = {}
|
|
e[Model.folderEnvVar()] = Model.folderPayload(newFolderName)
|
|
return bwEnv(e)
|
|
}
|
|
|
|
function sendEnv(json) {
|
|
var e = {}
|
|
e[Model.sendEnvVar()] = String(json || "")
|
|
return bwEnv(e)
|
|
}
|
|
|
|
function pinEnv(pin, secret) {
|
|
var env = {}
|
|
env[Model.pinEnvVar()] = String(pin || "")
|
|
if (secret) env[Model.keyringSecretEnvVar()] = String(secret)
|
|
return env
|
|
}
|
|
|
|
function secretEnv(value) {
|
|
var env = {}
|
|
env[Model.keyringSecretEnvVar()] = String(value || "")
|
|
return env
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Bitwarden Send
|
|
// -------------------------------------------------------------------------
|
|
|
|
function openSends() {
|
|
closeFilterGroup()
|
|
sendMode = "list"
|
|
sendError = ""
|
|
sendIndex = 0
|
|
currentScreen = "sends"
|
|
loadSends()
|
|
}
|
|
|
|
function loadSends() {
|
|
if (!session) return
|
|
sendsLoading = true
|
|
beginVaultRead("sends")
|
|
listSendsProc.command = Model.listSendsCommand()
|
|
listSendsProc.running = true
|
|
}
|
|
|
|
function onSendsLoaded(raw) {
|
|
sendsLoading = false
|
|
if (vaultReadIsStale("sends")) return
|
|
sends = Model.parseSends(raw)
|
|
if (sendIndex >= sends.length) sendIndex = Math.max(0, sends.length - 1)
|
|
}
|
|
|
|
function beginCreateSend() {
|
|
sendFormName = ""
|
|
sendFormText = ""
|
|
sendFormHidden = false
|
|
sendFormDays = 7
|
|
sendFormMaxAccess = 0
|
|
sendFormPassword = ""
|
|
sendError = ""
|
|
sendMode = "create"
|
|
Qt.callLater(function() { sendNameField.forceActiveFocus() })
|
|
}
|
|
|
|
function submitCreateSend() {
|
|
if (!String(sendFormText || "").trim()) {
|
|
sendError = "Nothing to send -- enter some text"
|
|
return
|
|
}
|
|
sendError = ""
|
|
sendBusy = true
|
|
sendPayloadJson = JSON.stringify(Model.buildSendPayload(
|
|
sendFormName, sendFormText, sendFormHidden,
|
|
sendFormDays, sendFormMaxAccess, sendFormPassword, ""))
|
|
beginVaultRead("sendCreate")
|
|
createSendProc.command = Model.createSendCommand()
|
|
createSendProc.running = true
|
|
}
|
|
|
|
function onSendCreated(exitCode, stdoutText, stderrText) {
|
|
sendBusy = false
|
|
sendPayloadJson = ""
|
|
if (vaultReadIsStale("sendCreate")) return
|
|
if (exitCode !== 0) {
|
|
sendError = String(stderrText || "").trim() || "Could not create the Send"
|
|
return
|
|
}
|
|
// bw prints the access URL; put it straight on the clipboard, since a Send
|
|
// is useless until the link reaches someone.
|
|
var created = null
|
|
try { created = JSON.parse(stdoutText) } catch (e) { created = null }
|
|
var url = created && created.accessUrl ? String(created.accessUrl) : String(stdoutText || "").trim()
|
|
if (url) {
|
|
copyToClipboard(url, "Send link")
|
|
} else {
|
|
flashNotification("Send created")
|
|
}
|
|
sendFormText = ""
|
|
sendFormPassword = ""
|
|
sendMode = "list"
|
|
loadSends()
|
|
}
|
|
|
|
function copySendLink(send) {
|
|
if (!send || !send.accessUrl) return
|
|
copyToClipboard(send.accessUrl, "Send link")
|
|
}
|
|
|
|
function deleteSend(send) {
|
|
if (!send || !send.id) return
|
|
sendBusy = true
|
|
beginVaultRead("sendDelete")
|
|
deleteSendProc.command = Model.deleteSendCommand(send.id)
|
|
deleteSendProc.running = true
|
|
}
|
|
|
|
function onSendDeleted(exitCode) {
|
|
sendBusy = false
|
|
if (vaultReadIsStale("sendDelete")) return
|
|
if (exitCode !== 0) {
|
|
sendError = "Could not delete the Send"
|
|
return
|
|
}
|
|
flashNotification("Send deleted")
|
|
loadSends()
|
|
}
|
|
|
|
function moveSendCursor(delta) {
|
|
if (sends.length === 0) return
|
|
sendIndex = Math.max(0, Math.min(sends.length - 1, sendIndex + delta))
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Generator
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Reached from the header button on any screen and from the item form's
|
|
// Generate button, which is the same thing: the form is just a caller that
|
|
// wants the value back.
|
|
function openGenerator() {
|
|
closeFilterGroup()
|
|
generatorReturnScreen = (currentScreen === "edit") ? "edit" : "main"
|
|
screenBeforeSettings = "main"
|
|
currentScreen = "generator"
|
|
// A form asking for a password wants a new one every time. A standalone
|
|
// visit keeps whatever was last generated, so reopening does not throw
|
|
// away a value you were about to copy.
|
|
if (generatorFeedsForm || !genValue) regenerate()
|
|
}
|
|
|
|
function closeGenerator() {
|
|
var toForm = generatorFeedsForm
|
|
currentScreen = generatorReturnScreen
|
|
generatorReturnScreen = "main"
|
|
// Land back on the field the trip was about, filled in or not.
|
|
if (toForm) Qt.callLater(function() { formPassField.forceActiveFocus() })
|
|
}
|
|
|
|
// The whole point of the round trip: put the value in the field the caller
|
|
// was on, and go back to it.
|
|
function useGeneratedPassword() {
|
|
if (!generatorFeedsForm || genBusy || !genValue) return
|
|
formPassword = genValue
|
|
// Show it. A password you cannot read is hard to trust, and it is going
|
|
// into a form you are still filling in rather than straight to the vault.
|
|
formPasswordRevealed = true
|
|
closeGenerator()
|
|
flashNotification("Generated password filled in")
|
|
}
|
|
|
|
// Generation is delegated to Bitwarden's own generator either way; the only
|
|
// question is how we reach it. `bw serve` answers in ~2ms against ~2.9s for
|
|
// a fresh `bw generate`, so the server is started on first use and the CLI
|
|
// stays as the fallback for when it cannot be.
|
|
function generatorOptionsSignature() {
|
|
return JSON.stringify(Model.normalizeGeneratorOptions(genOpts))
|
|
}
|
|
|
|
function regenerate() {
|
|
if (generateCliStopping) {
|
|
genBusy = true
|
|
genRegeneratePending = true
|
|
return
|
|
}
|
|
if (genBusy) {
|
|
genRegeneratePending = true
|
|
return
|
|
}
|
|
genBusy = true
|
|
genRegeneratePending = false
|
|
genRequestSignature = generatorOptionsSignature()
|
|
beginVaultRead("generator")
|
|
if (generateServeReady) {
|
|
requestGeneratedValue()
|
|
return
|
|
}
|
|
startGeneratorServe()
|
|
// Nothing to wait on if the server is already coming up -- onExited or the
|
|
// ready poll will drive the request.
|
|
if (!generateServeStarting) regenerateViaCli()
|
|
}
|
|
|
|
function regenerateViaCli() {
|
|
genBusy = true
|
|
genRegeneratePending = false
|
|
genRequestSignature = generatorOptionsSignature()
|
|
generateProc.command = Model.generateCommand(genOpts)
|
|
generateProc.running = true
|
|
}
|
|
|
|
// A locked server: no session in its environment, so it can generate and
|
|
// nothing else. See the comment on generateServeCommand in BitwardenModel.js
|
|
// for why that restriction is the whole point.
|
|
function generatorServeEnv() {
|
|
var env = {}
|
|
env[Model.sessionEnvVar()] = null
|
|
env[Model.noInteractionEnvVar()] = "true"
|
|
return env
|
|
}
|
|
|
|
// Nothing about an HTTP 200 proves the process that sent it is ours. Another
|
|
// account can bind the port first and answer /generate with passwords it
|
|
// already knows, and the panel would show one as freshly generated. There is
|
|
// no handshake to lean on -- `bw serve` prints no banner and offers no
|
|
// authentication -- so the evidence has to be that the port was silent before
|
|
// our own server took it. Anything already answering means the serve path is
|
|
// not available, and the CLI carries the feature instead.
|
|
function startGeneratorServe() {
|
|
if (generateServeReady || generateServeStarting || generateServeFailed) return
|
|
generateServeStarting = true
|
|
probeGeneratorPort()
|
|
}
|
|
|
|
// Every request to the generator port goes through a bounded child process
|
|
// rather than QML's XMLHttpRequest. XMLHttpRequest buffers responses in
|
|
// shared shell process memory before JavaScript can inspect or abort them,
|
|
// leaving the shell vulnerable to unbounded allocations from a rogue local
|
|
// port responder. The child process bounds both duration (--max-time) and
|
|
// payload volume (| head -c 65536) on the producer side, ensuring no more
|
|
// than 64KB ever enters the shell process.
|
|
//
|
|
// `done` is called with (exitCode, stdout, stderr).
|
|
property var generateServeRequestCallback: null
|
|
|
|
function generatorRequest(opts, done) {
|
|
if (generateServeRequestStopping || generateServeRequestProc.running) {
|
|
generateServeRequestPending = true
|
|
generateServeRequestPendingOptions = opts
|
|
generateServeRequestPendingCallback = done
|
|
return
|
|
}
|
|
generateServeRequestCallback = done
|
|
generateServeRequestProc.command = Model.generateServeRequestCommand(opts)
|
|
generateServeRequestProc.running = true
|
|
}
|
|
|
|
function resumePendingGeneratorRequest() {
|
|
if (!generateServeRequestPending) return false
|
|
var pendingOptions = generateServeRequestPendingOptions
|
|
var pendingCallback = generateServeRequestPendingCallback
|
|
generateServeRequestPending = false
|
|
generateServeRequestPendingOptions = null
|
|
generateServeRequestPendingCallback = null
|
|
Qt.callLater(function() {
|
|
if (root.opened && root.currentScreen === "generator")
|
|
root.generatorRequest(pendingOptions, pendingCallback)
|
|
})
|
|
return true
|
|
}
|
|
|
|
function probeGeneratorPort() {
|
|
generatorRequest(null, function(exitCode, stdout, stderr) {
|
|
if (Model.generatorProbeIsForeign(exitCode, stdout)) {
|
|
root.generateServeStarting = false
|
|
root.generateServeFailed = true
|
|
if (root.genBusy) root.regenerateViaCli()
|
|
return
|
|
}
|
|
// The screen can close while a probe is in flight, and starting a server
|
|
// for a screen nobody is looking at is the exposure this all avoids.
|
|
if (root.currentScreen !== "generator") {
|
|
root.generateServeStarting = false
|
|
return
|
|
}
|
|
generateServeProc.running = true
|
|
generateServePoll.attempts = 0
|
|
generateServePoll.restart()
|
|
})
|
|
}
|
|
|
|
function stopGeneratorServe() {
|
|
var cancelCliGeneration = genBusy && generateProc.running
|
|
generateServePoll.stop()
|
|
generateServeStarting = false
|
|
generateServeReady = false
|
|
// A deliberate shutdown is not the permanent bind failure, so the next
|
|
// visit is free to start a server again.
|
|
generateServeFailed = false
|
|
genBusy = false
|
|
genRegeneratePending = false
|
|
genRequestSignature = ""
|
|
generateServeRequestPending = false
|
|
generateServeRequestPendingOptions = null
|
|
generateServeRequestPendingCallback = null
|
|
if (generateServeRequestProc.running
|
|
&& !Model.isScrubCommand(generateServeRequestProc.command)) {
|
|
generateServeRequestCallback = null
|
|
generateServeRequestStopping = true
|
|
generateServeRequestProc.running = false
|
|
}
|
|
if (cancelCliGeneration) {
|
|
generateCliStopping = true
|
|
generateProc.running = false
|
|
}
|
|
if (generateServeProc.running) {
|
|
generateServeStopping = true
|
|
generateServeProc.running = false
|
|
}
|
|
}
|
|
|
|
// The server is up when it answers. Polling rather than trusting a fixed
|
|
// delay: bw takes a couple of seconds to bind, and the first generator open
|
|
// should not sit behind a guess.
|
|
function pollGeneratorServe() {
|
|
if (generateServeRequestProc.running) return
|
|
generatorRequest(root.genOpts, function(exitCode, stdout, stderr) {
|
|
if (exitCode !== 0) return
|
|
var value = Model.parseServeGenerated(stdout)
|
|
if (!value) return
|
|
root.generateServeStarting = false
|
|
root.generateServeReady = true
|
|
generateServePoll.stop()
|
|
root.onGenerated(value, 0)
|
|
})
|
|
}
|
|
|
|
function requestGeneratedValue() {
|
|
generatorRequest(root.genOpts, function(exitCode, stdout, stderr) {
|
|
var value = exitCode === 0 ? Model.parseServeGenerated(stdout) : ""
|
|
if (value) {
|
|
root.onGenerated(value, 0)
|
|
return
|
|
}
|
|
// The server went away mid-session, or stopped behaving like one; fall
|
|
// back and stop trusting it.
|
|
root.generateServeReady = false
|
|
root.regenerateViaCli()
|
|
})
|
|
}
|
|
|
|
function onGenerated(text, exitCode) {
|
|
if (vaultReadIsStale("generator")) {
|
|
genBusy = false
|
|
genRegeneratePending = false
|
|
return
|
|
}
|
|
if (genRegeneratePending || genRequestSignature !== generatorOptionsSignature()) {
|
|
genBusy = false
|
|
genRegeneratePending = false
|
|
regenerate()
|
|
return
|
|
}
|
|
genBusy = false
|
|
var v = String(text || "").trim()
|
|
if (exitCode !== 0 || !v) {
|
|
errorMessage = "Could not generate with these options"
|
|
return
|
|
}
|
|
genValue = v
|
|
}
|
|
|
|
// Every control funnels through here, so a change always regenerates --
|
|
// matching the extension's live behaviour -- and options stay normalised.
|
|
function setGenOpt(key, value) {
|
|
var next = {}
|
|
for (var k in genOpts) next[k] = genOpts[k]
|
|
next[key] = value
|
|
genOpts = Model.normalizeGeneratorOptions(next)
|
|
regenerate()
|
|
}
|
|
|
|
function copyGenerated() {
|
|
if (genBusy || !genValue) return
|
|
copyToClipboard(genValue, genOpts.type === "passphrase" ? "Passphrase" : "Password")
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// PIN Unlock
|
|
// -------------------------------------------------------------------------
|
|
|
|
function refreshPinConfigured() {
|
|
if (!keyringHasPinProc.running) keyringHasPinProc.running = true
|
|
}
|
|
|
|
function onPinConfiguredChecked(raw) {
|
|
pinConfigured = String(raw || "").trim() === "yes"
|
|
}
|
|
|
|
function beginPinSetup() {
|
|
pinSetupPin = ""
|
|
pinSetupConfirm = ""
|
|
pinSetupMaster = ""
|
|
pinError = ""
|
|
screenBeforeSettings = "main"
|
|
currentScreen = "pin"
|
|
Qt.callLater(function() { pinSetupPinField.forceActiveFocus() })
|
|
}
|
|
|
|
function abandonPinSetup() {
|
|
if (pinStoreProc.running) invalidateEpochOperation("pinStore")
|
|
pinBusy = false
|
|
pinSetupPin = ""
|
|
pinSetupConfirm = ""
|
|
pinSetupMaster = ""
|
|
}
|
|
|
|
// Encrypting needs the master password, and the vault does not keep it in
|
|
// memory once unlocked, so setting a PIN has to ask for it.
|
|
function submitPinSetup() {
|
|
if (pinBusy || pinStoreProc.running) return
|
|
var err = Model.validatePin(pinSetupPin, pinSetupConfirm)
|
|
if (err) { pinError = err; return }
|
|
if (!pinSetupMaster) { pinError = "Master password is required to encrypt the PIN"; return }
|
|
|
|
pinError = ""
|
|
pinBusy = true
|
|
beginEpochOperation("pinStore")
|
|
pinStoreProc.running = true
|
|
}
|
|
|
|
function onPinStored(exitCode) {
|
|
pinBusy = false
|
|
if (epochOperationIsStale("pinStore")) {
|
|
pinConfigured = false
|
|
pinSetupPin = ""
|
|
pinSetupConfirm = ""
|
|
pinSetupMaster = ""
|
|
requestPinCredentialClear()
|
|
return
|
|
}
|
|
if (exitCode !== 0) {
|
|
pinError = "Could not save the PIN. Is the OS keyring available?"
|
|
return
|
|
}
|
|
pinConfigured = true
|
|
pinSetupPin = ""
|
|
pinSetupConfirm = ""
|
|
pinSetupMaster = ""
|
|
pinAttempts = 0
|
|
writeSetting("pinUnlock", true, "bool")
|
|
flashNotification("PIN unlock enabled")
|
|
currentScreen = "settings"
|
|
}
|
|
|
|
function submitPinUnlock() {
|
|
if (!sshAuthSurfaceActive || !pinReady || isUnlocking || pinBusy) return
|
|
if (String(pinEntry || "").length < Model.pinMinLength()) {
|
|
pinError = "PIN must be at least " + Model.pinMinLength() + " digits"
|
|
return
|
|
}
|
|
pinError = ""
|
|
pinBusy = true
|
|
pinUnlockSubmitted = true
|
|
pinUnlockProc.command = Model.pinUnlockCommand()
|
|
pinUnlockProc.running = true
|
|
}
|
|
|
|
function onPinUnlockResult(exitCode, password) {
|
|
var accepting = pinUnlockSubmitted && sshAuthSurfaceActive && status === "locked"
|
|
pinUnlockSubmitted = false
|
|
pinBusy = false
|
|
if (!accepting) {
|
|
clearProcessCollectorSoon(pinUnlockProc)
|
|
return
|
|
}
|
|
var pw = String(password || "")
|
|
|
|
if (exitCode !== 0 || !pw) {
|
|
pinAttempts += 1
|
|
pinEntry = ""
|
|
if (pinAttempts >= pinMaxAttempts) {
|
|
// Refuse to keep serving guesses at the UI. The ciphertext goes too,
|
|
// so re-enabling requires the master password again.
|
|
clearPin()
|
|
pinError = "Too many incorrect PINs. PIN unlock has been removed -- use your master password."
|
|
} else {
|
|
pinError = "Incorrect PIN (" + pinAttempts + " of " + pinMaxAttempts + ")"
|
|
}
|
|
return
|
|
}
|
|
|
|
pinAttempts = 0
|
|
pendingUnlockFrom = "pin"
|
|
unlockVaultWithPassword(pw)
|
|
}
|
|
|
|
function clearPin() {
|
|
requestPinCredentialClear()
|
|
pinConfigured = false
|
|
pinEntry = ""
|
|
pinAttempts = 0
|
|
if (pinUnlock) writeSetting("pinUnlock", false, "bool")
|
|
}
|
|
|
|
function disablePinUnlock() {
|
|
clearPin()
|
|
pinError = ""
|
|
flashNotification("PIN unlock removed")
|
|
}
|
|
|
|
onPinUnlockChanged: {
|
|
if (pinUnlock) refreshPinConfigured()
|
|
else if (pinConfigured) clearPin()
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Setup Wizard & Settings
|
|
// -------------------------------------------------------------------------
|
|
|
|
function checkDependencies() {
|
|
if (!depsCheckProc.running) depsCheckProc.running = true
|
|
}
|
|
|
|
function onDependenciesChecked(raw) {
|
|
dependencies = Model.parseDependencies(raw)
|
|
depsChecked = true
|
|
if (pinUnlock) refreshPinConfigured()
|
|
|
|
// Fingerprint availability comes from the same probe, so keep them in step.
|
|
for (var i = 0; i < dependencies.items.length; i++) {
|
|
if (dependencies.items[i].key === "fprintd") fingerprintAvailable = dependencies.items[i].ready
|
|
}
|
|
if (fingerprintAvailable && fingerprintUnlock) {
|
|
if (!keyringHasMasterProc.running) keyringHasMasterProc.running = true
|
|
} else {
|
|
fingerprintStored = false
|
|
}
|
|
|
|
// A missing required tool is not something to discover mid-task.
|
|
if (Model.missingRequired(dependencies).length > 0) setupWasGated = true
|
|
|
|
var next = Model.dependencyProbeOutcome(dependencies, setupDismissed, statusProbeStarted, setupWasGated)
|
|
if (next === "setup") {
|
|
currentScreen = "setup"
|
|
} else if (next === "probe") {
|
|
// Either the first look at the vault this session, or the one that
|
|
// follows an install landing. onStatusFinished puts up whichever screen
|
|
// the answer calls for, so setup gets left behind without being told to.
|
|
setupWasGated = false
|
|
refreshStatus()
|
|
}
|
|
}
|
|
|
|
readonly property var missingRequired: Model.missingRequired(dependencies)
|
|
readonly property var installablePackages: Model.missingPackages(dependencies)
|
|
// Whether anything on the setup screen is still waiting on the user. Covers
|
|
// the setup rows too, so a fingerprint enrolment running in its own terminal
|
|
// is watched for the same way an install is.
|
|
readonly property bool setupActionsPending: {
|
|
var rows = Model.applicableDependencies(dependencies)
|
|
for (var i = 0; i < rows.length; i++) {
|
|
if (!rows[i].ready) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function installMissing() {
|
|
var pkgs = Model.missingPackages(dependencies)
|
|
var cmd = Model.installPackagesCommand(pkgs,
|
|
pkgs.length === 1 ? "Bitwarden CLI" : "Bitwarden plugin dependencies")
|
|
if (!cmd) return
|
|
Quickshell.execDetached(cmd)
|
|
flashNotification("Installing -- this screen updates itself")
|
|
}
|
|
|
|
function installOne(dep) {
|
|
if (!dep) return
|
|
// Omarchy's setup command owns its own rows; `pkg add` on one of those
|
|
// would install a package and leave the row exactly as red as it was.
|
|
if (dep.setup) {
|
|
runFingerprintSetup()
|
|
return
|
|
}
|
|
var cmd = Model.installPackagesCommand([dep.pkg], dep.label)
|
|
if (!cmd) return
|
|
Quickshell.execDetached(cmd)
|
|
flashNotification("Installing " + dep.pkg + " -- this screen updates itself")
|
|
}
|
|
|
|
// Stepping past setup. The gate is what was holding the first status probe
|
|
// back, so opening it has to release that probe as well -- otherwise the
|
|
// panel would sit on a login screen it never actually asked `bw` about.
|
|
function dismissSetup() {
|
|
setupDismissed = true
|
|
currentScreen = status === "unlocked" ? "main"
|
|
: (status === "locked" ? "locked" : "login")
|
|
if (!statusProbeStarted) refreshStatus()
|
|
}
|
|
|
|
function runFingerprintSetup() {
|
|
Quickshell.execDetached(Model.fingerprintSetupCommand())
|
|
flashNotification("Fingerprint setup opened -- this screen updates itself")
|
|
}
|
|
|
|
// A setting whose dependency is missing is inert; the cursor may sit on it,
|
|
// but changing it would silently do nothing.
|
|
function settingBlocked(entry) {
|
|
if (!entry || !entry.requires) return false
|
|
for (var i = 0; i < dependencies.items.length; i++) {
|
|
if (dependencies.items[i].key === entry.requires) return !dependencies.items[i].ready
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Group headings are rows in the list but not controls, so the cursor steps
|
|
// over them rather than stopping on one and doing nothing when activated.
|
|
function moveSettingsCursor(delta) {
|
|
var n = settingsEntries.length
|
|
if (n === 0) return
|
|
var step = delta < 0 ? -1 : 1
|
|
var i = settingsIndex + delta
|
|
while (i >= 0 && i < n && settingsEntries[i] && settingsEntries[i].kind === "group") i += step
|
|
// A heading at the far end leaves nowhere further to go in that direction;
|
|
// the cursor stays where it was rather than landing on the heading.
|
|
if (i < 0 || i >= n) return
|
|
settingsIndex = i
|
|
}
|
|
|
|
function firstSettingIndex() {
|
|
for (var i = 0; i < settingsEntries.length; i++) {
|
|
if (settingsEntries[i] && settingsEntries[i].kind === "setting") return i
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// Left/right nudge a value: numbers by their step, switches off and on.
|
|
function adjustSetting(direction) {
|
|
var e = settingsEntries[settingsIndex]
|
|
if (!e || settingBlocked(e)) return
|
|
|
|
if (e.type === "int") {
|
|
var cur = Number(settingValue(e))
|
|
var step = e.step || 1
|
|
var next = Math.max(e.min || 0, Math.min(e.max || 100, cur + direction * step))
|
|
if (next !== cur) writeSetting(e.key, next, "int")
|
|
return
|
|
}
|
|
|
|
if (e.type === "bool") {
|
|
var want = direction > 0
|
|
if (Boolean(settingValue(e)) !== want) activateSettingRow()
|
|
}
|
|
}
|
|
|
|
// The lane every vertical scrollbar in this panel gets to itself.
|
|
//
|
|
// These bars are overlays: left alone they draw on top of whatever occupies
|
|
// the right edge of the view, which across these screens is toggles, number
|
|
// fields, copy buttons and the ends of elided text. Every scrolling view
|
|
// subtracts this from its content width, so the bar has somewhere to be and
|
|
// the right-hand edges of all of them line up.
|
|
//
|
|
// Measured from a real scrollbar rather than guessed at, so a theme with a
|
|
// wider one does not put it back over the controls. One bar stands in for
|
|
// all of them because they are the same control with the same style; the
|
|
// floor covers both a null reference and the frames before it has an
|
|
// implicit width of its own.
|
|
readonly property real scrollGutter:
|
|
Math.max(settingsScrollBar ? settingsScrollBar.implicitWidth : 0, Style.space(10))
|
|
|
|
// Which section the view is currently inside, named by the pinned indicator.
|
|
// Held rather than derived, because it depends on delegate geometry the
|
|
// Repeater only knows after layout, and a binding cannot read that without
|
|
// fighting it.
|
|
property var settingsStickyEntry: null
|
|
|
|
// The settings view's two geometry questions, in one place. Everything else
|
|
// that needs them goes through these rather than reaching into the Flickable
|
|
// and the Repeater by id from across the file.
|
|
function settingsViewportTop() { return settingsFlick ? settingsFlick.contentY : 0 }
|
|
function settingsRepeaterItem(i) {
|
|
return settingsRepeater ? settingsRepeater.itemAt(i) : null
|
|
}
|
|
|
|
// The section the view is currently inside: the last heading at or above the
|
|
// top of the viewport, while any part of its section is still on screen.
|
|
//
|
|
// Both halves matter. Without the first the bar sits empty until the user
|
|
// has scrolled, which is the one position everybody starts from. Without the
|
|
// second the last group stays named through the maintenance and danger-zone
|
|
// rows below it, which belong to no section and would leave the bar
|
|
// describing somewhere the user had already scrolled past.
|
|
//
|
|
// Drawing the heading twice is prevented at the other end: the in-list
|
|
// heading of whichever section this names is drawn transparent, so it keeps
|
|
// its place in the layout without appearing alongside its own copy.
|
|
function updateSettingsSticky() {
|
|
var entries = settingsEntries
|
|
var top = settingsViewportTop()
|
|
var found = null
|
|
|
|
for (var i = 0; i < entries.length; i++) {
|
|
if (!entries[i] || entries[i].kind !== "group") continue
|
|
var row = settingsRepeaterItem(i)
|
|
if (!row) continue
|
|
// Still below the top edge: the section before this one is the one the
|
|
// view is in.
|
|
if (row.y > top + 1) break
|
|
if (top < settingsSectionEnd(i)) found = entries[i]
|
|
}
|
|
settingsStickyEntry = found
|
|
}
|
|
|
|
// Where the section beginning at `index` stops: the next heading, or for the
|
|
// last one, the bottom of the final row before the trailing action blocks.
|
|
function settingsSectionEnd(index) {
|
|
var entries = settingsEntries
|
|
for (var i = index + 1; i < entries.length; i++) {
|
|
if (!entries[i] || entries[i].kind !== "group") continue
|
|
var next = settingsRepeaterItem(i)
|
|
if (next) return next.y
|
|
}
|
|
for (var j = entries.length - 1; j > index; j--) {
|
|
var last = settingsRepeaterItem(j)
|
|
if (last) return last.y + last.height
|
|
}
|
|
var self = settingsRepeaterItem(index)
|
|
return self ? self.y + self.height : 0
|
|
}
|
|
|
|
function activateSettingRow() {
|
|
var e = settingsEntries[settingsIndex]
|
|
if (!e || settingBlocked(e)) return
|
|
|
|
// These two open a form rather than flipping a value.
|
|
if (e.action === "pin") {
|
|
if (pinConfigured) disablePinUnlock()
|
|
else beginPinSetup()
|
|
return
|
|
}
|
|
if (e.action === "fingerprint") {
|
|
if (fingerprintStored) forgetFingerprintUnlock()
|
|
else beginFingerprintSetup()
|
|
return
|
|
}
|
|
if (e.type === "bool") writeSetting(e.key, !settingValue(e), "bool")
|
|
}
|
|
|
|
function openSettings() {
|
|
closeFilterGroup()
|
|
if (currentScreen !== "settings") screenBeforeSettings = currentScreen
|
|
settingsFlash = ""
|
|
settingsIndex = firstSettingIndex()
|
|
uwsmFlash = ""
|
|
uwsmConfirmPending = false
|
|
checkDependencies()
|
|
inspectUwsmFragment()
|
|
currentScreen = "settings"
|
|
Qt.callLater(updateSettingsSticky)
|
|
}
|
|
|
|
function closeSettings() {
|
|
currentScreen = (screenBeforeSettings === "settings" ? "main" : screenBeforeSettings)
|
|
}
|
|
|
|
// Persisted via `omarchy bar set`, which owns shell.json. The shell reloads
|
|
// on write, so setting() reflects the new value without us caching it.
|
|
function writeSetting(key, value, type) {
|
|
settingWriteProc.command = Model.settingWriteCommand(key, value, type)
|
|
settingWriteProc.running = true
|
|
settingsFlash = "Saved"
|
|
settingsFlashTimer.restart()
|
|
}
|
|
|
|
// The remembered two-step method is not a preference anybody set, so it is
|
|
// written without the settings screen's "Saved" flash -- it is a note the
|
|
// login leaves for the next one, and it has no row to flash next to.
|
|
function writeSettingQuietly(key, value, type) {
|
|
settingWriteProc.command = Model.settingWriteCommand(key, value, type)
|
|
settingWriteProc.running = true
|
|
}
|
|
|
|
function rememberTwoFactorMethod(method) {
|
|
if (!Model.isTwoFactorMethod(method)) return
|
|
if (method === rememberedTwoFactorMethod) return
|
|
var next = Model.rememberTwoFactorMethodIn(twoFactorMethodStore, loginEmail, method)
|
|
if (next) writeSettingQuietly("twoFactorMethods", next, "json")
|
|
}
|
|
|
|
function forgetTwoFactorMethod() {
|
|
if (rememberedTwoFactorMethod < 0) return
|
|
var next = Model.forgetTwoFactorMethodIn(twoFactorMethodStore, loginEmail)
|
|
if (next) writeSettingQuietly("twoFactorMethods", next, "json")
|
|
}
|
|
|
|
// Read back through the same properties the plugin actually runs on, so the
|
|
// settings screen can never show a different value than the one in effect.
|
|
// (setting() alone would miss the manifest defaults for unset keys.)
|
|
function settingValue(entry) {
|
|
if (!entry) return 0
|
|
switch (entry.key) {
|
|
case "autoLockMinutes": return autoLockMinutes
|
|
case "clearClipboardSec": return clearClipboardSec
|
|
case "lockOnScreenLock": return lockOnScreenLock
|
|
case "lockOnSuspend": return lockOnSuspend
|
|
case "autoCopyTotpSec": return autoCopyTotpSec
|
|
case "closeOnCopy": return closeOnCopy
|
|
case "suggestOnOpen": return suggestOnOpen
|
|
case "rememberSession": return rememberSession
|
|
case "fingerprintUnlock": return fingerprintUnlock && fingerprintStored
|
|
// The toggle reflects a PIN actually being set, not just the flag.
|
|
case "pinUnlock": return pinUnlock && pinConfigured
|
|
case "sshAgentEnabled": return sshAgentEnabled
|
|
case "sshAgentUnlockOnDemand": return sshAgentUnlockOnDemand
|
|
case "sshAgentApprovalPopup": return sshAgentApprovalPopup
|
|
case "sshAgentApprovalWindowSec": return sshAgentApprovalWindowSec
|
|
}
|
|
return entry.type === "bool" ? Model.boolSetting(entry.key, setting(entry.key, entry.defaultValue)) : Number(setting(entry.key, 0))
|
|
}
|
|
|
|
function refreshFingerprintAvailability() {
|
|
checkDependencies()
|
|
}
|
|
|
|
function onFingerprintStoredChecked(raw) {
|
|
fingerprintStored = String(raw || "").trim() === "yes"
|
|
if (sshAuthSurfaceActive && status === "locked") startFingerprintUnlock()
|
|
}
|
|
|
|
function startFingerprintUnlock() {
|
|
if (!fingerprintReady || status !== "locked" || isUnlocking) return
|
|
if (fingerprintScanning || fingerprintPam.active) return
|
|
if (!userName) {
|
|
fingerprintMessage = "Cannot determine current user for fingerprint verification"
|
|
return
|
|
}
|
|
|
|
errorMessage = ""
|
|
fingerprintAuthorized = false
|
|
fingerprintScanning = true
|
|
fingerprintMessage = " Touch the fingerprint reader..."
|
|
if (!fingerprintPam.start()) {
|
|
fingerprintScanning = false
|
|
fingerprintMessage = "Could not start fingerprint verification"
|
|
}
|
|
}
|
|
|
|
function cancelFingerprintUnlock() {
|
|
fingerprintScanning = false
|
|
fingerprintAuthorized = false
|
|
if (fingerprintPam.active) fingerprintPam.abort()
|
|
}
|
|
|
|
function onFingerprintResult(result) {
|
|
var accepting = fingerprintScanning && sshAuthSurfaceActive && status === "locked"
|
|
fingerprintScanning = false
|
|
if (!accepting) return
|
|
|
|
if (result === PamResult.Success) {
|
|
fingerprintAuthorized = true
|
|
fingerprintMessage = " Fingerprint verified, unlocking..."
|
|
if (!keyringLookupMasterProc.running) {
|
|
keyringLookupMasterProc.command = Model.keyringLookupMasterPasswordCommand()
|
|
keyringLookupMasterProc.running = true
|
|
}
|
|
} else if (result === PamResult.MaxTries) {
|
|
fingerprintMessage = "Too many fingerprint attempts. Use your master password."
|
|
} else {
|
|
fingerprintMessage = "Fingerprint not recognised. Try again or use your master password."
|
|
}
|
|
}
|
|
|
|
// Only ever called after PamResult.Success.
|
|
function onFingerprintPasswordRetrieved(raw) {
|
|
if (!fingerprintAuthorized || !sshAuthSurfaceActive || status !== "locked") {
|
|
fingerprintAuthorized = false
|
|
clearProcessCollectorSoon(keyringLookupMasterProc)
|
|
return
|
|
}
|
|
fingerprintAuthorized = false
|
|
// The keyring command removes secret-tool's output newline. Do not trim
|
|
// here: spaces at either end can be part of the actual master password.
|
|
var pw = String(raw || "")
|
|
if (!pw) {
|
|
fingerprintStored = false
|
|
fingerprintMessage = "No stored master password. Unlock with your password once to enable this."
|
|
return
|
|
}
|
|
pendingUnlockFrom = "fingerprint"
|
|
unlockVaultWithPassword(pw)
|
|
}
|
|
|
|
// Enrolling asks for the master password up front, the same way setting a
|
|
// PIN does, rather than silently capturing it on some later unlock.
|
|
function beginFingerprintSetup() {
|
|
fpSetupMaster = ""
|
|
fpError = ""
|
|
currentScreen = "fingerprint"
|
|
Qt.callLater(function() { fpMasterField.forceActiveFocus() })
|
|
}
|
|
|
|
function abandonFingerprintSetup() {
|
|
var active = fpSetupActive
|
|
if (active && keyringStoreMasterProc.running) invalidateEpochOperation("masterStore")
|
|
fpSetupActive = false
|
|
fpBusy = false
|
|
fpSetupMaster = ""
|
|
if (active) masterToStore = ""
|
|
}
|
|
|
|
function submitFingerprintSetup() {
|
|
if (fpBusy || keyringStoreMasterProc.running) return
|
|
if (!fpSetupMaster) {
|
|
fpError = "Master password is required to enable fingerprint unlock"
|
|
return
|
|
}
|
|
fpError = ""
|
|
fpBusy = true
|
|
fpSetupActive = true
|
|
masterToStore = fpSetupMaster
|
|
beginEpochOperation("masterStore")
|
|
keyringStoreMasterProc.running = true
|
|
}
|
|
|
|
function onMasterPasswordStored(exitCode) {
|
|
masterToStore = ""
|
|
pendingUnlockPassword = ""
|
|
if (epochOperationIsStale("masterStore")) {
|
|
fpSetupActive = false
|
|
fpBusy = false
|
|
fpSetupMaster = ""
|
|
fingerprintStored = false
|
|
requestMasterCredentialClear()
|
|
return
|
|
}
|
|
fingerprintStored = (exitCode === 0)
|
|
|
|
if (fpSetupActive) {
|
|
fpSetupActive = false
|
|
fpBusy = false
|
|
fpSetupMaster = ""
|
|
if (exitCode !== 0) {
|
|
fpError = "Could not save the master password. Is the OS keyring available?"
|
|
return
|
|
}
|
|
writeSetting("fingerprintUnlock", true, "bool")
|
|
flashNotification("Fingerprint unlock enabled")
|
|
currentScreen = "settings"
|
|
return
|
|
}
|
|
|
|
if (exitCode !== 0) {
|
|
errorMessage = "Could not save master password to the OS keyring, so fingerprint unlock is unavailable."
|
|
}
|
|
}
|
|
|
|
function forgetFingerprintUnlock() {
|
|
requestMasterCredentialClear()
|
|
fingerprintStored = false
|
|
cancelFingerprintUnlock()
|
|
fingerprintMessage = ""
|
|
flashNotification("Fingerprint unlock forgotten")
|
|
}
|
|
|
|
onFingerprintUnlockChanged: {
|
|
if (!fingerprintUnlock) {
|
|
cancelFingerprintUnlock()
|
|
fingerprintMessage = ""
|
|
// Not `if (fingerprintStored)`. That flag is false whenever the reader
|
|
// or fprintd is missing, which says nothing about whether the master
|
|
// password is still sitting in the keyring -- and turning the feature
|
|
// off is precisely when it must not be.
|
|
forgetFingerprintUnlock()
|
|
} else {
|
|
refreshFingerprintAvailability()
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Vault Unlock & Lock
|
|
// -------------------------------------------------------------------------
|
|
|
|
function unlockVault() {
|
|
pendingUnlockFrom = ""
|
|
unlockVaultWithPassword(masterPassword)
|
|
}
|
|
|
|
function unlockVaultWithPassword(pass) {
|
|
var p = String(pass === undefined || pass === null ? "" : pass)
|
|
if (!p) {
|
|
errorMessage = "Master password required"
|
|
return
|
|
}
|
|
cancelFingerprintUnlock()
|
|
errorMessage = ""
|
|
isUnlocking = true
|
|
// Kept only until the unlock result is known; cleared on both paths below.
|
|
// The short-lived FIFO writer reads it as BW_PASSWORD. unlockProc was
|
|
// already bootstrapping while the user typed and never receives it.
|
|
pendingUnlockPassword = p
|
|
prepareUnlock()
|
|
unlockSubmitted = true
|
|
writeAuthPassword("unlock", p)
|
|
}
|
|
|
|
function onUnlockOutput(stdoutText, stderrText, exitCode) {
|
|
isUnlocking = false
|
|
var out = String(stdoutText || "").trim()
|
|
var err = String(stderrText || "").trim()
|
|
|
|
if (exitCode === 0 && out) {
|
|
onUnlockSuccess(out)
|
|
} else {
|
|
pendingUnlockPassword = ""
|
|
// A stored secret the vault no longer accepts is useless: drop it rather
|
|
// than fail on every open, and say which one went stale.
|
|
if (pendingUnlockFrom === "fingerprint") {
|
|
pendingUnlockFrom = ""
|
|
requestMasterCredentialClear()
|
|
fingerprintStored = false
|
|
fingerprintMessage = "Stored password no longer valid. Unlock with your master password to re-enable fingerprint unlock."
|
|
errorMessage = ""
|
|
focusAppropriateField()
|
|
Qt.callLater(prepareUnlock)
|
|
return
|
|
}
|
|
if (pendingUnlockFrom === "pin") {
|
|
pendingUnlockFrom = ""
|
|
clearPin()
|
|
pinError = "Your master password changed, so the PIN no longer works. Unlock with your password and set a new PIN."
|
|
errorMessage = ""
|
|
focusAppropriateField()
|
|
Qt.callLater(prepareUnlock)
|
|
return
|
|
}
|
|
if (err.indexOf("not logged in") !== -1) {
|
|
status = "unauthenticated"
|
|
currentScreen = "login"
|
|
errorMessage = "You are not logged in. Please log in below."
|
|
} else {
|
|
errorMessage = err || "Unlock failed: invalid master password"
|
|
Qt.callLater(prepareUnlock)
|
|
}
|
|
}
|
|
}
|
|
|
|
function onUnlockSuccess(rawSession) {
|
|
var s = Model.extractSessionToken(rawSession)
|
|
masterPassword = ""
|
|
loginPassword = ""
|
|
loginClientId = ""
|
|
loginClientSecret = ""
|
|
login2faCode = ""
|
|
show2faField = false
|
|
loginDeviceVerification = false
|
|
loginAttemptHadCode = false
|
|
show2faMethodPicker = false
|
|
login2faMethodConfirmed = false
|
|
login2faMethod = rememberedTwoFactorMethod
|
|
loginAttemptMethod = -1
|
|
showDeviceCodeField = false
|
|
loginDeviceCode = ""
|
|
deviceVerificationAttempt = false
|
|
deviceVerificationPending = false
|
|
secondFactorStartedAt = 0
|
|
loginPasswordRetryUsed = false
|
|
initialSyncAttempted = false
|
|
syncLoginFieldsToState()
|
|
isUnlocking = false
|
|
unlockSubmitted = false
|
|
if (!s) {
|
|
errorMessage = "Unlock did not return a session key"
|
|
return
|
|
}
|
|
|
|
session = s
|
|
vaultEpoch += 1
|
|
status = "unlocked"
|
|
currentScreen = "main"
|
|
flashNotification("Vault unlocked successfully!")
|
|
|
|
storeCurrentSession()
|
|
|
|
// Opting in stores the master password so a finger can stand in for it later.
|
|
// Keep an existing enrolment current after a master password change. It no
|
|
// longer creates one -- that is what the setup form is for.
|
|
if (fingerprintUnlock && fingerprintAvailable && fingerprintStored
|
|
&& pendingUnlockPassword && pendingUnlockFrom === ""
|
|
&& !keyringStoreMasterProc.running) {
|
|
masterToStore = pendingUnlockPassword
|
|
beginEpochOperation("masterStore")
|
|
keyringStoreMasterProc.running = true
|
|
} else {
|
|
pendingUnlockPassword = ""
|
|
}
|
|
pendingUnlockFrom = ""
|
|
pinEntry = ""
|
|
pinAttempts = 0
|
|
pinError = ""
|
|
fingerprintMessage = ""
|
|
|
|
beginInitialVaultLoad(true, false)
|
|
resetAutoLockTimer()
|
|
focusAppropriateField()
|
|
}
|
|
|
|
function lockVault() {
|
|
closeFilterGroup()
|
|
cancelAuthPrewarm()
|
|
clearClipboard()
|
|
// Before bw lock is launched, so the companion's deny transition is not
|
|
// sequenced behind it. The panel's own lock never waits on the answer.
|
|
applySshAgentLifecycle("lock")
|
|
if (session) {
|
|
lockProc.command = Model.lockCommand()
|
|
lockProc.running = true
|
|
}
|
|
// Not `if (rememberSession)`. The setting says whether to write a token,
|
|
// not whether one is there: turning it off after a session was remembered
|
|
// used to mean the lock skipped the erase and left the token behind.
|
|
// Clearing an entry that was never written is a no-op nobody reads.
|
|
requestSessionCredentialClear()
|
|
|
|
dropVaultState()
|
|
status = "locked"
|
|
currentScreen = "locked"
|
|
fingerprintMessage = ""
|
|
flashNotification("Vault locked")
|
|
focusAppropriateField()
|
|
if (sshAuthSurfaceActive) startFingerprintUnlock()
|
|
}
|
|
|
|
function vaultStatePresent() {
|
|
return !!session || status === "unlocked" || items.length > 0
|
|
|| organizations.length > 0 || folders.length > 0 || detailItem !== null
|
|
|| sends.length > 0 || itemPayloadJson !== "" || sendPayloadJson !== ""
|
|
}
|
|
|
|
// One local purge for every way an open vault stops being usable. Keeping
|
|
// this separate from the `bw lock` and keyring side effects lets a status
|
|
// transition fail closed without pretending that a remote/local CLI error
|
|
// was a successful Bitwarden lock command.
|
|
function dropVaultState() {
|
|
initialSyncAttempted = false
|
|
pinUnlockSubmitted = false
|
|
cancelFingerprintUnlock()
|
|
cancelAttachmentDownloads()
|
|
session = ""
|
|
vaultEpoch += 1
|
|
readEpochs = ({})
|
|
masterPassword = ""
|
|
itemsLoadedAt = 0
|
|
orgsLoadedAt = 0
|
|
foldersLoadedAt = 0
|
|
items = []
|
|
filteredItems = []
|
|
organizations = []
|
|
folders = []
|
|
selectedOrg = "all"
|
|
selectedFolder = "all"
|
|
openFilterGroup = ""
|
|
searchQuery = ""
|
|
selectedCategory = "all"
|
|
selectedIndex = 0
|
|
detailItem = null
|
|
revealedFields = ({})
|
|
attachmentSaved = ({})
|
|
formIsEditing = false
|
|
formItemId = ""
|
|
formTypeCode = 1
|
|
clearTypeFields()
|
|
formName = ""
|
|
formUsername = ""
|
|
formUri = ""
|
|
formNotes = ""
|
|
formFavorite = false
|
|
formOrgId = ""
|
|
formFolderId = ""
|
|
formPicker = ""
|
|
formCollections = []
|
|
formCollectionIds = []
|
|
formCollectionsLoading = false
|
|
newFolderName = ""
|
|
creatingFolder = false
|
|
totpFollowupActive = false
|
|
isLoading = false
|
|
isUnlocking = false
|
|
isSyncing = false
|
|
metadataLoadPending = false
|
|
metadataForceRefresh = false
|
|
statusRefreshAfterItems = false
|
|
syncReloadPending = false
|
|
sendsLoading = false
|
|
sendBusy = false
|
|
genBusy = false
|
|
pendingUnlockPassword = ""
|
|
sessionStorePending = false
|
|
dropVaultSecrets()
|
|
}
|
|
|
|
// A locked vault means the panel is holding nothing out of it, and nothing
|
|
// that would open it again. detailPassword and liveTotp were always dropped
|
|
// here; the rest were not, and each of them is the same kind of thing -- a
|
|
// generated password nobody copied, an item or Send form left mid-compose,
|
|
// the payload JSON on its way to bw, the master password typed into whichever
|
|
// setup form was open. The vault relocks after fifteen idle minutes and the
|
|
// shell process lives for the whole desktop session, so a property that
|
|
// survives a lock survives everything.
|
|
function dropVaultSecrets() {
|
|
detailPassword = ""
|
|
liveTotp = ""
|
|
totpRequestItemId = ""
|
|
totpQueuedItemId = ""
|
|
totpQueuedEpoch = -1
|
|
totpRestartPending = false
|
|
totpCopyItemId = ""
|
|
passwordCopyItemId = ""
|
|
totpFollowupItem = null
|
|
totpFollowupCode = ""
|
|
genValue = ""
|
|
formPassword = ""
|
|
formTotp = ""
|
|
itemPayloadJson = ""
|
|
sends = []
|
|
sendPayloadJson = ""
|
|
sendFormText = ""
|
|
sendFormPassword = ""
|
|
loginPassword = ""
|
|
login2faCode = ""
|
|
show2faField = false
|
|
loginDeviceVerification = false
|
|
loginAttemptHadCode = false
|
|
show2faMethodPicker = false
|
|
login2faMethodConfirmed = false
|
|
login2faMethod = rememberedTwoFactorMethod
|
|
loginAttemptMethod = -1
|
|
showDeviceCodeField = false
|
|
loginDeviceCode = ""
|
|
deviceVerificationAttempt = false
|
|
deviceVerificationPending = false
|
|
secondFactorStartedAt = 0
|
|
loginPasswordRetryUsed = false
|
|
loginClientId = ""
|
|
loginClientSecret = ""
|
|
syncLoginFieldsToState()
|
|
pinEntry = ""
|
|
pinSetupPin = ""
|
|
pinSetupConfirm = ""
|
|
pinSetupMaster = ""
|
|
fpSetupMaster = ""
|
|
masterToStore = ""
|
|
pendingAssociationsJson = ""
|
|
scrubSecretBuffers()
|
|
}
|
|
|
|
// Emptying those properties leaves the values they were copied out of still
|
|
// sitting in the collectors that read them, which is the same residue one
|
|
// step upstream. See the collector-scrubbing note in BitwardenModel.js for
|
|
// why running a command that prints nothing is the way to clear one.
|
|
//
|
|
// Built on demand rather than held as a property: these ids are declared
|
|
// below this point, and a list bound at creation time would be a list of
|
|
// undefineds.
|
|
function secretProcesses() {
|
|
return [
|
|
statusProc, sessionHandoffProc, keyringLookupProc, pinUnlockProc, keyringLookupMasterProc,
|
|
loginProc, unlockProc, listProc, listOrgsProc, listFoldersProc, orgCollectionsProc,
|
|
getItemProc, getTotpProc, generateProc, listSendsProc, createSendProc,
|
|
copyPasswordProc,
|
|
createItemProc, editItemProc, deleteItemProc, createFolderProc, attachmentProc,
|
|
associationsReadProc, generateServeRequestProc
|
|
]
|
|
}
|
|
|
|
function scrubSecretBuffers() {
|
|
scrubPending = secretProcesses()
|
|
scrubStep()
|
|
if (scrubPending.length) scrubRetry.restart()
|
|
}
|
|
|
|
// A process still running when the vault locked cannot be scrubbed yet --
|
|
// its buffer is in the middle of being written, and taking its command away
|
|
// would abandon a read someone is still waiting on. It stays in the queue
|
|
// and the retry comes back for it.
|
|
function scrubStep() {
|
|
var pass = Model.scrubPass(scrubPending)
|
|
for (var i = 0; i < pass.start.length; i++) {
|
|
pass.start[i].command = Model.scrubCommand()
|
|
pass.start[i].running = true
|
|
}
|
|
scrubPending = pass.waiting
|
|
}
|
|
|
|
// Complete a scrub before its handler can reuse the same Process. What
|
|
// arrives from a scrub is an empty string and exit status zero, which reads
|
|
// as a successful login, empty vault or saved item unless every handler asks
|
|
// here first.
|
|
function finishScrubRun(proc) {
|
|
if (!Model.isScrubCommand(proc.command)) return false
|
|
scrubPending = Model.finishScrub(scrubPending, proc)
|
|
if (!scrubPending.length) scrubRetry.stop()
|
|
return true
|
|
}
|
|
|
|
function clearProcessCollectorSoon(proc) {
|
|
Qt.callLater(function() {
|
|
if (proc.running) return
|
|
// Deferred by a callLater, so a submit can arrive between the schedule
|
|
// and the run. Taking the process here would make that submit wait on
|
|
// the scrub instead of on its own login.
|
|
if (proc === loginProc
|
|
&& (loginSubmitAfterPrewarmStop || loginPrepareAfterPrewarmStop
|
|
|| deviceVerificationPending || loginSubmitted)) return
|
|
proc.command = Model.scrubCommand()
|
|
proc.running = true
|
|
})
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Vault Data Operations
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Stamped on a reader as it starts, and checked again where its answer
|
|
// arrives. A `bw` already in flight when the vault locks cannot be called
|
|
// back -- it is past the point where the session mattered -- so the only
|
|
// place left to refuse its answer is the completion handler. See the Vault
|
|
// generation section of BitwardenModel.js for what that answer costs when
|
|
// nobody refuses it.
|
|
function beginEpochOperation(name) {
|
|
readEpochs[name] = vaultEpoch
|
|
}
|
|
|
|
function epochOperationIsStale(name) {
|
|
return Number(readEpochs[name]) !== Number(vaultEpoch)
|
|
}
|
|
|
|
function invalidateEpochOperation(name) {
|
|
readEpochs[name] = vaultEpoch - 1
|
|
}
|
|
|
|
function beginVaultRead(name) {
|
|
beginEpochOperation(name)
|
|
}
|
|
|
|
function vaultReadIsStale(name) {
|
|
return epochOperationIsStale(name) || !session
|
|
}
|
|
|
|
// The first post-authentication process is always the item list. Organization
|
|
// and folder metadata each need another bw bootstrap, so they are scheduled
|
|
// only after items have reached the model and had time to paint.
|
|
function beginInitialVaultLoad(showSpinner, forceMetadata) {
|
|
metadataLoadPending = true
|
|
metadataForceRefresh = forceMetadata === true
|
|
loadItems(showSpinner)
|
|
}
|
|
|
|
// Open-time load: skip the CLI entirely when the in-memory vault is fresh.
|
|
// Stale-while-revalidate. `bw list items` is a CLI bootstrap plus a full
|
|
// vault decrypt, so blocking the panel on it means a spinner on every open
|
|
// once the cache ages out. Show what we already have immediately, refresh
|
|
// behind it, and swap the list in when it lands. The spinner is only for
|
|
// the case where there is genuinely nothing to show yet.
|
|
function ensureItemsFresh() {
|
|
var haveItems = items.length > 0
|
|
var stale = (Date.now() - itemsLoadedAt) >= itemsFreshMs
|
|
|
|
if (haveItems) {
|
|
if (activeWindowData) handleActiveWindowDetected(activeWindowData)
|
|
else rebuildFilter()
|
|
if (!stale) return
|
|
}
|
|
|
|
beginInitialVaultLoad(!haveItems, false)
|
|
}
|
|
|
|
// `showSpinner` defaults to true, so existing callers are unchanged; a
|
|
// background revalidation passes false and refreshes without the UI moving.
|
|
function loadItems(showSpinner) {
|
|
if (!session) return
|
|
if (showSpinner !== false) isLoading = true
|
|
beginVaultRead("items")
|
|
listReadMode = Model.vaultListMode(dependencies)
|
|
if (listReadMode === "blocked") {
|
|
isLoading = false
|
|
if (!vaultReadIsStale("items")) errorMessage = Model.vaultListBlockedMessage(dependencies)
|
|
return
|
|
}
|
|
startVaultListRead(false)
|
|
}
|
|
|
|
// The one place the item read is launched, so the agent branch and its
|
|
// retry-without-it cannot drift apart. `retrying` is the second attempt
|
|
// after a fan-out read failed; it never carries the branch.
|
|
function startVaultListRead(retrying) {
|
|
var useAgent = !retrying && sshAgentGateOpen && Model.isValidLoadId(sshAgentNextLoadId)
|
|
if (useAgent) {
|
|
sshAgentEpoch += 1
|
|
sshAgentLoadId = sshAgentNextLoadId
|
|
sshAgentNextLoadId = ""
|
|
sshAgentLoadActive = true
|
|
sshAgentLoadedForVaultEpoch = root.vaultEpoch
|
|
if (sshAgentProc.stdinEnabled) {
|
|
sshAgentProc.write(Model.sshAgentLoadBeginLine(sshAgentEpoch, sshAgentLoadId))
|
|
}
|
|
}
|
|
listAgentBranchActive = useAgent
|
|
listProc.environment = root.vaultListEnv(useAgent ? sshAgentLoadId : "")
|
|
listProc.command = Model.sanitizedListCommand({ agentBranch: useAgent })
|
|
listProc.running = true
|
|
}
|
|
|
|
// The nonce reaches `jq` through the environment rather than argv, because
|
|
// /proc/<pid>/cmdline is world-readable and the nonce's whole purpose is
|
|
// being unguessable by another process running as this user.
|
|
function vaultListEnv(loadId) {
|
|
var env = root.bwEnv()
|
|
env[Model.loadIdEnvVar()] = loadId !== "" ? loadId : null
|
|
return env
|
|
}
|
|
|
|
function onListFinished(rawJson) {
|
|
isLoading = false
|
|
if (vaultReadIsStale("items")) return
|
|
sshCapability = Model.inspectSanitizedVault(rawJson)
|
|
items = Model.parseSanitizedItems(rawJson)
|
|
itemsLoadedAt = Date.now()
|
|
refreshDerivedFromItems()
|
|
if (syncReloadPending) {
|
|
syncReloadPending = false
|
|
isSyncing = false
|
|
flashNotification("Vault synced with Bitwarden")
|
|
}
|
|
if (metadataLoadPending) deferredMetadataTimer.restart()
|
|
// The first read of a session usually beats the helper's handshake, so it
|
|
// carries no keys. Now that it has landed, check whether one is owed.
|
|
maybeStartupLoad()
|
|
}
|
|
|
|
function onListProcessExited(exitCode, rawJson, stderrText) {
|
|
if (finishScrubRun(listProc)) return
|
|
var hadAgentBranch = listAgentBranchActive
|
|
listAgentBranchActive = false
|
|
endSshAgentLoad(exitCode === 0)
|
|
|
|
if (exitCode === 0) {
|
|
listRetriedWithoutAgent = false
|
|
onListFinished(rawJson)
|
|
return
|
|
}
|
|
|
|
// The optional feature is never allowed to cost the user their item list.
|
|
// One retry, without the branch, before anything is reported as an error.
|
|
if (hadAgentBranch && !listRetriedWithoutAgent && !vaultReadIsStale("items")) {
|
|
listRetriedWithoutAgent = true
|
|
beginVaultRead("items")
|
|
startVaultListRead(true)
|
|
return
|
|
}
|
|
listRetriedWithoutAgent = false
|
|
|
|
isLoading = false
|
|
isSyncing = false
|
|
syncReloadPending = false
|
|
metadataLoadPending = false
|
|
metadataForceRefresh = false
|
|
if (statusRefreshAfterItems) {
|
|
statusRefreshAfterItems = false
|
|
}
|
|
if (!vaultReadIsStale("items")) {
|
|
errorMessage = Model.vaultListFailureMessage(stderrText, dependencies, listReadMode)
|
|
}
|
|
}
|
|
|
|
// Each of these is its own `bw` invocation, and organizations and folders
|
|
// change rarely -- new ones arrive through this panel, which invalidates
|
|
// them explicitly. `force` is for exactly that case.
|
|
function loadOrganizations(force) {
|
|
if (!session) return
|
|
if (!force && organizations.length > 0 && (Date.now() - orgsLoadedAt) < metaFreshMs) return
|
|
beginVaultRead("organizations")
|
|
listOrgsProc.command = Model.listOrganizationsCommand()
|
|
listOrgsProc.running = true
|
|
}
|
|
|
|
function onListOrgsFinished(rawJson) {
|
|
if (vaultReadIsStale("organizations")) return
|
|
organizations = Model.parseOrganizations(rawJson)
|
|
orgsLoadedAt = Date.now()
|
|
}
|
|
|
|
function loadFolders(force) {
|
|
if (!session) return
|
|
if (!force && folders.length > 0 && (Date.now() - foldersLoadedAt) < metaFreshMs) return
|
|
beginVaultRead("folders")
|
|
listFoldersProc.command = Model.listFoldersCommand()
|
|
listFoldersProc.running = true
|
|
}
|
|
|
|
function onListFoldersFinished(rawJson) {
|
|
if (vaultReadIsStale("folders")) return
|
|
folders = Model.parseFolders(rawJson)
|
|
foldersLoadedAt = Date.now()
|
|
}
|
|
|
|
function selectFolder(folderId) {
|
|
selectedFolder = folderId
|
|
selectedIndex = 0
|
|
openFilterGroup = ""
|
|
rebuildFilter()
|
|
}
|
|
|
|
function toggleFilterGroup(group) {
|
|
if (openFilterGroup === group) {
|
|
openFilterGroup = ""
|
|
return
|
|
}
|
|
openFilterGroup = group
|
|
// Start on whichever option is currently active, so Enter is a no-op
|
|
// rather than a surprise.
|
|
var opts = filterOptions(group)
|
|
filterOptionIndex = 0
|
|
for (var i = 0; i < opts.length; i++) {
|
|
if (opts[i].active) { filterOptionIndex = i; break }
|
|
}
|
|
}
|
|
|
|
// Any action that is not part of the drawer closes it, so it never lingers
|
|
// over the results the user just filtered down to.
|
|
function closeFilterGroup() {
|
|
if (openFilterGroup !== "") openFilterGroup = ""
|
|
}
|
|
|
|
function moveFilterCursor(delta) {
|
|
var n = currentFilterOptions.length
|
|
if (n === 0) return
|
|
filterOptionIndex = Math.max(0, Math.min(n - 1, filterOptionIndex + delta))
|
|
}
|
|
|
|
function activateFilterOption() {
|
|
var opts = currentFilterOptions
|
|
if (filterOptionIndex < 0 || filterOptionIndex >= opts.length) return
|
|
applyFilterOption(openFilterGroup, opts[filterOptionIndex].id)
|
|
}
|
|
|
|
// Labels for the collapsed buttons, so the current filter is readable
|
|
// without opening anything.
|
|
function folderFilterLabel() {
|
|
if (selectedFolder === "all") return "All"
|
|
if (selectedFolder === "none") return "Unfiled"
|
|
return Model.folderName(folders, selectedFolder) || "Folder"
|
|
}
|
|
|
|
function organizationFilterLabel() {
|
|
if (selectedOrg === "all") return "All"
|
|
if (selectedOrg === "personal") return "Personal"
|
|
for (var i = 0; i < organizations.length; i++) {
|
|
if (organizations[i].id === selectedOrg) return organizations[i].name
|
|
}
|
|
return "Vault"
|
|
}
|
|
|
|
function typeFilterLabel() {
|
|
for (var i = 0; i < categories.length; i++) {
|
|
if (categories[i].id === selectedCategory) return categories[i].label
|
|
}
|
|
return "All"
|
|
}
|
|
|
|
// Option rows for whichever group is open, in one shape so the three lists
|
|
// render identically.
|
|
function filterOptions(group) {
|
|
var out = []
|
|
var i
|
|
if (group === "folders") {
|
|
out.push({ id: "all", label: "All Folders", icon: "", active: selectedFolder === "all" })
|
|
out.push({ id: "none", label: "No Folder", icon: "", active: selectedFolder === "none" })
|
|
for (i = 0; i < folders.length; i++) {
|
|
out.push({ id: folders[i].id, label: folders[i].name, icon: "", active: selectedFolder === folders[i].id })
|
|
}
|
|
} else if (group === "organizations") {
|
|
out.push({ id: "all", label: "All Organizations", icon: "", active: selectedOrg === "all" })
|
|
out.push({ id: "personal", label: "My Vault", icon: "", active: selectedOrg === "personal" })
|
|
for (i = 0; i < organizations.length; i++) {
|
|
out.push({ id: organizations[i].id, label: organizations[i].name, icon: "", active: selectedOrg === organizations[i].id })
|
|
}
|
|
} else if (group === "types") {
|
|
for (i = 0; i < visibleCategories.length; i++) {
|
|
out.push({ id: visibleCategories[i].id, label: visibleCategories[i].label, icon: visibleCategories[i].icon, active: selectedCategory === visibleCategories[i].id })
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
function applyFilterOption(group, id) {
|
|
if (group === "folders") selectFolder(id)
|
|
else if (group === "organizations") { selectOrganization(id); openFilterGroup = "" }
|
|
else if (group === "types") { selectCategory(id); openFilterGroup = "" }
|
|
}
|
|
|
|
function toggleFormPicker(which) {
|
|
formPicker = (formPicker === which) ? "" : which
|
|
}
|
|
|
|
// What Escape does, wherever it is pressed. Kept here rather than inline in
|
|
// the key handler because it has two callers: PanelKeyCatcher's
|
|
// closeRequested, and the shortcut interceptor -- the catcher goes `blocked`
|
|
// on every screen with a text field, which used to take Escape down with it.
|
|
//
|
|
// Innermost thing first: a drawer or picker closes before the screen it is
|
|
// on, and a screen goes back before the panel closes.
|
|
function handleEscape() {
|
|
// Ahead of every other screen: a signing request is a question with a
|
|
// client blocked on the answer, so dismissing it has to mean "no" rather
|
|
// than "later".
|
|
if (currentScreen === "sshApproval" || sshUnlockRequest) {
|
|
denySshRequest()
|
|
return
|
|
}
|
|
if (openFilterGroup !== "") {
|
|
closeFilterGroup()
|
|
return
|
|
}
|
|
if (currentScreen === "edit" && formPicker !== "") {
|
|
formPicker = ""
|
|
return
|
|
}
|
|
if (currentScreen === "sends") {
|
|
if (sendMode === "create") {
|
|
sendError = ""
|
|
sendMode = "list"
|
|
// Leaving the composer does not change the screen, so nothing else
|
|
// takes focus off its (now hidden) name field.
|
|
restoreScreenFocus()
|
|
} else {
|
|
currentScreen = "main"
|
|
}
|
|
} else if (currentScreen === "generator") {
|
|
// Back to the item form when that is where this came from, leaving
|
|
// the password field as it was.
|
|
closeGenerator()
|
|
} else if (currentScreen === "fingerprint") {
|
|
fpError = ""
|
|
currentScreen = "settings"
|
|
} else if (currentScreen === "pin") {
|
|
pinError = ""
|
|
currentScreen = "settings"
|
|
} else if (currentScreen === "settings") {
|
|
closeSettings()
|
|
} else if (currentScreen === "setup") {
|
|
dismissSetup()
|
|
} else if (currentScreen === "edit") {
|
|
// Editing is abandoned, not saved -- the form is scratch space until
|
|
// Save, and Escape is how you throw it away. Back where the form was
|
|
// opened from, which is what the form's own Cancel button does.
|
|
currentScreen = formIsEditing ? "detail" : "main"
|
|
} else if (currentScreen === "detail") {
|
|
currentScreen = "main"
|
|
} else {
|
|
close()
|
|
}
|
|
}
|
|
|
|
// Qt does not clear active focus when an item is hidden, so leaving a screen
|
|
// whose field had focus leaves that field owning the keyboard from behind
|
|
// whatever replaced it -- which is how Escape on the item form reached the
|
|
// search box and closed the panel. Re-home focus whenever the screen
|
|
// changes, and the stale owner goes with it.
|
|
onCurrentScreenChanged: {
|
|
// The server lives as long as the screen that needs it and no longer. A
|
|
// loopback port has no authentication and every account on the machine can
|
|
// reach it, and `bw serve` answers /status with the account email and user
|
|
// id whether the vault is locked or not. Holding that open for hours to
|
|
// save a second on a screen visited for a few is the wrong trade.
|
|
if (currentScreen !== "generator") stopGeneratorServe()
|
|
// Both setup forms ask for the master password, and both used to keep it
|
|
// for the rest of the shell's life: Cancel and Escape only reset the error
|
|
// line. Leaving the form is the answer either way, so the clearing lives
|
|
// here rather than at each of the ways out.
|
|
if (currentScreen !== "pin") abandonPinSetup()
|
|
if (currentScreen !== "fingerprint") abandonFingerprintSetup()
|
|
restoreScreenFocus()
|
|
}
|
|
|
|
function restoreScreenFocus() {
|
|
Qt.callLater(function() {
|
|
if (status !== "unlocked") { focusAppropriateField(); return }
|
|
switch (currentScreen) {
|
|
case "main": searchField.forceActiveFocus(); return
|
|
case "edit": formNameField.forceActiveFocus(); return
|
|
// These open through a function that focuses their own first field.
|
|
case "pin": case "fingerprint": return
|
|
case "sends": if (sendMode === "create") return; break
|
|
}
|
|
// Everything else is keyboard-navigated rather than typed into.
|
|
keyCatcher.forceActiveFocus()
|
|
})
|
|
}
|
|
|
|
function setFormFolder(id) {
|
|
formFolderId = id
|
|
formPicker = ""
|
|
}
|
|
|
|
// Changing owner invalidates the collection choice: collections belong to a
|
|
// single organization, and a personal item cannot have any.
|
|
function setFormOrganization(id) {
|
|
formOrgId = id
|
|
formPicker = ""
|
|
formCollectionIds = []
|
|
formCollections = []
|
|
if (id && id !== "personal" && id !== "all") loadOrgCollections(id)
|
|
}
|
|
|
|
function loadOrgCollections(orgId) {
|
|
if (!session || !orgId) return
|
|
formCollectionsLoading = true
|
|
beginVaultRead("collections")
|
|
orgCollectionsProc.command = Model.listOrgCollectionsCommand(orgId)
|
|
orgCollectionsProc.running = true
|
|
}
|
|
|
|
function onOrgCollectionsLoaded(raw) {
|
|
formCollectionsLoading = false
|
|
if (vaultReadIsStale("collections")) return
|
|
formCollections = Model.parseCollections(raw)
|
|
// A single collection is not a choice; pre-select it.
|
|
if (formCollections.length === 1 && formCollectionIds.length === 0) {
|
|
formCollectionIds = [formCollections[0].id]
|
|
}
|
|
}
|
|
|
|
function toggleFormCollection(id) {
|
|
var next = []
|
|
var found = false
|
|
for (var i = 0; i < formCollectionIds.length; i++) {
|
|
if (formCollectionIds[i] === id) found = true
|
|
else next.push(formCollectionIds[i])
|
|
}
|
|
if (!found) next.push(id)
|
|
formCollectionIds = next
|
|
}
|
|
|
|
function isFormCollectionSelected(id) {
|
|
for (var i = 0; i < formCollectionIds.length; i++) {
|
|
if (formCollectionIds[i] === id) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function formFolderLabel() {
|
|
if (!formFolderId) return "No Folder"
|
|
return Model.folderName(folders, formFolderId) || "No Folder"
|
|
}
|
|
|
|
function formOrgLabel() {
|
|
if (!formOrgId || formOrgId === "personal") return "My Vault"
|
|
for (var i = 0; i < organizations.length; i++) {
|
|
if (organizations[i].id === formOrgId) return organizations[i].name
|
|
}
|
|
return "My Vault"
|
|
}
|
|
|
|
function submitNewFolder() {
|
|
var name = String(newFolderName || "").trim()
|
|
if (!name) return
|
|
creatingFolder = true
|
|
beginVaultRead("folderCreate")
|
|
createFolderProc.command = Model.createFolderCommand()
|
|
createFolderProc.running = true
|
|
}
|
|
|
|
function onFolderCreated(exitCode, stdoutText) {
|
|
creatingFolder = false
|
|
if (vaultReadIsStale("folderCreate")) return
|
|
if (exitCode !== 0) {
|
|
errorMessage = "Could not create folder"
|
|
return
|
|
}
|
|
var created = null
|
|
try { created = JSON.parse(stdoutText) } catch (e) { created = null }
|
|
newFolderName = ""
|
|
// Creating a folder from the item form is only ever a prelude to filing
|
|
// the item into it, so select it straight away.
|
|
if (created && created.id) formFolderId = String(created.id)
|
|
flashNotification("Folder created")
|
|
loadFolders(true)
|
|
}
|
|
|
|
function syncVault() {
|
|
closeFilterGroup()
|
|
if (!session) return
|
|
isSyncing = true
|
|
beginVaultRead("sync")
|
|
syncProc.command = Model.syncCommand()
|
|
syncProc.running = true
|
|
}
|
|
|
|
function onSyncFinished(exitCode) {
|
|
if (vaultReadIsStale("sync")) return
|
|
if (exitCode === 0) {
|
|
itemsLoadedAt = 0
|
|
syncReloadPending = true
|
|
beginInitialVaultLoad(true, true)
|
|
} else {
|
|
isSyncing = false
|
|
syncReloadPending = false
|
|
errorMessage = "Sync failed"
|
|
}
|
|
}
|
|
|
|
function openDetail(item) {
|
|
closeFilterGroup()
|
|
if (!item || !item.id) return
|
|
learnFromPick(item)
|
|
isLoading = true
|
|
errorMessage = ""
|
|
revealedFields = ({})
|
|
showDeleteConfirm = false
|
|
detailItem = null
|
|
detailPassword = ""
|
|
liveTotp = ""
|
|
// Another item's downloads say nothing about this one's.
|
|
attachmentQueue = []
|
|
attachmentSaved = ({})
|
|
currentScreen = "detail"
|
|
|
|
// The list already fetched the whole item, so render from that rather than
|
|
// spending a second CLI round trip on data we are holding. Only fall back
|
|
// to `bw get item` if this item somehow arrived without its raw object.
|
|
var detail = item.rawObject ? Model.itemDetailFromObject(item.rawObject) : null
|
|
if (detail) {
|
|
isLoading = false
|
|
detailItem = detail
|
|
detailPassword = detail.password
|
|
} else {
|
|
beginVaultRead("detail")
|
|
if (item.typeCode === 5) {
|
|
isLoading = false
|
|
errorMessage = "SSH keys are read-only public records"
|
|
currentScreen = "main"
|
|
return
|
|
}
|
|
getItemProc.command = Model.getItemCommand(item.id, item.typeCode)
|
|
getItemProc.running = true
|
|
}
|
|
|
|
// The TOTP code is time-based, so it is the one thing the list cannot
|
|
// carry. It loads alongside rather than in front of the detail view.
|
|
if (item.hasTotp) {
|
|
fetchTotp(item.id)
|
|
}
|
|
}
|
|
|
|
function onDetailFinished(rawJson) {
|
|
isLoading = false
|
|
if (vaultReadIsStale("detail")) return
|
|
var parsed = Model.parseItemDetail(rawJson)
|
|
if (parsed) {
|
|
detailItem = parsed
|
|
detailPassword = parsed.password
|
|
} else {
|
|
errorMessage = "Could not load item details"
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Attachments
|
|
// -------------------------------------------------------------------------
|
|
|
|
function cancelAttachmentDownloads() {
|
|
attachmentQueue = []
|
|
attachmentBusyId = ""
|
|
invalidateEpochOperation("attachment")
|
|
// A download holds decrypted bytes and the session it inherited at start.
|
|
// The supervised process group removes its private staging directory and
|
|
// cannot commit a file after the vault or panel has closed.
|
|
if (attachmentProc.running) attachmentProc.running = false
|
|
}
|
|
|
|
function queueAttachment(att) {
|
|
if (!detailItem || !att || !att.id) return
|
|
if (attachmentBusyId === att.id) return
|
|
for (var i = 0; i < attachmentQueue.length; i++) {
|
|
if (attachmentQueue[i].id === att.id) return
|
|
}
|
|
resetAutoLockTimer()
|
|
errorMessage = ""
|
|
var next = attachmentQueue.slice()
|
|
// The declared size travels with the job so the saver can refuse an
|
|
// oversized attachment before it starts, and check the disk has room.
|
|
next.push({ id: att.id, fileName: att.fileName, itemId: detailItem.id, size: att.size })
|
|
attachmentQueue = next
|
|
pumpAttachmentQueue()
|
|
}
|
|
|
|
function saveAllAttachments() {
|
|
if (!detailItem || !detailItem.attachments) return
|
|
for (var i = 0; i < detailItem.attachments.length; i++) {
|
|
queueAttachment(detailItem.attachments[i])
|
|
}
|
|
}
|
|
|
|
function pumpAttachmentQueue() {
|
|
if (attachmentBusyId !== "" || attachmentQueue.length === 0) return
|
|
if (!session) {
|
|
attachmentQueue = []
|
|
errorMessage = "Vault is locked or session expired. Please unlock your vault."
|
|
return
|
|
}
|
|
var next = attachmentQueue.slice()
|
|
var job = next.shift()
|
|
attachmentQueue = next
|
|
attachmentBusyId = job.id
|
|
beginVaultRead("attachment")
|
|
attachmentProc.command = Model.attachmentDownloadCommand(job.id, job.itemId, job.fileName, job.size)
|
|
attachmentProc.running = true
|
|
}
|
|
|
|
function onAttachmentDownloaded(exitCode, savedPath, stderrText) {
|
|
var id = attachmentBusyId
|
|
attachmentBusyId = ""
|
|
if (vaultReadIsStale("attachment")) return
|
|
var path = String(savedPath || "").trim()
|
|
|
|
if (exitCode !== 0 || !path) {
|
|
// bw's own message is the useful one -- "Not found." for an attachment
|
|
// that has since been deleted, or a permission error on the directory.
|
|
var err = String(stderrText || "").trim().split("\n")[0]
|
|
errorMessage = err ? ("Could not save the attachment: " + err)
|
|
: "Could not save the attachment"
|
|
attachmentQueue = []
|
|
return
|
|
}
|
|
|
|
var saved = {}
|
|
for (var k in attachmentSaved) saved[k] = attachmentSaved[k]
|
|
saved[id] = path
|
|
attachmentSaved = saved
|
|
flashNotification("Saved " + Model.baseName(path))
|
|
pumpAttachmentQueue()
|
|
}
|
|
|
|
function attachmentSavedPath(id) {
|
|
return (attachmentSaved && attachmentSaved[id]) ? String(attachmentSaved[id]) : ""
|
|
}
|
|
|
|
function isAttachmentQueued(id) {
|
|
for (var i = 0; i < attachmentQueue.length; i++) {
|
|
if (attachmentQueue[i].id === id) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function openSavedAttachment(id) {
|
|
var path = attachmentSaved[id]
|
|
if (!path) return
|
|
resetAutoLockTimer()
|
|
Quickshell.execDetached(["xdg-open", path])
|
|
}
|
|
|
|
function revealSavedAttachment(id) {
|
|
var path = attachmentSaved[id]
|
|
if (!path) return
|
|
var dir = Model.parentDirectory(path)
|
|
if (!dir) return
|
|
resetAutoLockTimer()
|
|
Quickshell.execDetached(["xdg-open", dir])
|
|
}
|
|
|
|
function fetchTotp(itemId, copyWhenReady) {
|
|
if (!session || !itemId) return
|
|
if (copyWhenReady) totpCopyItemId = String(itemId)
|
|
if (getTotpProc.running || totpRestartPending) {
|
|
if (totpRequestItemId !== String(itemId)) {
|
|
totpQueuedItemId = String(itemId)
|
|
totpQueuedEpoch = vaultEpoch
|
|
}
|
|
return
|
|
}
|
|
startTotpFetch(String(itemId))
|
|
}
|
|
|
|
function startTotpFetch(itemId) {
|
|
if (!session || !itemId) return
|
|
totpRequestItemId = itemId
|
|
beginVaultRead("totp")
|
|
getTotpProc.command = Model.getTotpCommand(itemId)
|
|
getTotpProc.running = true
|
|
}
|
|
|
|
function onTotpProcessExited(exitCode, code) {
|
|
var itemId = totpRequestItemId
|
|
totpRequestItemId = ""
|
|
if (exitCode === 0) onTotpFinished(itemId, code)
|
|
else if (totpCopyItemId === itemId) {
|
|
totpCopyItemId = ""
|
|
errorMessage = "Could not read this TOTP code"
|
|
}
|
|
|
|
continueTotpQueue(false)
|
|
}
|
|
|
|
function continueTotpQueue(collectorIsClean) {
|
|
var queued = totpQueuedItemId
|
|
var queuedEpoch = totpQueuedEpoch
|
|
totpQueuedItemId = ""
|
|
totpQueuedEpoch = -1
|
|
if (queued) {
|
|
// Reserve this Process before deferring its restart. Without the flag, a
|
|
// newer request can start in this one-event-loop gap and then be
|
|
// overwritten by the older queued request.
|
|
totpRestartPending = true
|
|
totpRequestItemId = queued
|
|
Qt.callLater(function() {
|
|
root.totpRestartPending = false
|
|
if (queuedEpoch === root.vaultEpoch && root.session) root.startTotpFetch(queued)
|
|
else {
|
|
if (root.totpRequestItemId === queued) root.totpRequestItemId = ""
|
|
if (!collectorIsClean) root.clearProcessCollectorSoon(getTotpProc)
|
|
}
|
|
})
|
|
}
|
|
else if (!collectorIsClean) clearProcessCollectorSoon(getTotpProc)
|
|
}
|
|
|
|
function onTotpFinished(itemId, code) {
|
|
if (vaultReadIsStale("totp")) return
|
|
var c = String(code || "").trim()
|
|
if (detailItem && detailItem.id === itemId) liveTotp = c
|
|
if (totpFollowupActive && totpFollowupItem && totpFollowupItem.id === itemId) {
|
|
totpFollowupCode = c
|
|
}
|
|
if (totpCopyItemId === itemId) {
|
|
totpCopyItemId = ""
|
|
if (c) copyToClipboard(c, "TOTP code")
|
|
else errorMessage = "Could not read this TOTP code"
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// CRUD Operations (Add, Edit, Delete)
|
|
// -------------------------------------------------------------------------
|
|
|
|
// The card or identity boxes, in the shape buildCreatePayload and
|
|
// buildEditPayload want. Returns null for a login or a note, and null is
|
|
// exactly what tells buildEditPayload to leave an existing sub-object alone.
|
|
function formTypeFields() {
|
|
if (formTypeCode === 3) {
|
|
return {
|
|
cardholderName: formCardholderName, brand: formCardBrand,
|
|
number: formCardNumber, expMonth: formCardExpMonth,
|
|
expYear: formCardExpYear, code: formCardCode
|
|
}
|
|
}
|
|
if (formTypeCode === 4) {
|
|
return {
|
|
title: formIdTitle, firstName: formIdFirstName,
|
|
middleName: formIdMiddleName, lastName: formIdLastName,
|
|
username: formIdUsername, company: formIdCompany,
|
|
email: formIdEmail, phone: formIdPhone, ssn: formIdSsn,
|
|
passportNumber: formIdPassport, licenseNumber: formIdLicense,
|
|
address1: formIdAddress1, address2: formIdAddress2,
|
|
address3: formIdAddress3, city: formIdCity, state: formIdState,
|
|
postalCode: formIdPostalCode, country: formIdCountry
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Every card and identity box, emptied. Called wherever the form resets so
|
|
// a new item never opens wearing the last one's card number.
|
|
function clearTypeFields() {
|
|
formCardholderName = ""; formCardBrand = ""; formCardNumber = ""
|
|
formCardExpMonth = ""; formCardExpYear = ""; formCardCode = ""
|
|
formIdTitle = ""; formIdFirstName = ""; formIdMiddleName = ""
|
|
formIdLastName = ""; formIdUsername = ""; formIdCompany = ""
|
|
formIdEmail = ""; formIdPhone = ""; formIdSsn = ""
|
|
formIdPassport = ""; formIdLicense = ""; formIdAddress1 = ""
|
|
formIdAddress2 = ""; formIdAddress3 = ""; formIdCity = ""
|
|
formIdState = ""; formIdPostalCode = ""; formIdCountry = ""
|
|
}
|
|
|
|
function loadTypeFields(item) {
|
|
clearTypeFields()
|
|
if (!item) return
|
|
var c = item.card || null
|
|
if (c) {
|
|
formCardholderName = String(c.cardholderName || "")
|
|
formCardBrand = String(c.brand || "")
|
|
formCardNumber = String(c.number || "")
|
|
formCardExpMonth = String(c.expMonth || "")
|
|
formCardExpYear = String(c.expYear || "")
|
|
formCardCode = String(c.code || "")
|
|
}
|
|
var d = item.identity || null
|
|
if (d) {
|
|
formIdTitle = String(d.title || "")
|
|
formIdFirstName = String(d.firstName || "")
|
|
formIdMiddleName = String(d.middleName || "")
|
|
formIdLastName = String(d.lastName || "")
|
|
formIdUsername = String(d.username || "")
|
|
formIdCompany = String(d.company || "")
|
|
formIdEmail = String(d.email || "")
|
|
formIdPhone = String(d.phone || "")
|
|
formIdSsn = String(d.ssn || "")
|
|
formIdPassport = String(d.passportNumber || "")
|
|
formIdLicense = String(d.licenseNumber || "")
|
|
formIdAddress1 = String(d.address1 || "")
|
|
formIdAddress2 = String(d.address2 || "")
|
|
formIdAddress3 = String(d.address3 || "")
|
|
formIdCity = String(d.city || "")
|
|
formIdState = String(d.state || "")
|
|
formIdPostalCode = String(d.postalCode || "")
|
|
formIdCountry = String(d.country || "")
|
|
}
|
|
}
|
|
|
|
function startAddNewItem() {
|
|
closeFilterGroup()
|
|
formIsEditing = false
|
|
formItemId = ""
|
|
formTypeCode = 1
|
|
clearTypeFields()
|
|
formName = ""
|
|
formUsername = ""
|
|
formPassword = ""
|
|
formTotp = ""
|
|
formUri = ""
|
|
formNotes = ""
|
|
formFavorite = false
|
|
formOrgId = selectedOrg !== "all" ? selectedOrg : ""
|
|
formFolderId = (selectedFolder !== "all" && selectedFolder !== "none") ? selectedFolder : ""
|
|
newFolderName = ""
|
|
formPicker = ""
|
|
formCollections = []
|
|
formCollectionIds = []
|
|
if (formOrgId && formOrgId !== "personal") loadOrgCollections(formOrgId)
|
|
formPasswordRevealed = false
|
|
errorMessage = ""
|
|
currentScreen = "edit"
|
|
}
|
|
|
|
// The item form as one object, so a save that fails can be reopened exactly
|
|
// as it was rather than costing the user everything they typed.
|
|
function captureItemForm() {
|
|
return {
|
|
isEditing: formIsEditing, itemId: formItemId, typeCode: formTypeCode,
|
|
name: formName, username: formUsername, password: formPassword,
|
|
totp: formTotp, uri: formUri, notes: formNotes, favorite: formFavorite,
|
|
orgId: formOrgId, folderId: formFolderId,
|
|
collectionIds: (formCollectionIds || []).slice(),
|
|
typeFields: formTypeFields()
|
|
}
|
|
}
|
|
|
|
function restoreItemForm(f) {
|
|
if (!f) return
|
|
formIsEditing = f.isEditing
|
|
formItemId = f.itemId
|
|
formTypeCode = f.typeCode
|
|
formName = f.name
|
|
formUsername = f.username
|
|
formPassword = f.password
|
|
formTotp = f.totp
|
|
formUri = f.uri
|
|
formNotes = f.notes
|
|
formFavorite = f.favorite
|
|
formOrgId = f.orgId
|
|
formFolderId = f.folderId
|
|
formCollectionIds = (f.collectionIds || []).slice()
|
|
loadTypeFields({ card: f.typeCode === 3 ? f.typeFields : null,
|
|
identity: f.typeCode === 4 ? f.typeFields : null })
|
|
formPicker = ""
|
|
formPasswordRevealed = false
|
|
if (formOrgId && formOrgId !== "personal") loadOrgCollections(formOrgId)
|
|
currentScreen = "edit"
|
|
}
|
|
|
|
// Reopens the form a refused save was made from.
|
|
function reopenFailedSave() {
|
|
if (!failedSave) return
|
|
var f = failedSave.form
|
|
failedSave = null
|
|
errorMessage = ""
|
|
restoreItemForm(f)
|
|
}
|
|
|
|
function startEditItem(item) {
|
|
if (!item || item.typeCode === 5) {
|
|
if (item && item.typeCode === 5) errorMessage = "SSH keys are read-only public records"
|
|
return
|
|
}
|
|
// The vault has not answered about this row yet, and on a create it does
|
|
// not have an id to edit. Editing it would race the save it is waiting on.
|
|
if (item.pending) {
|
|
errorMessage = "Still saving this item -- one moment"
|
|
return
|
|
}
|
|
formIsEditing = true
|
|
formItemId = item.id
|
|
formTypeCode = item.typeCode || 1
|
|
formName = item.name || ""
|
|
formUsername = item.username || ""
|
|
formPassword = detailPassword || (item.rawObject && item.rawObject.login ? item.rawObject.login.password : "") || ""
|
|
formTotp = item.totpKey || (item.rawObject && item.rawObject.login ? item.rawObject.login.totp : "") || ""
|
|
formUri = item.uris && item.uris.length > 0 ? item.uris[0] : ""
|
|
formNotes = item.notes || ""
|
|
formFavorite = Boolean(item.favorite)
|
|
formOrgId = item.organizationId || ""
|
|
formFolderId = item.folderId || ""
|
|
newFolderName = ""
|
|
formPicker = ""
|
|
formCollections = []
|
|
// Editing keeps whatever collections the item already has until changed.
|
|
formCollectionIds = (item.rawObject && item.rawObject.collectionIds)
|
|
? item.rawObject.collectionIds.slice() : []
|
|
// The list row carries the parsed card and identity, so an edit opens with
|
|
// the real values in the boxes rather than blanks that would be written
|
|
// straight back over them on save.
|
|
loadTypeFields(item)
|
|
if (formOrgId && formOrgId !== "personal") loadOrgCollections(formOrgId)
|
|
formPasswordRevealed = false
|
|
errorMessage = ""
|
|
currentScreen = "edit"
|
|
}
|
|
|
|
// A save takes as long as `bw` takes -- a second or two of CLI startup, vault
|
|
// decryption and a round trip, none of which this plugin can shorten. What it
|
|
// can do is stop making the user watch. The form closes as soon as the
|
|
// command is launched and the list shows the item as it will be, marked as
|
|
// saving, and the authoritative row replaces it when the vault answers.
|
|
//
|
|
// One at a time. There is a single process per kind, and starting a second
|
|
// command on a running one would lose the first; a save while one is in
|
|
// flight is refused with a reason rather than silently dropped.
|
|
function saveItemForm() {
|
|
if (pendingSave) {
|
|
errorMessage = "Still saving " + pendingSave.name + " -- one moment"
|
|
return
|
|
}
|
|
|
|
// Bitwarden refuses an organization item with no collection; say so here
|
|
// rather than letting the CLI fail after the form is gone.
|
|
var problem = Model.validateItemForm(formName, formOrgId, formCollectionIds)
|
|
if (problem) {
|
|
errorMessage = problem
|
|
return
|
|
}
|
|
|
|
var editing = formIsEditing
|
|
var payload = editing
|
|
? Model.buildEditPayload(detailItem, formName, formUsername, formPassword, formTotp, formUri, formNotes, formFavorite, formOrgId, formFolderId, formCollectionIds, formTypeFields())
|
|
: Model.buildCreatePayload(formTypeCode, formName, formUsername, formPassword, formTotp, formUri, formNotes, formFavorite, formOrgId, formFolderId, formCollectionIds, formTypeFields())
|
|
if (!payload) {
|
|
errorMessage = editing ? "This item is read-only" : "This item type is read-only"
|
|
return
|
|
}
|
|
|
|
errorMessage = ""
|
|
beginVaultRead("itemSave")
|
|
|
|
// An edit keeps the item's id; a create has none until the server assigns
|
|
// one, so the row carries a provisional id the response swaps out.
|
|
var rowId = editing ? formItemId : Model.pendingItemId(Date.now())
|
|
var optimistic = Model.optimisticItem(payload, rowId)
|
|
|
|
pendingSave = {
|
|
id: rowId,
|
|
isCreate: !editing,
|
|
name: String(formName || "Untitled").trim(),
|
|
// What the list held before, so a failed save can put it back rather
|
|
// than leaving the panel showing something the vault never accepted.
|
|
previous: editing ? Model.findItemById(items, rowId) : null,
|
|
// The form as it was, so a failed save can be reopened and retried
|
|
// instead of costing the user everything they typed.
|
|
form: captureItemForm()
|
|
}
|
|
|
|
itemPayloadJson = JSON.stringify(payload)
|
|
if (editing) {
|
|
editItemProc.command = Model.editItemCommand(formItemId, formTypeCode)
|
|
editItemProc.running = true
|
|
} else {
|
|
createItemProc.command = Model.createItemCommand(payload)
|
|
createItemProc.running = true
|
|
}
|
|
|
|
if (optimistic) {
|
|
items = Model.replaceItemById(items, rowId, optimistic)
|
|
itemsLoadedAt = Date.now()
|
|
refreshDerivedFromItems()
|
|
}
|
|
currentScreen = "main"
|
|
}
|
|
|
|
function onSaveItemFinished(exitCode, stdoutText, stderrText) {
|
|
isLoading = false
|
|
// The payload carries the item's password in the clear, the same way a
|
|
// Send payload does, so it goes the same way the Send one does: as soon as
|
|
// the process that needed it has exited.
|
|
itemPayloadJson = ""
|
|
|
|
var save = pendingSave
|
|
pendingSave = null
|
|
if (vaultReadIsStale("itemSave")) return
|
|
|
|
if (exitCode !== 0) {
|
|
// The vault refused it, so the list must stop showing it as though it
|
|
// had not. The optimistic row is taken back out -- replaced by what was
|
|
// there before on an edit, removed entirely on a create -- and what the
|
|
// user typed is kept so they can reopen it instead of retyping it.
|
|
if (save) {
|
|
items = Model.replaceItemById(items, save.id, save.previous)
|
|
itemsLoadedAt = Date.now()
|
|
refreshDerivedFromItems()
|
|
failedSave = { name: save.name, form: save.form }
|
|
errorMessage = "Could not save " + save.name + ". " + (stderrText || "")
|
|
} else {
|
|
errorMessage = stderrText || "Failed to save item"
|
|
}
|
|
return
|
|
}
|
|
|
|
flashNotification(save && save.isCreate ? "Item created successfully!" : "Item updated successfully!")
|
|
|
|
// The save printed the item the vault now holds, so the list can be
|
|
// brought up to date from that instead of re-reading and re-decrypting
|
|
// every other item to learn about this one. On a create the row being
|
|
// replaced is the provisional one, whose id the server has just assigned.
|
|
//
|
|
// Any doubt falls back to the full read. The command prints a marker when
|
|
// the item was stored but could not be sanitised, and spliceSavedItem
|
|
// returns null on an envelope it does not recognise; in both cases the
|
|
// item is in the vault and the list simply has to catch up the slow way.
|
|
// A list that quietly disagrees with the vault is worse than a slow one.
|
|
var spliced = String(stdoutText).indexOf(Model.savedUnsanitizedMarker()) === 0
|
|
? null : Model.spliceSavedItem(items, stdoutText, save ? save.id : "")
|
|
if (!spliced) {
|
|
// A provisional row must never survive a reload it is not part of.
|
|
if (save && save.isCreate) items = Model.replaceItemById(items, save.id, null)
|
|
loadItems()
|
|
return
|
|
}
|
|
items = spliced
|
|
itemsLoadedAt = Date.now()
|
|
refreshDerivedFromItems()
|
|
}
|
|
|
|
// A delete costs the same second or two of `bw` a save does, and used to
|
|
// spend it on a frozen detail screen and then spend more of it re-reading
|
|
// the whole vault to learn about the one row that had gone. The row goes
|
|
// now and the panel comes back; if the vault refuses, the row returns.
|
|
function deleteCurrentItem() {
|
|
if (!detailItem || !detailItem.id || detailItem.typeCode === 5) return
|
|
if (detailItem.pending || Model.isPendingItemId(detailItem.id)) {
|
|
errorMessage = "Still saving this item -- one moment"
|
|
return
|
|
}
|
|
if (pendingDelete) {
|
|
errorMessage = "Still deleting " + pendingDelete.name + " -- one moment"
|
|
return
|
|
}
|
|
|
|
var id = detailItem.id
|
|
pendingDelete = {
|
|
id: id,
|
|
name: String(detailItem.name || "this item"),
|
|
// The row as the list holds it, so a refusal can put it back exactly.
|
|
previous: Model.findItemById(items, id)
|
|
}
|
|
|
|
beginVaultRead("itemDelete")
|
|
deleteItemProc.command = Model.deleteItemCommand(id, detailItem.typeCode)
|
|
deleteItemProc.running = true
|
|
|
|
showDeleteConfirm = false
|
|
items = Model.replaceItemById(items, id, null)
|
|
itemsLoadedAt = Date.now()
|
|
refreshDerivedFromItems()
|
|
currentScreen = "main"
|
|
}
|
|
|
|
function onDeleteItemFinished(exitCode, stdoutText, stderrText) {
|
|
isLoading = false
|
|
showDeleteConfirm = false
|
|
|
|
var removal = pendingDelete
|
|
pendingDelete = null
|
|
if (vaultReadIsStale("itemDelete")) return
|
|
|
|
if (exitCode === 0) {
|
|
// The row is already gone and nothing else about the vault changed, so
|
|
// there is nothing left to read.
|
|
flashNotification("Item deleted")
|
|
return
|
|
}
|
|
|
|
// Still in the vault, so it belongs back in the list. Nothing was typed
|
|
// here, so putting the row back is the whole of the recovery.
|
|
if (removal && removal.previous) {
|
|
items = Model.replaceItemById(items, removal.id, removal.previous)
|
|
itemsLoadedAt = Date.now()
|
|
refreshDerivedFromItems()
|
|
errorMessage = "Could not delete " + removal.name + ". " + (stderrText || "")
|
|
} else {
|
|
errorMessage = stderrText || "Failed to delete item"
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Filtering & Selection
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Everything downstream of `items`. Suggestions are derived from the item
|
|
// list too, so a change to it that only called rebuildFilter() would leave
|
|
// the suggested rows describing the vault as it was. Both the full load and
|
|
// a single spliced save come through here so they cannot drift.
|
|
function refreshDerivedFromItems() {
|
|
if (activeWindowData) {
|
|
handleActiveWindowDetected(activeWindowData)
|
|
} else {
|
|
rebuildFilter()
|
|
}
|
|
}
|
|
|
|
function rebuildFilter() {
|
|
var baseList = Model.filterItems(items, searchQuery, selectedCategory, selectedOrg, selectedFolder)
|
|
if (searchQuery.trim() === "" && selectedCategory === "all" && selectedOrg === "all" && selectedFolder === "all" && !suggestionsDismissed && suggestedItems.length > 0) {
|
|
var suggestedIds = {}
|
|
var topMatches = []
|
|
for (var s = 0; s < suggestedItems.length; s++) {
|
|
var sItem = Object.assign({}, suggestedItems[s], { isSuggested: true })
|
|
topMatches.push(sItem)
|
|
suggestedIds[sItem.id] = true
|
|
}
|
|
var otherItems = []
|
|
for (var o = 0; o < baseList.length; o++) {
|
|
if (!suggestedIds[baseList[o].id]) {
|
|
otherItems.push(baseList[o])
|
|
}
|
|
}
|
|
filteredItems = topMatches.concat(otherItems)
|
|
} else {
|
|
filteredItems = baseList
|
|
}
|
|
|
|
if (selectedIndex >= filteredItems.length) {
|
|
selectedIndex = Math.max(0, filteredItems.length - 1)
|
|
}
|
|
if (selectedIndex < 0 && filteredItems.length > 0) {
|
|
selectedIndex = 0
|
|
}
|
|
}
|
|
|
|
// What the list says when it has nothing to show. The SSH filter gets its own
|
|
// answer: a vault that returned no SSH keys is not the same as a server that
|
|
// never confirmed it can store them, and only the first is worth waiting on.
|
|
function emptyListMessage() {
|
|
if (selectedCategory === "sshKey" && filteredItems.length === 0 && sshCapability
|
|
&& sshCapability.state === "unconfirmed") {
|
|
return sshCapability.message
|
|
}
|
|
if (items.length === 0) return "Vault is empty"
|
|
return "No items match '" + searchQuery + "'"
|
|
}
|
|
|
|
function selectCategory(catId) {
|
|
selectedCategory = catId === "sshKey" && !sshUiAvailable ? "all" : catId
|
|
selectedIndex = 0
|
|
rebuildFilter()
|
|
}
|
|
|
|
function selectOrganization(orgId) {
|
|
selectedOrg = orgId
|
|
selectedIndex = 0
|
|
rebuildFilter()
|
|
}
|
|
|
|
function cycleCategory(delta) {
|
|
var currentIndex = 0
|
|
for (var i = 0; i < visibleCategories.length; i++) {
|
|
if (visibleCategories[i].id === selectedCategory) {
|
|
currentIndex = i
|
|
break
|
|
}
|
|
}
|
|
var nextIndex = (currentIndex + delta + visibleCategories.length) % visibleCategories.length
|
|
selectCategory(visibleCategories[nextIndex].id)
|
|
}
|
|
|
|
// Every main-screen shortcut in one place. Reached two ways: bare letters
|
|
// when the list has focus, and Alt+letter from inside the search box, where
|
|
// a bare letter is search text and must stay that way.
|
|
// Alt+letter. Same table as the bare letters, except Alt+s opens Sends --
|
|
// Send has no bare letter of its own, and plain s is already Settings.
|
|
function runAltShortcut(lower) {
|
|
// Alt+s is Send, which has no bare letter of its own, so Settings keeps
|
|
// its own Alt binding on the comma rather than losing one.
|
|
if (lower === "s") { openSends(); return true }
|
|
if (lower === ",") { openSettings(); return true }
|
|
return runShortcut(lower)
|
|
}
|
|
|
|
function runShortcut(lower) {
|
|
var item = getSelectedItem()
|
|
switch (lower) {
|
|
case "y": case "p": if (item) copyPassword(item); return true
|
|
case "u": case "c": if (item) copyUsername(item); return true
|
|
case "m": if (item && item.hasTotp) copyTotpCode(item); return true
|
|
case "w": if (item && item.uris && item.uris.length > 0) openUrl(item.uris[0]); return true
|
|
case "e": if (item) openDetail(item); return true
|
|
case "n": startAddNewItem(); return true
|
|
case "l": lockVault(); return true
|
|
case "r": syncVault(); return true
|
|
case "f": toggleFilterGroup("folders"); return true
|
|
case "o": toggleFilterGroup("organizations"); return true
|
|
case "t": toggleFilterGroup("types"); return true
|
|
case "g": openGenerator(); return true
|
|
case "s": openSettings(); return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function moveCursor(delta) {
|
|
if (filteredItems.length === 0) return
|
|
// Moving to an item means the user is done filtering; get the list out of
|
|
// the way rather than leaving it covering the results.
|
|
openFilterGroup = ""
|
|
selectedIndex = Math.max(0, Math.min(filteredItems.length - 1, selectedIndex + delta))
|
|
if (itemsListView) {
|
|
itemsListView.positionViewAtIndex(selectedIndex, ListView.Contain)
|
|
}
|
|
}
|
|
|
|
function getSelectedItem() {
|
|
if (filteredItems.length === 0 || selectedIndex < 0 || selectedIndex >= filteredItems.length) {
|
|
return null
|
|
}
|
|
return filteredItems[selectedIndex]
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Clipboard Actions & Sequential Password -> TOTP Follow-Up
|
|
// -------------------------------------------------------------------------
|
|
|
|
function copyToClipboard(text, label) {
|
|
if (!text) return
|
|
resetAutoLockTimer()
|
|
// The value goes through the environment: `printf %s '<secret>'` would put
|
|
// the password or TOTP code straight into /proc/<pid>/cmdline. Remove that
|
|
// variable before starting wl-copy, whose clipboard owner can outlive this
|
|
// short shell after it forks into the background.
|
|
Quickshell.execDetached({
|
|
command: ["bash", "-c", "printf '%s' \"$QSBW_CLIP\" | env -u QSBW_CLIP wl-copy --sensitive"],
|
|
environment: { "QSBW_CLIP": String(text) }
|
|
})
|
|
flashNotification(label + " copied!")
|
|
|
|
if (clearClipboardSec > 0) {
|
|
clipboardClearTimer.restart()
|
|
}
|
|
}
|
|
|
|
function clearClipboard() {
|
|
clipboardClearTimer.stop()
|
|
Quickshell.execDetached(["wl-copy", "--clear"])
|
|
}
|
|
|
|
function requestPasswordCopy(itemId, typeCode) {
|
|
if (!session || !itemId) return
|
|
if (copyPasswordProc.running) {
|
|
errorMessage = "Another password copy is still loading"
|
|
return
|
|
}
|
|
passwordCopyItemId = String(itemId)
|
|
beginVaultRead("passwordCopy")
|
|
copyPasswordProc.command = Model.getPasswordCommand(itemId, typeCode)
|
|
copyPasswordProc.running = true
|
|
}
|
|
|
|
function onPasswordCopyFinished(exitCode, text) {
|
|
var requested = passwordCopyItemId
|
|
passwordCopyItemId = ""
|
|
// The clipboard has its own expiry; the pipe buffer needs one too. Once
|
|
// the value has been handed to wl-copy there is no reason to keep a second
|
|
// plaintext copy in this long-lived Process object.
|
|
clearProcessCollectorSoon(copyPasswordProc)
|
|
if (vaultReadIsStale("passwordCopy")) return
|
|
var password = String(text || "")
|
|
if (exitCode === 0 && requested && password) {
|
|
copyToClipboard(password, "Password")
|
|
return
|
|
}
|
|
errorMessage = "Could not read this password"
|
|
}
|
|
|
|
// Smart sequential Enter handler: Copies Password, then arms and auto-copies TOTP
|
|
// Enter on a list row does the obvious thing for the item under it. For a
|
|
// login that is "copy the password", which is what this used to be and the
|
|
// only thing it did: every other type fell out of the guard below and Enter
|
|
// did nothing at all, on an item whose whole content was one keystroke away.
|
|
//
|
|
// A card, an identity, a note and an SSH key have no default secret to put
|
|
// on the clipboard, and neither does a login that was saved without a
|
|
// password. In all of those cases the useful answer is to open the item,
|
|
// which is what a user pressing Enter on a row they cannot copy from was
|
|
// reaching for anyway.
|
|
function handleSmartEnter(item) {
|
|
openFilterGroup = ""
|
|
if (!item) return
|
|
|
|
var copyable = Model.isLoginItem(item)
|
|
&& (item.hasPassword !== undefined ? item.hasPassword : Boolean(item.password))
|
|
if (!copyable) {
|
|
openDetail(item)
|
|
return
|
|
}
|
|
|
|
// If already in active TOTP follow-up mode for this item, copy TOTP now!
|
|
if (totpFollowupActive && totpFollowupItem && totpFollowupItem.id === item.id) {
|
|
copyTotpCode(item)
|
|
totpFollowupActive = false
|
|
if (closeOnCopy) close()
|
|
return
|
|
}
|
|
|
|
// Step 1: Copy password
|
|
copyPassword(item)
|
|
|
|
// Step 2: If item has TOTP, arm follow-up and schedule auto-copy!
|
|
if (item.hasTotp) {
|
|
totpFollowupItem = item
|
|
totpFollowupActive = true
|
|
fetchTotp(item.id)
|
|
totpFollowupTimer.restart()
|
|
|
|
if (autoCopyTotpSec > 0) {
|
|
autoTotpTimer.interval = autoCopyTotpSec * 1000
|
|
autoTotpTimer.restart()
|
|
}
|
|
}
|
|
|
|
if (closeOnCopy) {
|
|
close()
|
|
}
|
|
}
|
|
|
|
function copyPassword(item) {
|
|
closeFilterGroup()
|
|
if (!item || !Model.isLoginItem(item)) return
|
|
learnFromPick(item)
|
|
var pass = (detailItem && detailItem.id === item.id && detailPassword) ? detailPassword : (item.password || "")
|
|
if (pass) {
|
|
copyToClipboard(pass, "Password")
|
|
return
|
|
}
|
|
if (session) {
|
|
requestPasswordCopy(item.id, item.typeCode)
|
|
} else {
|
|
errorMessage = "Vault is locked or session expired. Please unlock your vault."
|
|
}
|
|
}
|
|
|
|
function copyUsername(item) {
|
|
closeFilterGroup()
|
|
if (!item || !item.username) return
|
|
copyToClipboard(item.username, "Username")
|
|
}
|
|
|
|
function copyTotpCode(item) {
|
|
closeFilterGroup()
|
|
if (!item || !Model.isLoginItem(item)) return
|
|
if (liveTotp && item.id === (detailItem ? detailItem.id : "")) {
|
|
copyToClipboard(liveTotp, "TOTP code")
|
|
return
|
|
}
|
|
if (totpFollowupActive && totpFollowupItem && totpFollowupItem.id === item.id && totpFollowupCode) {
|
|
copyToClipboard(totpFollowupCode, "TOTP code")
|
|
return
|
|
}
|
|
fetchTotp(item.id, true)
|
|
}
|
|
|
|
function openUrl(url) {
|
|
if (!url) return
|
|
// Only http and https are handed to xdg-open; see normalizeOpenableUrl().
|
|
var resolved = Model.normalizeOpenableUrl(url)
|
|
if (!resolved.ok) {
|
|
errorMessage = resolved.reason === "ambiguous"
|
|
? "Refusing to open an ambiguous link containing a backslash"
|
|
: resolved.scheme
|
|
? ("Refusing to open a " + resolved.scheme + ": link -- only http and https are opened")
|
|
: "That item has no link to open"
|
|
return
|
|
}
|
|
Quickshell.execDetached(["xdg-open", resolved.url])
|
|
flashNotification("Opening " + resolved.url)
|
|
}
|
|
|
|
function flashNotification(msg) {
|
|
flashMessage = msg
|
|
flashTimer.restart()
|
|
}
|
|
|
|
function resetAutoLockTimer() {
|
|
// Recorded even when auto-lock is off, so turning it back on mid-session
|
|
// starts counting from the last thing the user did rather than from zero.
|
|
autoLockArmedAt = Date.now()
|
|
if (autoLockMinutes > 0) {
|
|
autoLockTimer.interval = autoLockMinutes * 60 * 1000
|
|
autoLockTimer.restart()
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Timers
|
|
// -------------------------------------------------------------------------
|
|
|
|
Timer {
|
|
id: searchDebounceTimer
|
|
interval: 50
|
|
repeat: false
|
|
onTriggered: root.rebuildFilter()
|
|
}
|
|
|
|
Timer {
|
|
id: deferredMetadataTimer
|
|
// One frame at 60 Hz is ~17 ms. Fifty milliseconds leaves room for the
|
|
// parsed item model to polish and render before two more bw processes
|
|
// begin their startup work.
|
|
interval: 50
|
|
repeat: false
|
|
onTriggered: {
|
|
if (root.status !== "unlocked" || !root.metadataLoadPending) return
|
|
var force = root.metadataForceRefresh
|
|
root.metadataLoadPending = false
|
|
root.metadataForceRefresh = false
|
|
root.loadOrganizations(force)
|
|
root.loadFolders(force)
|
|
if (root.statusRefreshAfterItems) {
|
|
root.statusRefreshAfterItems = false
|
|
root.runStatusCheck(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: flashTimer
|
|
interval: 2500
|
|
onTriggered: root.flashMessage = ""
|
|
}
|
|
|
|
Timer {
|
|
id: totpFollowupTimer
|
|
interval: 8000
|
|
onTriggered: root.totpFollowupActive = false
|
|
}
|
|
|
|
Timer {
|
|
id: autoTotpTimer
|
|
repeat: false
|
|
onTriggered: {
|
|
if (root.totpFollowupItem && root.totpFollowupItem.hasTotp) {
|
|
root.copyTotpCode(root.totpFollowupItem)
|
|
// The code itself stays out of the notification. It is already on the
|
|
// clipboard, and a notification is not a private channel: the daemon
|
|
// keeps history and can render the body over a lock screen. The panel
|
|
// shows the digits on screen instead, where you asked for them.
|
|
Quickshell.execDetached(["omarchy-notification-send", "-g", "", "--app-name", "Bitwarden", "-t", "4000", "TOTP Code Copied", "2FA verification code ready to paste"])
|
|
root.totpFollowupActive = false
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: clipboardClearTimer
|
|
interval: root.clearClipboardSec * 1000
|
|
onTriggered: root.clearClipboard()
|
|
}
|
|
|
|
Timer {
|
|
id: autoLockTimer
|
|
interval: root.autoLockMinutes * 60 * 1000
|
|
running: root.status === "unlocked" && root.autoLockMinutes > 0
|
|
onTriggered: {
|
|
if (root.status === "unlocked") {
|
|
root.lockVault()
|
|
}
|
|
}
|
|
}
|
|
|
|
// The timer above measures the time the shell was awake for, which on a
|
|
// laptop is not the time the vault was exposed for: Qt schedules on
|
|
// CLOCK_MONOTONIC and Linux stops that clock across a suspend, so a lock
|
|
// armed before the lid closed still had its full countdown left when the lid
|
|
// opened. This is the wall-clock half of the same deadline; see the
|
|
// Auto-lock section of BitwardenModel.js.
|
|
Timer {
|
|
id: autoLockWatchdog
|
|
interval: Model.autoLockPollMs(root.autoLockMinutes)
|
|
repeat: true
|
|
running: root.status === "unlocked" && root.autoLockMinutes > 0
|
|
onTriggered: {
|
|
if (root.status !== "unlocked") return
|
|
// An unlock that somehow reached us without arming the window starts it
|
|
// here rather than reading a deadline of "1970 plus fifteen minutes".
|
|
if (root.autoLockArmedAt <= 0) {
|
|
root.autoLockArmedAt = Date.now()
|
|
return
|
|
}
|
|
if (Model.autoLockExpired(root.autoLockArmedAt, root.autoLockMinutes, Date.now())) {
|
|
root.lockVault()
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Locking on screen lock and on suspend
|
|
// -------------------------------------------------------------------------
|
|
//
|
|
// Both are the same conclusion the auto-lock reaches on a timer, arrived at
|
|
// from evidence instead: the vault is no longer being attended. Neither
|
|
// replaces the countdown -- a vault left open at an unlocked desk is still
|
|
// the case only elapsed time can catch.
|
|
|
|
// The last reading from the screen-lock poll, with the moment it was taken.
|
|
// The agent needs this even when lockOnScreenLock is off, because it must
|
|
// never raise an approval prompt over a locked screen.
|
|
property bool screenIsLocked: false
|
|
property double screenLockCheckedAt: 0
|
|
|
|
function onScreenLockState(raw) {
|
|
root.screenIsLocked = Model.screenIsLocked(raw)
|
|
root.screenLockCheckedAt = Date.now()
|
|
if (!lockOnScreenLock || status !== "unlocked") return
|
|
if (root.screenIsLocked) lockVault()
|
|
}
|
|
|
|
function onSleepSignal(line) {
|
|
var token = String(line || "").trim()
|
|
if (token === Model.wakeSignalToken()) {
|
|
// Coming back is not by itself a reason to do anything -- the watchdog
|
|
// below already notices a countdown that expired across the suspend --
|
|
// but the panel should not be showing a vault state from before the lid
|
|
// closed either.
|
|
if (opened) refreshStatus()
|
|
return
|
|
}
|
|
if (token !== Model.sleepSignalToken()) return
|
|
if (!lockOnSuspend || status !== "unlocked") return
|
|
// Synchronous as far as the session key in this process is concerned; the
|
|
// keyring clear it spawns is what the inhibitor's held second is for.
|
|
lockVault()
|
|
}
|
|
|
|
Timer {
|
|
id: screenLockPoll
|
|
interval: Model.screenLockPollMs()
|
|
repeat: true
|
|
// Nothing to ask while the setting is off or the vault is already locked,
|
|
// which between them is every state but the one this is for.
|
|
// Also while the agent is serving: an approval prompt must never appear
|
|
// over a locked screen, and that needs a current reading regardless of
|
|
// whether the vault is set to lock with the screen.
|
|
running: (root.lockOnScreenLock && root.status === "unlocked") || root.sshAgentGateOpen
|
|
onTriggered: {
|
|
if (!screenLockStateProc.running) screenLockStateProc.running = true
|
|
}
|
|
}
|
|
|
|
// Comes back for the processes that were mid-read when the vault locked.
|
|
// Stops as soon as the queue empties, which is the same tick for everything
|
|
// that was already idle.
|
|
Timer {
|
|
id: scrubRetry
|
|
interval: Model.scrubRetryMs()
|
|
repeat: true
|
|
onTriggered: {
|
|
root.scrubStep()
|
|
if (!root.scrubPending.length) stop()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: screenLockStateProc
|
|
command: Model.screenLockStateCommand()
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: root.onScreenLockState(text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: sshAgentHelperProc
|
|
stdout: StdioCollector {
|
|
id: sshAgentHelperStdout
|
|
waitForEnd: true
|
|
onStreamFinished: root.onSshAgentHelperInspected(text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: sshExportProc
|
|
command: Model.sshExportCommand()
|
|
stdinEnabled: true
|
|
stdout: StdioCollector { id: sshExportStdout; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
sshExportProc.stdinEnabled = true
|
|
root.onSshExportFinished(exitCode, sshExportStdout.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: sshExportClearProc
|
|
command: Model.sshExportClearCommand()
|
|
stdout: StdioCollector { id: sshExportClearStdout; waitForEnd: true }
|
|
onExited: function(exitCode) { root.onSshExportFinished(exitCode, sshExportClearStdout.text) }
|
|
}
|
|
|
|
Process {
|
|
id: loadIdProc
|
|
command: Model.loadIdCommand()
|
|
stdout: StdioCollector {
|
|
id: loadIdStdout
|
|
waitForEnd: true
|
|
onStreamFinished: root.onSshAgentLoadIdRead(text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: uwsmInspectProc
|
|
command: Model.uwsmInspectCommand()
|
|
stdout: StdioCollector {
|
|
id: uwsmInspectStdout
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
root.uwsmFragment = Model.parseUwsmInspection(text)
|
|
root.applyUwsmRestore()
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: pluginDataRemoveProc
|
|
command: Model.pluginDataRemoveCommand()
|
|
stdout: StdioCollector { id: pluginDataRemoveStdout; waitForEnd: true }
|
|
onExited: function(exitCode) { root.onPluginDataRemoved(exitCode, pluginDataRemoveStdout.text) }
|
|
}
|
|
|
|
Process {
|
|
id: uwsmWriteProc
|
|
command: Model.uwsmWriteCommand()
|
|
stdout: StdioCollector { id: uwsmWriteStdout; waitForEnd: true }
|
|
onExited: function(exitCode) { root.onUwsmActionFinished(exitCode, uwsmWriteStdout.text) }
|
|
}
|
|
|
|
Process {
|
|
id: uwsmRemoveProc
|
|
command: Model.uwsmRemoveCommand()
|
|
stdout: StdioCollector { id: uwsmRemoveStdout; waitForEnd: true }
|
|
onExited: function(exitCode) { root.onUwsmActionFinished(exitCode, uwsmRemoveStdout.text) }
|
|
}
|
|
|
|
// The SSH companion. Tracked and non-detached so it dies with the shell and
|
|
// with a configuration reload, rather than outliving the panel that holds
|
|
// its control channel: the helper treats stdin EOF as "drop the keys and
|
|
// exit", and that only works if this Process really owns the child.
|
|
//
|
|
// clearEnvironment strips everything the shell was started with -- PATH,
|
|
// HOME, and above all BW_SESSION -- and `environment` puts back the single
|
|
// variable the helper reads. It runs no `bw` and spawns nothing, so it needs
|
|
// nothing else.
|
|
Process {
|
|
id: sshAgentProc
|
|
// Whichever candidate the inspection accepted -- the shipped artifact by
|
|
// preference, a local development build otherwise.
|
|
command: Model.sshAgentHelperCommand(root.sshAgentPluginDir, root.sshAgentHelper.source)
|
|
clearEnvironment: true
|
|
environment: Model.sshAgentHelperEnv(root.sshAgentRuntimeDir) || ({})
|
|
stdinEnabled: true
|
|
// Attached from startup, so the `ready` that answers hello cannot be
|
|
// missed by a parser wired up after the fact.
|
|
stdout: SplitParser {
|
|
onRead: function(line) { root.applySshAgentEvent({ kind: "line", line: line, nowMs: Date.now() }) }
|
|
}
|
|
onStarted: root.applySshAgentEvent({ kind: "started", nowMs: Date.now() })
|
|
onExited: function(exitCode) {
|
|
sshAgentTerminateTimer.stop()
|
|
root.applySshAgentEvent({ kind: "exited", exitCode: exitCode, nowMs: Date.now() })
|
|
}
|
|
}
|
|
|
|
// The bound on the handshake. QML never waits for `ready`; it arms this and
|
|
// carries on, and a helper that has not answered by the time it fires is
|
|
// stopped and retried like any other failure.
|
|
Timer {
|
|
id: sshAgentHandshakeTimer
|
|
interval: Model.sshAgentHandshakeTimeoutMs()
|
|
repeat: false
|
|
running: root.sshAgentPhase === "starting" || root.sshAgentPhase === "handshaking"
|
|
onTriggered: root.applySshAgentEvent({ kind: "handshakeTimeout", nowMs: Date.now() })
|
|
}
|
|
|
|
// Only while there is something to count down. A grant is at most fifteen
|
|
// minutes, so this is never a timer that runs for the life of the shell.
|
|
Timer {
|
|
id: sshGrantCountdown
|
|
interval: 1000
|
|
repeat: true
|
|
running: root.sshGrantsAnnounced.length > 0
|
|
onTriggered: root.sshGrantTick = Date.now()
|
|
}
|
|
|
|
Timer {
|
|
id: sshCooldownCountdown
|
|
interval: 1000
|
|
repeat: true
|
|
running: root.sshCooldownStatus.active
|
|
onTriggered: root.noteSshCooldown()
|
|
}
|
|
|
|
Timer {
|
|
id: sshPromptCountdown
|
|
interval: 1000
|
|
repeat: true
|
|
running: root.sshPrompt !== null || root.sshUnlockRequest !== null
|
|
onTriggered: {
|
|
var elapsed = Date.now() - root.sshPromptStartedMs
|
|
var remaining = Math.ceil((Model.sshAgentRequestDeadlineMs() - elapsed) / 1000)
|
|
root.sshPromptRemainingSec = Math.max(0, remaining)
|
|
if (remaining <= 0) root.expireSshRequest()
|
|
}
|
|
}
|
|
|
|
// The grace period between asking the helper to shut down and making it.
|
|
// Two seconds is far longer than dropping keys and unlinking two paths
|
|
// takes, and short enough that a wedged helper does not delay a restart.
|
|
Timer {
|
|
id: sshAgentTerminateTimer
|
|
interval: 2000
|
|
repeat: false
|
|
onTriggered: if (sshAgentProc.running) sshAgentProc.running = false
|
|
}
|
|
|
|
// Capped restart backoff. The interval is set by the reducer before each
|
|
// restart; the timer only reports that it elapsed.
|
|
Timer {
|
|
id: sshAgentRestartTimer
|
|
repeat: false
|
|
onTriggered: root.applySshAgentEvent({ kind: "restartTimer", nowMs: Date.now() })
|
|
}
|
|
|
|
// Long-lived: it holds the sleep inhibitor that makes the lock land before
|
|
// the machine is frozen, so it runs whenever the setting is on rather than
|
|
// only while the vault happens to be unlocked -- a suspend announcement is
|
|
// no use to a panel that started listening after it.
|
|
Process {
|
|
id: sleepMonitorProc
|
|
running: root.lockOnSuspend
|
|
command: Model.sleepMonitorCommand()
|
|
stdout: SplitParser {
|
|
onRead: function(line) { root.onSleepSignal(line) }
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: totpCountdownTimer
|
|
interval: 1000
|
|
running: root.opened && (root.currentScreen === "detail" || root.totpFollowupActive)
|
|
repeat: true
|
|
onTriggered: {
|
|
var sec = 30 - (Math.floor(Date.now() / 1000) % 30)
|
|
root.totpSecRemaining = sec
|
|
if (sec === 30) {
|
|
if (root.currentScreen === "detail" && root.detailItem && root.detailItem.hasTotp) {
|
|
root.fetchTotp(root.detailItem.id)
|
|
} else if (root.totpFollowupActive && root.totpFollowupItem) {
|
|
root.fetchTotp(root.totpFollowupItem.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Processes (Quickshell.Io)
|
|
// -------------------------------------------------------------------------
|
|
|
|
Process {
|
|
id: statusProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector {
|
|
id: statusStdout
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(statusProc)) return
|
|
root.onStatusFinished(exitCode === 0 ? statusStdout.text : "")
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: sessionHandoffProc
|
|
// Set by refreshStatus(), which decides whether this is a read or a
|
|
// discard. Defaults to the discard form so a run that somehow starts
|
|
// without going through there cannot adopt a key -- and a scrub, which
|
|
// replaces this command with one that reads nothing at all, only makes
|
|
// that stricter.
|
|
command: Model.sessionHandoffReadCommand(false)
|
|
stdout: StdioCollector {
|
|
id: sessionHandoffStdout
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(sessionHandoffProc)) return
|
|
root.onSessionHandoff(exitCode === 0 ? sessionHandoffStdout.text : "")
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: keyringLookupProc
|
|
command: Model.keyringLookupCommand()
|
|
stdout: StdioCollector {
|
|
id: keyringLookupStdout
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(keyringLookupProc)) return
|
|
root.onKeyringLookupFinished(exitCode === 0 ? keyringLookupStdout.text : "")
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: keyringStoreProc
|
|
command: Model.keyringStoreCommand()
|
|
environment: root.secretEnv(root.session)
|
|
onExited: function(exitCode) {
|
|
root.onSessionStored(exitCode)
|
|
if (root.logoutPending && root.allCredentialsClearPending)
|
|
Qt.callLater(root.requestAllCredentialClear)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: keyringClearProc
|
|
command: Model.keyringClearCommand()
|
|
onExited: function(exitCode) {
|
|
if (root.sessionClearPending) {
|
|
Qt.callLater(root.requestSessionCredentialClear)
|
|
return
|
|
}
|
|
if (root.sessionStorePending) Qt.callLater(root.storeCurrentSession)
|
|
}
|
|
}
|
|
|
|
// ---- Fingerprint unlock ----
|
|
|
|
Process {
|
|
id: listFoldersProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector {
|
|
id: listFoldersStdout
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(listFoldersProc)) return
|
|
if (exitCode === 0) root.onListFoldersFinished(listFoldersStdout.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: orgCollectionsProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector {
|
|
id: orgCollectionsStdout
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(orgCollectionsProc)) return
|
|
if (exitCode === 0) root.onOrgCollectionsLoaded(orgCollectionsStdout.text)
|
|
else root.formCollectionsLoading = false
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: createFolderProc
|
|
environment: root.folderEnv()
|
|
stdout: StdioCollector { id: createFolderStdout; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(createFolderProc)) return
|
|
root.onFolderCreated(exitCode, createFolderStdout.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: attachmentProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector { id: attachmentStdout; waitForEnd: true }
|
|
stderr: StdioCollector { id: attachmentStderr; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(attachmentProc)) return
|
|
root.onAttachmentDownloaded(exitCode, attachmentStdout.text, attachmentStderr.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: listSendsProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector {
|
|
id: listSendsStdout
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(listSendsProc)) return
|
|
if (exitCode === 0) root.onSendsLoaded(listSendsStdout.text)
|
|
else root.sendsLoading = false
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: createSendProc
|
|
environment: root.sendEnv(root.sendPayloadJson)
|
|
stdout: StdioCollector { id: createSendStdout; waitForEnd: true }
|
|
stderr: StdioCollector { id: createSendStderr; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(createSendProc)) return
|
|
root.onSendCreated(exitCode, createSendStdout.text, createSendStderr.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: deleteSendProc
|
|
environment: root.bwEnv()
|
|
onExited: function(exitCode) { root.onSendDeleted(exitCode) }
|
|
}
|
|
|
|
Process {
|
|
id: generateProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector { id: generateStdout; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(generateProc)) return
|
|
if (root.generateCliStopping) {
|
|
root.generateCliStopping = false
|
|
var restart = root.currentScreen === "generator" && root.genRegeneratePending
|
|
root.genBusy = false
|
|
root.genRegeneratePending = false
|
|
if (restart) Qt.callLater(root.regenerate)
|
|
return
|
|
}
|
|
root.onGenerated(generateStdout.text, exitCode)
|
|
}
|
|
}
|
|
|
|
// The generator server. A managed Process rather than execDetached, so it
|
|
// exits with the shell instead of outliving it.
|
|
Process {
|
|
id: generateServeProc
|
|
command: Model.generateServeCommand()
|
|
environment: root.generatorServeEnv()
|
|
onExited: function(exitCode) {
|
|
generateServePoll.stop()
|
|
var act = Model.generatorServeExitAction({
|
|
stopping: root.generateServeStopping,
|
|
wasReady: root.generateServeReady,
|
|
busy: root.genBusy,
|
|
onGeneratorScreen: root.currentScreen === "generator"
|
|
})
|
|
root.generateServeStarting = false
|
|
root.generateServeReady = false
|
|
root.generateServeStopping = false
|
|
if (act.giveUp) root.generateServeFailed = true
|
|
if (act.dropValue) root.genValue = ""
|
|
if (act.useCli) root.regenerateViaCli()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: generateServeRequestProc
|
|
stdout: StdioCollector { id: generateServeRequestStdout; waitForEnd: true }
|
|
stderr: StdioCollector { id: generateServeRequestStderr; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(generateServeRequestProc)) {
|
|
root.resumePendingGeneratorRequest()
|
|
return
|
|
}
|
|
var stopped = root.generateServeRequestStopping
|
|
root.generateServeRequestStopping = false
|
|
var cb = root.generateServeRequestCallback
|
|
root.generateServeRequestCallback = null
|
|
if (root.resumePendingGeneratorRequest()) return
|
|
if (stopped) return
|
|
if (cb) cb(exitCode, generateServeRequestStdout.text, generateServeRequestStderr.text)
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: generateServePoll
|
|
property int attempts: 0
|
|
interval: 250
|
|
repeat: true
|
|
onTriggered: {
|
|
attempts++
|
|
if (attempts > 40) { // 10s, well past bw's usual couple of seconds
|
|
stop()
|
|
root.generateServeStarting = false
|
|
root.generateServeFailed = true
|
|
if (root.genBusy) root.regenerateViaCli()
|
|
return
|
|
}
|
|
root.pollGeneratorServe()
|
|
}
|
|
}
|
|
|
|
// ---- PIN unlock ----
|
|
//
|
|
// PIN and master password are handed over in the environment; encrypt-and-store
|
|
// and lookup-and-decrypt each run inside one process, so the plaintext never
|
|
// travels back through QML on its way to or from the keyring.
|
|
|
|
Process {
|
|
id: pinStoreProc
|
|
command: Model.pinStoreCommand()
|
|
environment: root.pinEnv(root.pinSetupPin, root.pinSetupMaster)
|
|
onExited: function(exitCode) {
|
|
root.onPinStored(exitCode)
|
|
if (root.logoutPending && root.allCredentialsClearPending)
|
|
Qt.callLater(root.requestAllCredentialClear)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: pinUnlockProc
|
|
command: Model.pinUnlockCommand()
|
|
environment: root.pinEnv(root.pinEntry, "")
|
|
stdout: StdioCollector { id: pinUnlockStdout; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(pinUnlockProc)) return
|
|
root.onPinUnlockResult(exitCode, pinUnlockStdout.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: keyringHasPinProc
|
|
command: Model.keyringHasPinCommand()
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: root.onPinConfiguredChecked(text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: keyringClearPinProc
|
|
command: Model.keyringClearPinCommand()
|
|
onExited: function(exitCode) {
|
|
if (root.pinClearPending) Qt.callLater(root.requestPinCredentialClear)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: depsCheckProc
|
|
command: Model.dependencyCheckCommand()
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: root.onDependenciesChecked(text)
|
|
}
|
|
}
|
|
|
|
// An install runs in a terminal this panel does not own, so there is nothing
|
|
// to wait on and no exit code to hear about. Re-probing while the setup
|
|
// screen is up is what closes that loop: the moment `bw` lands on PATH the
|
|
// screen turns green and onDependenciesChecked moves on to the vault, with
|
|
// no second visit to a Re-check button. Only while the panel is open and
|
|
// only on that screen, so it costs nothing the rest of the time.
|
|
Timer {
|
|
id: setupPollTimer
|
|
interval: 2500
|
|
running: root.opened && root.currentScreen === "setup" && root.setupActionsPending
|
|
repeat: true
|
|
onTriggered: root.checkDependencies()
|
|
}
|
|
|
|
// The whole first paint now waits behind the dependency probe. If that probe
|
|
// never reports -- a shell that will not start, a mangled PATH -- the vault
|
|
// should still be reachable instead of the panel sitting on "checking"
|
|
// forever, so the status probe goes ahead on its own after a few seconds.
|
|
Timer {
|
|
id: statusProbeFallbackTimer
|
|
interval: 4000
|
|
running: !root.statusProbeStarted
|
|
repeat: false
|
|
onTriggered: {
|
|
if (root.statusProbeStarted || root.setupGated) return
|
|
// Four seconds of silence from a probe that takes milliseconds means it
|
|
// is not coming. Treating that as "checked, nothing missing" is what
|
|
// gets past refreshStatus()'s own !depsChecked guard -- an unanswered
|
|
// probe must not be the thing that keeps the vault out of reach.
|
|
root.depsChecked = true
|
|
root.refreshStatus()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: settingWriteProc
|
|
stderr: StdioCollector {
|
|
id: settingWriteStderr
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (exitCode !== 0) {
|
|
root.settingsFlash = ""
|
|
root.errorMessage = (settingWriteStderr.text || "").trim() || "Could not save setting to shell.json"
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: settingsFlashTimer
|
|
interval: 1600
|
|
onTriggered: root.settingsFlash = ""
|
|
}
|
|
|
|
Process {
|
|
id: keyringHasMasterProc
|
|
command: Model.keyringHasMasterPasswordCommand()
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: root.onFingerprintStoredChecked(text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: keyringStoreMasterProc
|
|
command: Model.keyringStoreMasterPasswordCommand()
|
|
environment: root.secretEnv(root.masterToStore)
|
|
onExited: function(exitCode) {
|
|
root.onMasterPasswordStored(exitCode)
|
|
if (root.logoutPending && root.allCredentialsClearPending)
|
|
Qt.callLater(root.requestAllCredentialClear)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: keyringLookupMasterProc
|
|
command: Model.keyringLookupMasterPasswordCommand()
|
|
stdout: StdioCollector {
|
|
id: keyringLookupMasterStdout
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(keyringLookupMasterProc)) return
|
|
if (exitCode === 0) {
|
|
root.onFingerprintPasswordRetrieved(keyringLookupMasterStdout.text)
|
|
} else {
|
|
root.fingerprintAuthorized = false
|
|
root.fingerprintStored = false
|
|
root.fingerprintMessage = "Stored master password unavailable. Use your password."
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: keyringClearMasterProc
|
|
command: Model.keyringClearMasterPasswordCommand()
|
|
onExited: function(exitCode) {
|
|
if (root.masterClearPending) Qt.callLater(root.requestMasterCredentialClear)
|
|
}
|
|
}
|
|
|
|
// Logout's clean sweep; see forgetStoredCredentials().
|
|
Process {
|
|
id: keyringClearAllProc
|
|
command: Model.keyringClearAllCommand()
|
|
onExited: function(exitCode) {
|
|
if (root.allCredentialsClearPending) {
|
|
Qt.callLater(root.requestAllCredentialClear)
|
|
return
|
|
}
|
|
root.onLogoutCredentialsFinished(exitCode)
|
|
}
|
|
}
|
|
|
|
// ---- Learned associations ----
|
|
|
|
Process {
|
|
id: associationsReadProc
|
|
command: Model.associationsReadCommand()
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
if (root.finishScrubRun(associationsReadProc)) return
|
|
root.onAssociationsLoaded(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: associationsWriteProc
|
|
command: Model.associationsWriteCommand()
|
|
environment: root.associationsEnv()
|
|
onExited: function(exitCode) {
|
|
if (root.associationsClearPending) {
|
|
root.associationsClearPending = false
|
|
root.associationsWritePending = false
|
|
root.pendingAssociationsJson = ""
|
|
associationsClearProc.running = true
|
|
return
|
|
}
|
|
if (exitCode !== 0) {
|
|
console.warn("qs-bitwarden-cli: could not save learned suggestions (exit " + exitCode + ")")
|
|
}
|
|
if (root.associationsWritePending) {
|
|
root.associationsWritePending = false
|
|
associationsWriteProc.running = true
|
|
return
|
|
}
|
|
root.pendingAssociationsJson = ""
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: associationsClearProc
|
|
command: Model.associationsClearCommand()
|
|
}
|
|
|
|
PamContext {
|
|
id: fingerprintPam
|
|
config: "omarchy-lock-fingerprint"
|
|
user: root.userName
|
|
|
|
onCompleted: function(result) {
|
|
root.onFingerprintResult(result)
|
|
}
|
|
|
|
onError: function(error) {
|
|
root.fingerprintScanning = false
|
|
root.fingerprintAuthorized = false
|
|
root.fingerprintMessage = "Fingerprint verification unavailable"
|
|
}
|
|
}
|
|
|
|
// Polls rather than counting down, for the same reason the auto-lock does:
|
|
// a monotonic timer stops while the machine is suspended, and a login left
|
|
// pending across a lid close must expire on the time that actually passed.
|
|
Timer {
|
|
id: pendingLoginTimer
|
|
interval: 1000
|
|
repeat: true
|
|
running: root.secondFactorStartedAt > 0
|
|
onTriggered: {
|
|
if (!Model.secondFactorWindowOpen(root.secondFactorStartedAt, Date.now())) {
|
|
root.abandonAuthSecrets()
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: loginProc
|
|
environment: root.loginProcessEnv()
|
|
stdout: StdioCollector {
|
|
id: loginStdout
|
|
waitForEnd: true
|
|
}
|
|
stderr: StdioCollector {
|
|
id: loginStderr
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
// A scrub is started from this same handler and claims the process for a
|
|
// moment, so a submit arriving in that moment waits on the scrub's exit
|
|
// rather than the login's. Returning here without dispatching used to
|
|
// drop that submit on the floor -- the click did nothing at all, and the
|
|
// one after it worked because by then nothing held the process. That was
|
|
// "I had to press Verify twice".
|
|
if (root.finishScrubRun(loginProc)) {
|
|
if (!root.loginSubmitted) root.resumeDeferredLogin(false)
|
|
return
|
|
}
|
|
if (!root.loginSubmitted) {
|
|
root.resumeDeferredLogin(true)
|
|
return
|
|
}
|
|
root.loginSubmitted = false
|
|
root.onLoginOutput(loginStdout.text, loginStderr.text, exitCode)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: authPasswordWriterProc
|
|
environment: root.authEnv(root.authPasswordWriteValue, "", "", "")
|
|
onExited: function(exitCode) { root.onAuthPasswordWriterExited(exitCode) }
|
|
}
|
|
|
|
Process {
|
|
id: unlockProc
|
|
command: Model.unlockPrewarmCommand()
|
|
environment: root.authEnv("", "", "", "")
|
|
stdout: StdioCollector {
|
|
id: unlockStdout
|
|
waitForEnd: true
|
|
}
|
|
stderr: StdioCollector {
|
|
id: unlockStderr
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(unlockProc)) {
|
|
if (root.sshAuthSurfaceActive && root.status === "locked") Qt.callLater(root.prepareUnlock)
|
|
return
|
|
}
|
|
if (!root.unlockSubmitted) {
|
|
root.clearProcessCollectorSoon(unlockProc)
|
|
return
|
|
}
|
|
root.unlockSubmitted = false
|
|
root.onUnlockOutput(unlockStdout.text, unlockStderr.text, exitCode)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: logoutProc
|
|
environment: root.bwEnv()
|
|
onExited: function(exitCode) { root.onLogoutCliFinished(exitCode) }
|
|
}
|
|
|
|
Process {
|
|
id: listProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector {
|
|
id: listStdout
|
|
waitForEnd: true
|
|
}
|
|
stderr: StdioCollector {
|
|
id: listStderr
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
root.onListProcessExited(exitCode, listStdout.text, listStderr.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: listOrgsProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector {
|
|
id: listOrgsStdout
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(listOrgsProc)) return
|
|
if (exitCode === 0) root.onListOrgsFinished(listOrgsStdout.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: getItemProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector {
|
|
id: getItemStdout
|
|
waitForEnd: true
|
|
}
|
|
stderr: StdioCollector {
|
|
id: getItemStderr
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(getItemProc)) return
|
|
if (exitCode === 0) {
|
|
root.onDetailFinished(getItemStdout.text)
|
|
} else {
|
|
root.isLoading = false
|
|
if (!root.vaultReadIsStale("detail")) {
|
|
root.errorMessage = String(getItemStderr.text || "").trim() || "Could not load item details"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: getTotpProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector {
|
|
id: getTotpStdout
|
|
waitForEnd: true
|
|
}
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(getTotpProc)) {
|
|
root.continueTotpQueue(true)
|
|
return
|
|
}
|
|
root.onTotpProcessExited(exitCode, getTotpStdout.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: copyPasswordProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector { id: copyPasswordStdout; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(copyPasswordProc)) return
|
|
root.onPasswordCopyFinished(exitCode, copyPasswordStdout.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: activeWindowProc
|
|
command: Model.activeWindowCommand()
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
if (text && text.trim()) {
|
|
try {
|
|
var data = JSON.parse(text)
|
|
root.handleActiveWindowDetected(data)
|
|
} catch (e) {
|
|
root.suggestedItems = []
|
|
root.detectedContext = null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: createItemProc
|
|
environment: root.itemEnv()
|
|
stdout: StdioCollector { id: createItemStdout; waitForEnd: true }
|
|
stderr: StdioCollector { id: createItemStderr; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(createItemProc)) return
|
|
root.itemPayloadJson = ""
|
|
root.onSaveItemFinished(exitCode, createItemStdout.text, createItemStderr.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: editItemProc
|
|
environment: root.itemEnv()
|
|
stdout: StdioCollector { id: editItemStdout; waitForEnd: true }
|
|
stderr: StdioCollector { id: editItemStderr; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(editItemProc)) return
|
|
root.itemPayloadJson = ""
|
|
root.onSaveItemFinished(exitCode, editItemStdout.text, editItemStderr.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: deleteItemProc
|
|
environment: root.bwEnv()
|
|
stdout: StdioCollector { id: deleteItemStdout; waitForEnd: true }
|
|
stderr: StdioCollector { id: deleteItemStderr; waitForEnd: true }
|
|
onExited: function(exitCode) {
|
|
if (root.finishScrubRun(deleteItemProc)) return
|
|
root.onDeleteItemFinished(exitCode, deleteItemStdout.text, deleteItemStderr.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: syncProc
|
|
environment: root.bwEnv()
|
|
onExited: function(exitCode) {
|
|
root.onSyncFinished(exitCode)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: lockProc
|
|
environment: root.bwEnv()
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// IPC Handler
|
|
// -------------------------------------------------------------------------
|
|
|
|
IpcHandler {
|
|
target: "io.github.elevate08.qs-bitwarden-cli"
|
|
function open(): void { root.open() }
|
|
function close(): void { root.close() }
|
|
function toggle(): void { root.toggle() }
|
|
function lock(): string { root.lockVault(); return "locked" }
|
|
function settings(): string { root.open(); root.openSettings(); return "settings" }
|
|
function setup(): string {
|
|
root.open()
|
|
root.setupDismissed = false
|
|
root.checkDependencies()
|
|
root.currentScreen = "setup"
|
|
return "setup"
|
|
}
|
|
function sync(): string { root.syncVault(); return "syncing" }
|
|
function status(): string { return root.status }
|
|
// Non-secret diagnostics for the SSH agent. No key material, no
|
|
// fingerprints, no process paths -- just enough to tell why a signature
|
|
// was or was not answered.
|
|
function sshAgentStatus(): string {
|
|
return JSON.stringify({
|
|
enabled: root.sshAgentEnabled,
|
|
phase: root.sshAgentPhase,
|
|
// Named for what it is: the control channel to the helper is up and
|
|
// handshaked. It is not "signing is allowed" -- that is the vault
|
|
// state below, and reading this as the former is misleading next to a
|
|
// locked vault.
|
|
helperChannelOpen: root.sshAgentGateOpen,
|
|
vaultState: Model.sshAgentVaultState({
|
|
enabled: root.sshAgentEnabled,
|
|
helperReady: root.sshAgentGateOpen,
|
|
loggedIn: root.status !== "unauthenticated",
|
|
unlocked: root.status === "unlocked",
|
|
loading: root.sshAgentLoadActive,
|
|
hasPublicCache: root.sshAgentKeyCount > 0
|
|
}),
|
|
setupState: root.sshAgentSetup.state,
|
|
// Which binary is actually running, and whether its digest was
|
|
// checked. A shipped helper and a silently substituted development
|
|
// build behave identically until one of them misbehaves, and without
|
|
// these two fields the terminal cannot tell them apart at all.
|
|
helperSource: root.sshAgentHelper.source,
|
|
helperChecksum: root.sshAgentHelper.checksum,
|
|
// Why inspection rejected it, in the inspector's own vocabulary:
|
|
// checksum-mismatch, not-elf, wrong-architecture, not-executable,
|
|
// self-test-failed. errorCode covers the running helper and stays
|
|
// empty for all of these, so without this the terminal is told the
|
|
// feature is in error and never told what the error was.
|
|
helperState: root.sshAgentHelper.state,
|
|
// What the panel believes about client routing: the file it last
|
|
// inspected, and whether that produced a notice. Both are read from
|
|
// the same state the settings screen draws, so a disagreement between
|
|
// this and the screen is itself the answer.
|
|
routingFragment: root.uwsmFragment.state,
|
|
routingNotice: root.sshRoutingNotice.text !== "",
|
|
errorCode: root.sshAgentErrorCode,
|
|
keyCount: root.sshAgentKeyCount,
|
|
loadActive: root.sshAgentLoadActive,
|
|
epoch: root.sshAgentEpoch,
|
|
promptShowing: root.sshPrompt !== null,
|
|
unlockShowing: root.sshUnlockRequest !== null,
|
|
grants: root.sshGrants.length,
|
|
screenLocked: root.screenIsLocked,
|
|
screenLockAgeMs: root.screenLockCheckedAt > 0 ? Math.round(Date.now() - root.screenLockCheckedAt) : -1,
|
|
mayPrompt: root.sshAgentMayPrompt(),
|
|
cooldownRefusals: root.sshCooldown ? root.sshCooldown.refusals : 0,
|
|
cooldownActive: Model.sshAgentCooldownActive(root.sshCooldown, Date.now())
|
|
})
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: shieldIconComp
|
|
|
|
Item {
|
|
anchors.fill: parent
|
|
|
|
// Constant Base Shield
|
|
TextMetrics {
|
|
id: shieldGlyphMetrics
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.bar.iconFont
|
|
text: ""
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: shieldGlyph
|
|
// The centering below is what holds the glyph on the same logical
|
|
// centerline as the bar's panel-open indicator; the renderer does not
|
|
// enter into it. Measured at scale 1.3333, QtRendering and
|
|
// NativeRendering put the painted center on the same pixel -- but
|
|
// QtRendering came out with saturated colour on the glyph edges, blue
|
|
// down one side and gold down the other, which no other icon in the bar
|
|
// has. So this matches what Omarchy uses everywhere else
|
|
// (Ui/OpticalGlyph.qml, Ui/WidgetButton.qml) and the plugin's own lock
|
|
// and install badges below.
|
|
anchors.centerIn: parent
|
|
anchors.horizontalCenterOffset: shieldGlyph.implicitWidth / 2
|
|
- (shieldGlyphMetrics.tightBoundingRect.x
|
|
+ shieldGlyphMetrics.tightBoundingRect.width / 2)
|
|
text: ""
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.bar.iconFont
|
|
color: root.colorizeIcon ? Color.accent : (bar ? bar.barForeground : Color.foreground)
|
|
renderType: Text.NativeRendering
|
|
}
|
|
|
|
// Mini Install Badge in the same corner while a required tool is absent.
|
|
// A freshly installed widget has to say "click me, there is one step
|
|
// left" rather than sit there looking like it failed, so this outranks
|
|
// the padlock: with no `bw` there is no lock state worth reporting.
|
|
Item {
|
|
visible: root.missingRequired.length > 0
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
anchors.rightMargin: -Style.space(2)
|
|
anchors.bottomMargin: -Style.space(2)
|
|
width: Style.space(10)
|
|
height: Style.space(10)
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: width / 2
|
|
color: bar ? bar.background : Color.background
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.centerIn: parent
|
|
text: ""
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.space(8)
|
|
color: bar ? bar.urgent : Color.urgent
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
|
|
// Mini Padlock Badge in Bottom-Right Corner when locked
|
|
Item {
|
|
visible: root.status === "locked" && root.missingRequired.length === 0
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
anchors.rightMargin: -Style.space(2)
|
|
anchors.bottomMargin: -Style.space(2)
|
|
width: Style.space(10)
|
|
height: Style.space(10)
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: width / 2
|
|
color: bar ? bar.background : Color.background
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.centerIn: parent
|
|
text: ""
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.space(8)
|
|
color: bar ? bar.barForeground : Color.foreground
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Status Bar Button
|
|
// -------------------------------------------------------------------------
|
|
|
|
BarIconButton {
|
|
id: button
|
|
anchors.fill: parent
|
|
bar: root.bar
|
|
iconComponent: shieldIconComp
|
|
useActiveColor: false
|
|
dimmed: root.status === "unauthenticated" || root.status === "checking"
|
|
tooltipText: {
|
|
// Ahead of every status: with a required tool missing, whatever `bw`
|
|
// last said about the vault is beside the point.
|
|
if (root.missingRequired.length > 0) {
|
|
return "Bitwarden (Click to finish setup)"
|
|
}
|
|
if (root.status === "unlocked") {
|
|
return "Bitwarden (" + (root.items.length > 0 ? root.items.length + " items" : "Unlocked") + ")"
|
|
}
|
|
if (root.status === "locked") {
|
|
return "Bitwarden (Locked)"
|
|
}
|
|
return "Bitwarden (Not Logged In)"
|
|
}
|
|
onPressed: function(buttonCode) {
|
|
if (buttonCode === Qt.RightButton) {
|
|
if (root.status === "unlocked") root.lockVault()
|
|
else root.open()
|
|
} else if (buttonCode === Qt.MiddleButton) {
|
|
root.syncVault()
|
|
} else {
|
|
root.toggle()
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Popup Window (KeyboardPanel)
|
|
// -------------------------------------------------------------------------
|
|
|
|
SshApprovalPopup {
|
|
panel: root
|
|
anchorItem: button
|
|
}
|
|
|
|
KeyboardPanel {
|
|
id: panel
|
|
anchorItem: button
|
|
owner: root
|
|
bar: root.bar
|
|
open: root.opened
|
|
// Every unlocked screen except the two that are text entry drives the key
|
|
// catcher, so arrow navigation works on settings and the generator too.
|
|
// Setup is buttons, not text entry, and it is reached with the vault state
|
|
// still unknown -- so it takes the key catcher outright rather than
|
|
// handing focus to a password field that is not even on screen.
|
|
focusTarget: root.currentScreen === "setup"
|
|
? keyCatcher
|
|
: ((root.status === "unlocked"
|
|
&& root.currentScreen !== "edit"
|
|
&& root.currentScreen !== "pin"
|
|
&& root.currentScreen !== "fingerprint")
|
|
? keyCatcher
|
|
: (root.status === "unauthenticated"
|
|
? (root.show2faField ? code2faField : emailField)
|
|
: passField))
|
|
contentWidth: panel.fittedContentWidth(Style.space(450))
|
|
contentHeight: panel.fittedContentHeight(mainColumn.implicitHeight, Style.space(640) + root.filterDrawerHeight)
|
|
|
|
// PanelKeyCatcher maps h/j/k/l to arrow navigation and consumes them before
|
|
// its textKey signal fires, which silently swallowed the l (lock) shortcut.
|
|
// Forwarding here first gives our letter bindings the first look; anything
|
|
// we do not accept falls through to the catcher's own navigation.
|
|
Item {
|
|
id: shortcutInterceptor
|
|
Keys.onPressed: function(event) {
|
|
// Escape is handled here rather than in the key catcher because the
|
|
// catcher is blocked on every screen built around a text field -- the
|
|
// item form, the PIN and fingerprint screens, the Send composer --
|
|
// and a blocked catcher swallows Escape along with everything else.
|
|
// This interceptor runs first and is not gated by `blocked`, so
|
|
// cancelling out of a form works while the cursor is in a field.
|
|
if (event.key === Qt.Key_Escape && !(event.modifiers & ~Qt.KeypadModifier)) {
|
|
root.handleEscape()
|
|
event.accepted = true
|
|
return
|
|
}
|
|
|
|
// Alt may arrive with no text depending on the keymap, so fall back to
|
|
// the key code for A-Z.
|
|
var t = event.text ? String(event.text).toLowerCase() : ""
|
|
if (!t && event.key >= Qt.Key_A && event.key <= Qt.Key_Z) {
|
|
t = String.fromCharCode(event.key).toLowerCase()
|
|
}
|
|
|
|
if (event.modifiers & Qt.AltModifier) {
|
|
if (t && root.status === "unlocked" && root.runAltShortcut(t)) event.accepted = true
|
|
return
|
|
}
|
|
|
|
if (event.modifiers & ~Qt.KeypadModifier) return
|
|
if (!t || root.currentScreen !== "main") return
|
|
if (root.openFilterGroup !== "") return
|
|
if (t !== "h" && t !== "j" && t !== "k" && t !== "l") return
|
|
if (root.runShortcut(t)) event.accepted = true
|
|
}
|
|
}
|
|
|
|
PanelKeyCatcher {
|
|
id: keyCatcher
|
|
anchors.fill: parent
|
|
Keys.forwardTo: [shortcutInterceptor]
|
|
blocked: searchField.activeFocus
|
|
|| emailField.activeFocus
|
|
|| loginPassField.activeFocus
|
|
|| code2faField.activeFocus
|
|
|| passField.activeFocus
|
|
|| pinField.activeFocus
|
|
|| (root.currentScreen === "edit")
|
|
|| (root.currentScreen === "pin")
|
|
|| (root.currentScreen === "fingerprint")
|
|
|| (root.currentScreen === "sends" && root.sendMode === "create")
|
|
|
|
// Reached only on screens where the catcher is not blocked; the
|
|
// interceptor handles Escape everywhere else. Same dispatch either way.
|
|
onCloseRequested: root.handleEscape()
|
|
onTabRequested: function(direction) {
|
|
if (root.currentScreen === "main") {
|
|
root.cycleCategory(direction)
|
|
} else {
|
|
root.switchPanel(direction)
|
|
}
|
|
}
|
|
onMoveRequested: function(dx, dy) {
|
|
if (root.currentScreen === "sends" && root.sendMode === "list") {
|
|
if (dy !== 0) root.moveSendCursor(dy)
|
|
return
|
|
}
|
|
if (root.currentScreen === "settings") {
|
|
if (dy !== 0) root.moveSettingsCursor(dy)
|
|
else if (dx !== 0) root.adjustSetting(dx)
|
|
return
|
|
}
|
|
// While a filter drawer is open the arrows drive it, not the item list.
|
|
if (root.openFilterGroup !== "" && root.currentScreen === "main") {
|
|
if (dy !== 0) root.moveFilterCursor(dy)
|
|
return
|
|
}
|
|
if (!root.cursorActive) {
|
|
root.cursorActive = true
|
|
return
|
|
}
|
|
if (root.currentScreen === "main") {
|
|
if (dy !== 0) root.moveCursor(dy)
|
|
else if (dx !== 0) root.cycleCategory(dx)
|
|
}
|
|
}
|
|
onActivateRequested: {
|
|
if (root.currentScreen === "generator" && root.generatorFeedsForm) {
|
|
root.useGeneratedPassword()
|
|
return
|
|
}
|
|
if (root.currentScreen === "sends" && root.sendMode === "list") {
|
|
if (root.sendIndex < root.sends.length) root.copySendLink(root.sends[root.sendIndex])
|
|
return
|
|
}
|
|
if (root.currentScreen === "settings") {
|
|
root.activateSettingRow()
|
|
return
|
|
}
|
|
if (root.openFilterGroup !== "" && root.currentScreen === "main") {
|
|
root.activateFilterOption()
|
|
return
|
|
}
|
|
if (root.currentScreen === "main") {
|
|
var item = root.getSelectedItem()
|
|
if (item) {
|
|
root.handleSmartEnter(item)
|
|
}
|
|
return
|
|
}
|
|
// The password row has always been labelled "Copy password (y / Enter)"
|
|
// and the detail screen has never handled Enter, so that half of the
|
|
// tooltip was a promise nothing kept. Enter copies the item's primary
|
|
// secret here, the same one `y` reaches: the password on a login, the
|
|
// number on a card. A note or an identity has no single such value, so
|
|
// Enter stays inert on those rather than guessing at one.
|
|
if (root.currentScreen === "detail") {
|
|
if (root.detailIsCard) {
|
|
if (root.detailCard && root.detailCard.number) {
|
|
root.copyToClipboard(root.detailCard.number, "Card number")
|
|
}
|
|
} else if (root.detailIsLoginLike && root.detailPassword) {
|
|
root.copyToClipboard(root.detailPassword, "Password")
|
|
}
|
|
}
|
|
}
|
|
onTextKey: function(key) {
|
|
var lower = String(key).toLowerCase()
|
|
if (root.currentScreen === "sends" && root.sendMode === "list") {
|
|
if (lower === "n") root.beginCreateSend()
|
|
else if (lower === "r") root.loadSends()
|
|
else if (lower === "x" && root.sendIndex < root.sends.length) root.deleteSend(root.sends[root.sendIndex])
|
|
return
|
|
}
|
|
if (root.currentScreen === "main") {
|
|
if (lower === "/") searchField.forceActiveFocus()
|
|
else root.runShortcut(lower)
|
|
} else if (root.currentScreen === "detail") {
|
|
// `y` is "copy the thing this item is for". On a login that is the
|
|
// password; on a card it is the number. Keeping one key for the
|
|
// primary secret is worth more than a key that means `password`
|
|
// everywhere and does nothing on two of the four types.
|
|
if (lower === "y" || lower === "p") {
|
|
if (root.detailIsCard) {
|
|
if (root.detailCard && root.detailCard.number) root.copyToClipboard(root.detailCard.number, "Card number")
|
|
} else if (root.detailPassword) {
|
|
root.copyToClipboard(root.detailPassword, "Password")
|
|
}
|
|
} else if (lower === "n") {
|
|
if (root.detailIsCard && root.detailCard && root.detailCard.number) {
|
|
root.copyToClipboard(root.detailCard.number, "Card number")
|
|
}
|
|
} else if (lower === "k") {
|
|
if (root.detailIsCard && root.detailCard && root.detailCard.code) {
|
|
root.copyToClipboard(root.detailCard.code, "Security code")
|
|
}
|
|
} else if (lower === "u" || lower === "c") {
|
|
// `u` copies the identifier, `c` the contact address. On a login
|
|
// both land on the one username field, which is what they have
|
|
// always done.
|
|
if (root.detailIsIdentity && root.detailIdentity) {
|
|
if (lower === "c" && root.detailIdentity.email) {
|
|
root.copyToClipboard(root.detailIdentity.email, "Email")
|
|
} else if (root.detailIdentity.username) {
|
|
root.copyToClipboard(root.detailIdentity.username, "Username")
|
|
}
|
|
} else if (root.detailItem && root.detailItem.username) {
|
|
root.copyToClipboard(root.detailItem.username, "Username")
|
|
}
|
|
} else if (lower === "m") {
|
|
if (root.liveTotp) root.copyToClipboard(root.liveTotp, "TOTP")
|
|
} else if (lower === "e") {
|
|
if (root.detailItem) root.startEditItem(root.detailItem)
|
|
} else if (lower === "x") {
|
|
if (root.detailItem && root.detailItem.typeCode !== 5) root.showDeleteConfirm = true
|
|
} else if (lower === "v") {
|
|
if (root.primaryRevealKey !== "") root.toggleFieldReveal(root.primaryRevealKey)
|
|
} else if (lower === "a") {
|
|
root.saveAllAttachments()
|
|
} else if (lower === "b" || lower === "q") {
|
|
root.currentScreen = "main"
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
id: mainColumn
|
|
anchors.fill: parent
|
|
spacing: Style.space(12)
|
|
|
|
// -------------------------------------------------------------------
|
|
// Hero Header
|
|
// -------------------------------------------------------------------
|
|
PanelHero {
|
|
width: parent.width
|
|
title: "Bitwarden"
|
|
meta: {
|
|
if (root.status === "unlocked") {
|
|
if (root.isSyncing) return "Syncing..."
|
|
if (root.isLoading && root.items.length === 0) return "Loading items..."
|
|
// The email arrives with `bw status`, which lags the item list on
|
|
// a cold start and after a terminal-login handoff. Fall back to
|
|
// the count so the subtitle is never blank in that gap.
|
|
return root.userEmail || (root.filteredItems.length + " items")
|
|
}
|
|
if (root.status === "locked") return "Vault Locked"
|
|
if (root.status === "checking") return "Checking status..."
|
|
return "Log In"
|
|
}
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
|
|
iconComponent: Text {
|
|
textFormat: Text.PlainText
|
|
text: ""
|
|
color: root.barIconColor
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.display
|
|
}
|
|
|
|
trailingControl: Row {
|
|
spacing: Style.space(6)
|
|
|
|
// New Item Button
|
|
PanelActionButton {
|
|
visible: root.status === "unlocked" && root.activeScreen === "main"
|
|
iconText: ""
|
|
tooltipText: "New item (n)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.startAddNewItem()
|
|
}
|
|
|
|
// Sync Vault Button
|
|
PanelActionButton {
|
|
visible: root.status === "unlocked"
|
|
iconText: ""
|
|
tooltipText: root.isSyncing ? "Syncing..." : "Sync vault (r)"
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.isSyncing
|
|
onClicked: root.syncVault()
|
|
}
|
|
|
|
// Send Button
|
|
PanelActionButton {
|
|
visible: root.status === "unlocked" && root.activeScreen !== "sends"
|
|
iconText: ""
|
|
tooltipText: "Bitwarden Send (Alt+S)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.openSends()
|
|
}
|
|
|
|
// Generator Button
|
|
PanelActionButton {
|
|
visible: root.status === "unlocked" && root.activeScreen !== "generator"
|
|
iconText: ""
|
|
tooltipText: "Password generator (g)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.openGenerator()
|
|
}
|
|
|
|
// Settings Button
|
|
PanelActionButton {
|
|
visible: root.activeScreen !== "settings" && root.activeScreen !== "setup" && root.activeScreen !== "pin"
|
|
iconText: ""
|
|
tooltipText: "Settings (s)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.openSettings()
|
|
}
|
|
|
|
// Lock Vault Button
|
|
PanelActionButton {
|
|
visible: root.status === "unlocked"
|
|
iconText: ""
|
|
tooltipText: "Lock vault (l)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.lockVault()
|
|
}
|
|
|
|
// Close Panel Button
|
|
PanelActionButton {
|
|
iconText: ""
|
|
tooltipText: "Close (Esc)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.close()
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Sequential TOTP Follow-Up Action Banner
|
|
// -------------------------------------------------------------------
|
|
BorderSurface {
|
|
visible: root.totpFollowupActive && root.totpFollowupItem !== null
|
|
width: parent.width
|
|
implicitHeight: Style.space(42)
|
|
color: Util.alpha(Color.accent, 0.2)
|
|
radius: Style.cornerRadius
|
|
borderSpec: Border.surfaceSpec("menu", "border", Color.accent, 1)
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.rightMargin: Style.space(10)
|
|
spacing: Style.space(8)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: ""
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
|
|
Column {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - copyFollowupTotpBtn.width - Style.space(40)
|
|
spacing: 1
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "Password copied! Press Enter for TOTP"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: root.totpFollowupCode ? ("Code: " + root.totpFollowupCode + " (expires in " + root.totpSecRemaining + "s)") : "Fetching 2FA code..."
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
|
|
Button {
|
|
id: copyFollowupTotpBtn
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "Copy TOTP (Enter)"
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: {
|
|
if (root.totpFollowupItem) root.copyTotpCode(root.totpFollowupItem)
|
|
root.totpFollowupActive = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Development Helper Banner
|
|
// -------------------------------------------------------------------
|
|
// The shipped helper is what a user installed and what CI verified.
|
|
// Falling back to a local build is deliberate -- a broken release must
|
|
// not strand a working one -- but it is a state you can sit in for
|
|
// days without noticing, signing with a binary nobody checked. The
|
|
// settings screen says so in passing; this says so wherever you are.
|
|
BorderSurface {
|
|
visible: root.sshAgentHelper.source === "development" && root.activeScreen !== "settings"
|
|
width: parent.width
|
|
implicitHeight: sshDevHelperText.implicitHeight + Style.space(12)
|
|
color: Util.alpha(Color.urgent, 0.15)
|
|
radius: Style.cornerRadius
|
|
borderSpec: Border.surfaceSpec("menu", "border", Color.urgent, 1)
|
|
|
|
Row {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Style.space(16)
|
|
spacing: Style.space(8)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: ""
|
|
color: Color.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: sshDevHelperText
|
|
text: Model.sshAgentDevelopmentHelperWarning(root.sshAgentHelper)
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.Wrap
|
|
width: parent.width - Style.space(24)
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SSH Signing Cooldown Banner
|
|
// -------------------------------------------------------------------
|
|
// A five-minute signing outage is not noticed on the SSH agent
|
|
// settings screen: the requests it refuses arrive while the panel is
|
|
// showing something else, or while the vault is locked and no prompt
|
|
// can be raised at all. So the explanation lives on every screen,
|
|
// and carries the only control that ends the cooldown early -- an
|
|
// approval cannot, because there is no prompt left to approve.
|
|
BorderSurface {
|
|
visible: root.sshCooldownStatus.active
|
|
width: parent.width
|
|
implicitHeight: sshCooldownBannerBody.implicitHeight + Style.space(12)
|
|
color: Util.alpha(Color.urgent, 0.15)
|
|
radius: Style.cornerRadius
|
|
borderSpec: Border.surfaceSpec("menu", "border", Color.urgent, 1)
|
|
|
|
Row {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Style.space(16)
|
|
spacing: Style.space(8)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: ""
|
|
color: Color.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
Column {
|
|
id: sshCooldownBannerBody
|
|
width: parent.width - Style.space(24)
|
|
spacing: Style.space(8)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: sshCooldownBannerText
|
|
text: root.sshCooldownStatus.message
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.Wrap
|
|
width: parent.width
|
|
}
|
|
|
|
Button {
|
|
text: "Resume Signing Now"
|
|
iconText: ""
|
|
tooltipText: "End the cooldown; the next signing request asks again"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.resumeSshSigning()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 0f: BITWARDEN SEND
|
|
// -------------------------------------------------------------------
|
|
Flickable {
|
|
id: sendFlick
|
|
visible: root.activeScreen === "sends"
|
|
width: parent.width
|
|
height: Math.min(Style.space(520), sendCol.implicitHeight)
|
|
contentWidth: width
|
|
contentHeight: sendCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: sendFlick }
|
|
|
|
Column {
|
|
id: sendCol
|
|
width: sendFlick.width - root.scrollGutter
|
|
spacing: Style.space(10)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: root.sendMode === "create" ? "Back to Sends" : "Back (Esc)"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: {
|
|
if (root.sendMode === "create") { root.sendError = ""; root.sendMode = "list" }
|
|
else root.currentScreen = "main"
|
|
}
|
|
}
|
|
|
|
Button {
|
|
visible: root.sendMode === "list"
|
|
text: "New Send"
|
|
iconText: ""
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.beginCreateSend()
|
|
}
|
|
|
|
Button {
|
|
visible: root.sendMode === "list"
|
|
text: "Refresh"
|
|
iconText: ""
|
|
iconSpinning: root.sendsLoading
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.loadSends()
|
|
}
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.sendError !== ""
|
|
width: parent.width
|
|
text: root.sendError
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// ---------------- list ----------------
|
|
Column {
|
|
visible: root.sendMode === "list"
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: !root.sendsLoading && root.sends.length === 0
|
|
width: parent.width
|
|
text: "No Sends yet. A Send shares a secret through a link that expires on its own -- useful for handing someone a credential without it living in a chat log."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.sendsLoading
|
|
text: "Loading Sends..."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
|
|
Repeater {
|
|
model: root.sends
|
|
|
|
delegate: BorderSurface {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent.width
|
|
implicitHeight: sendRowCol.implicitHeight + Style.space(16)
|
|
radius: Style.cornerRadius
|
|
readonly property bool cursored: index === root.sendIndex
|
|
color: cursored ? Style.hoverFillFor(root.fg, Color.accent) : "transparent"
|
|
borderSpec: Border.surfaceSpec("menu", "border",
|
|
cursored ? Color.accent : Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.18), 1)
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onEntered: root.sendIndex = index
|
|
}
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.margins: Style.space(8)
|
|
spacing: Style.space(8)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.isFile ? "" : ""
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.subtitle
|
|
}
|
|
|
|
Column {
|
|
id: sendRowCol
|
|
width: parent.width - Style.space(110)
|
|
spacing: Style.space(2)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: modelData.name
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
font.bold: true
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Row {
|
|
spacing: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: Model.sendExpiryLabel(modelData, Date.now())
|
|
color: Model.sendExpiryLabel(modelData, Date.now()) === "expired" ? root.urgent : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "\u00b7 " + Model.sendAccessLabel(modelData)
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: modelData.passwordSet
|
|
text: "\u00b7 password"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
}
|
|
|
|
PanelActionButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
tooltipText: "Copy Send link"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.copySendLink(modelData)
|
|
}
|
|
|
|
PanelActionButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
tooltipText: "Delete this Send"
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.sendBusy
|
|
onClicked: root.deleteSend(modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------- create ----------------
|
|
Column {
|
|
visible: root.sendMode === "create"
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Text { textFormat: Text.PlainText; text: "NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
id: sendNameField
|
|
width: parent.width
|
|
placeholderText: "What is this? (optional)"
|
|
text: root.sendFormName
|
|
onTextChanged: root.sendFormName = text
|
|
enabled: !root.sendBusy
|
|
}
|
|
|
|
Text { textFormat: Text.PlainText; text: "TEXT TO SEND"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "The secret to share..."
|
|
text: root.sendFormText
|
|
onTextChanged: root.sendFormText = text
|
|
enabled: !root.sendBusy
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Button {
|
|
text: "Hide text by default"
|
|
tooltipText: "The recipient must click to reveal it"
|
|
selected: root.sendFormHidden
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.sendFormHidden = !root.sendFormHidden
|
|
}
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(170)
|
|
text: "Delete after"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "days"
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
NumberField {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
value: root.sendFormDays
|
|
from: 1
|
|
to: 31
|
|
stepSize: 1
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { root.sendFormDays = v }
|
|
}
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(170)
|
|
text: "Maximum views"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.sendFormMaxAccess === 0 ? "unlimited" : ""
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
NumberField {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
value: root.sendFormMaxAccess
|
|
from: 0
|
|
to: 100
|
|
stepSize: 1
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { root.sendFormMaxAccess = v }
|
|
}
|
|
}
|
|
|
|
Text { textFormat: Text.PlainText; text: "PASSWORD (OPTIONAL)"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "Recipient must enter this to open the Send..."
|
|
password: true
|
|
text: root.sendFormPassword
|
|
onTextChanged: root.sendFormPassword = text
|
|
enabled: !root.sendBusy
|
|
}
|
|
|
|
Button {
|
|
width: parent.width
|
|
text: root.sendBusy ? "Creating..." : "Create Send & Copy Link"
|
|
iconText: root.sendBusy ? "" : ""
|
|
iconSpinning: root.sendBusy
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.sendBusy
|
|
onClicked: root.submitCreateSend()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 0e: FINGERPRINT SETUP
|
|
// -------------------------------------------------------------------
|
|
Flickable {
|
|
id: fpFlick
|
|
visible: root.activeScreen === "fingerprint"
|
|
width: parent.width
|
|
height: Math.min(Style.space(520), fpCol.implicitHeight)
|
|
contentWidth: width
|
|
contentHeight: fpCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: fpFlick }
|
|
|
|
Column {
|
|
id: fpCol
|
|
width: fpFlick.width - root.scrollGutter
|
|
spacing: Style.space(12)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "Enable fingerprint unlock"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: "A fingerprint proves you are present but cannot produce your master password, and bw unlock accepts nothing else. The password is stored in the OS login keyring, and a verified fingerprint is the gate on reading it back."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: "Anyone who can read your unlocked login keyring can read the password. A PIN stores it encrypted instead."
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Text { textFormat: Text.PlainText; text: "MASTER PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
|
|
TextField {
|
|
id: fpMasterField
|
|
width: parent.width
|
|
placeholderText: "Needed once, to store for fingerprint unlock..."
|
|
password: true
|
|
text: root.fpSetupMaster
|
|
onTextChanged: root.fpSetupMaster = text
|
|
onAccepted: root.submitFingerprintSetup()
|
|
enabled: !root.fpBusy
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.fpError !== ""
|
|
width: parent.width
|
|
text: root.fpError
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: root.fpBusy ? "Saving..." : "Enable"
|
|
iconText: root.fpBusy ? "" : ""
|
|
iconSpinning: root.fpBusy
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.fpBusy
|
|
onClicked: root.submitFingerprintSetup()
|
|
}
|
|
|
|
Button {
|
|
text: "Cancel"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.fpBusy
|
|
onClicked: { root.fpError = ""; root.currentScreen = "settings" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 0c: PIN SETUP
|
|
// -------------------------------------------------------------------
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 0d: GENERATOR
|
|
// -------------------------------------------------------------------
|
|
Flickable {
|
|
id: genFlick
|
|
visible: root.activeScreen === "generator"
|
|
width: parent.width
|
|
height: Math.min(Style.space(520), genCol.implicitHeight)
|
|
contentWidth: width
|
|
contentHeight: genCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: genFlick }
|
|
|
|
Column {
|
|
id: genCol
|
|
width: genFlick.width - root.scrollGutter
|
|
spacing: Style.space(10)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: root.generatorFeedsForm ? "Back to item (Esc)" : "Back (Esc)"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.closeGenerator()
|
|
}
|
|
|
|
// Only when the generator was opened from the item form: hand
|
|
// the value back to the password field and return there.
|
|
Button {
|
|
visible: root.generatorFeedsForm
|
|
text: "Use this password (Enter)"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
selected: true
|
|
accent: Color.accent
|
|
enabled: !root.genBusy && root.genValue !== ""
|
|
onClicked: root.useGeneratedPassword()
|
|
}
|
|
}
|
|
|
|
// Generated value
|
|
BorderSurface {
|
|
width: parent.width
|
|
implicitHeight: Style.space(58)
|
|
radius: Style.cornerRadius
|
|
color: Style.hoverFillFor(root.fg, Color.accent)
|
|
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(12)
|
|
anchors.rightMargin: Style.space(6)
|
|
spacing: Style.space(4)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(90)
|
|
text: root.genBusy ? "Generating..." : (root.genValue || "-")
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.subtitle
|
|
font.bold: true
|
|
wrapMode: Text.WrapAnywhere
|
|
maximumLineCount: 2
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
PanelActionButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
tooltipText: "Regenerate"
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.genBusy
|
|
onClicked: root.regenerate()
|
|
}
|
|
|
|
PanelActionButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
tooltipText: "Copy"
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.genBusy && root.genValue !== ""
|
|
onClicked: root.copyGenerated()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strength meter
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
|
|
readonly property var strength: Model.generatorStrength(root.genOpts)
|
|
|
|
Row {
|
|
width: parent.width
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: parent.parent.strength.label
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
Item { width: Style.space(6); height: 1 }
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "~" + parent.parent.strength.bits + " bits of entropy"
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: parent.width
|
|
height: Style.space(4)
|
|
radius: height / 2
|
|
color: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.15)
|
|
|
|
Rectangle {
|
|
width: parent.width * parent.parent.strength.fraction
|
|
height: parent.height
|
|
radius: height / 2
|
|
color: Color.accent
|
|
}
|
|
}
|
|
}
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
// Type
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: "Password"
|
|
iconText: ""
|
|
selected: root.genOpts.type === "password"
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.setGenOpt("type", "password")
|
|
}
|
|
|
|
Button {
|
|
text: "Passphrase"
|
|
iconText: ""
|
|
selected: root.genOpts.type === "passphrase"
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.setGenOpt("type", "passphrase")
|
|
}
|
|
}
|
|
|
|
// ---- Password options ----
|
|
Column {
|
|
visible: root.genOpts.type === "password"
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(170)
|
|
text: "Length"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
NumberField {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
value: root.genOpts.length
|
|
from: 5
|
|
to: 128
|
|
stepSize: 1
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { root.setGenOpt("length", v) }
|
|
}
|
|
}
|
|
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Button {
|
|
text: "A-Z"
|
|
selected: root.genOpts.uppercase
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.setGenOpt("uppercase", !root.genOpts.uppercase)
|
|
}
|
|
Button {
|
|
text: "a-z"
|
|
selected: root.genOpts.lowercase
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.setGenOpt("lowercase", !root.genOpts.lowercase)
|
|
}
|
|
Button {
|
|
text: "0-9"
|
|
selected: root.genOpts.numbers
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.setGenOpt("numbers", !root.genOpts.numbers)
|
|
}
|
|
Button {
|
|
text: "!@#$%^&*"
|
|
selected: root.genOpts.special
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.setGenOpt("special", !root.genOpts.special)
|
|
}
|
|
Button {
|
|
text: "Avoid ambiguous"
|
|
tooltipText: "Exclude characters that are easy to confuse, such as l, 1, I, O and 0"
|
|
selected: root.genOpts.ambiguous
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.setGenOpt("ambiguous", !root.genOpts.ambiguous)
|
|
}
|
|
}
|
|
|
|
Row {
|
|
visible: root.genOpts.numbers
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(170)
|
|
text: "Minimum numbers"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
NumberField {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
value: root.genOpts.minNumber
|
|
from: 0
|
|
to: 9
|
|
stepSize: 1
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { root.setGenOpt("minNumber", v) }
|
|
}
|
|
}
|
|
|
|
Row {
|
|
visible: root.genOpts.special
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(170)
|
|
text: "Minimum special"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
NumberField {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
value: root.genOpts.minSpecial
|
|
from: 0
|
|
to: 9
|
|
stepSize: 1
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { root.setGenOpt("minSpecial", v) }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Passphrase options ----
|
|
Column {
|
|
visible: root.genOpts.type === "passphrase"
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(170)
|
|
text: "Number of words"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
NumberField {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
value: root.genOpts.words
|
|
from: 3
|
|
to: 20
|
|
stepSize: 1
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { root.setGenOpt("words", v) }
|
|
}
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(170)
|
|
text: "Word separator"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
TextField {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Style.space(90)
|
|
text: root.genOpts.separator
|
|
onTextChanged: if (text && text !== root.genOpts.separator) root.setGenOpt("separator", text.charAt(0))
|
|
}
|
|
}
|
|
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Button {
|
|
text: "Capitalize"
|
|
selected: root.genOpts.capitalize
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.setGenOpt("capitalize", !root.genOpts.capitalize)
|
|
}
|
|
Button {
|
|
text: "Include number"
|
|
selected: root.genOpts.includeNumber
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.setGenOpt("includeNumber", !root.genOpts.includeNumber)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scrolls rather than overflowing the panel: this screen is taller
|
|
// than the popup's height cap on smaller displays.
|
|
Flickable {
|
|
id: pinFlick
|
|
visible: root.activeScreen === "pin"
|
|
width: parent.width
|
|
height: Math.min(Style.space(520), pinCol.implicitHeight)
|
|
contentWidth: width
|
|
contentHeight: pinCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: pinFlick }
|
|
|
|
Column {
|
|
id: pinCol
|
|
width: pinFlick.width - root.scrollGutter
|
|
spacing: Style.space(12)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "Set an unlock PIN"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: "Your master password is encrypted with a key derived from this PIN, and only the encrypted form is stored. "
|
|
+ "Use " + Model.pinRecommendedLength() + " digits or more; " + Model.pinMinLength()
|
|
+ " is the floor, and every extra digit multiplies an attacker's work by ten."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Text { textFormat: Text.PlainText; text: "MASTER PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "Needed once, to encrypt the PIN..."
|
|
password: true
|
|
text: root.pinSetupMaster
|
|
onTextChanged: root.pinSetupMaster = text
|
|
enabled: !root.pinBusy
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "PIN"
|
|
// The label turns with the field, so the warning is visible even
|
|
// when the cursor has moved on to Confirm.
|
|
color: root.pinSetupWeak ? root.urgent : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
TextField {
|
|
id: pinSetupPinField
|
|
width: parent.width
|
|
placeholderText: Model.pinRecommendedLength() + " digits or more..."
|
|
password: true
|
|
text: root.pinSetupPin
|
|
onTextChanged: root.pinSetupPin = text.replace(/[^0-9]/g, "")
|
|
enabled: !root.pinBusy
|
|
// A short PIN is allowed but not waved through: the border goes
|
|
// red rather than accent while it is under the recommendation.
|
|
accent: root.pinSetupWeak ? root.urgent : Color.accent
|
|
foreground: root.pinSetupWeak ? root.urgent : root.fg
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.pinSetupWeak
|
|
width: parent.width
|
|
text: " " + Model.pinWeakWarning(root.pinSetupPin)
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Text { textFormat: Text.PlainText; text: "CONFIRM PIN"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "Repeat the PIN..."
|
|
password: true
|
|
text: root.pinSetupConfirm
|
|
onTextChanged: root.pinSetupConfirm = text.replace(/[^0-9]/g, "")
|
|
onAccepted: root.submitPinSetup()
|
|
enabled: !root.pinBusy
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.pinError !== ""
|
|
width: parent.width
|
|
text: root.pinError
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: root.pinBusy ? "Encrypting..." : "Save PIN"
|
|
iconText: root.pinBusy ? "" : ""
|
|
iconSpinning: root.pinBusy
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.pinBusy
|
|
onClicked: root.submitPinSetup()
|
|
}
|
|
|
|
Button {
|
|
text: "Cancel"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.pinBusy
|
|
onClicked: { root.pinError = ""; root.currentScreen = "settings" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 0a: SETUP WIZARD (missing dependencies)
|
|
// -------------------------------------------------------------------
|
|
// Scrolls rather than overflowing the panel: this screen is taller
|
|
// than the popup's height cap on smaller displays.
|
|
Flickable {
|
|
id: setupFlick
|
|
visible: root.activeScreen === "setup"
|
|
width: parent.width
|
|
height: Math.min(Style.space(520), setupCol.implicitHeight)
|
|
contentWidth: width
|
|
contentHeight: setupCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: setupFlick }
|
|
|
|
Column {
|
|
id: setupCol
|
|
width: setupFlick.width - root.scrollGutter
|
|
spacing: Style.space(12)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: root.missingRequired.length > 0 ? "One more step" : "All set"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: root.missingRequired.length > 0
|
|
? "The plugin drives these tools rather than bundling them. Install the required ones below and the panel picks them up on its own -- no terminal work to come back from."
|
|
: "Every required tool is installed. Optional ones below unlock extra features."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
// Only rows this machine can act on. A desktop with no fingerprint
|
|
// reader is not missing a dependency.
|
|
model: Model.applicableDependencies(root.dependencies)
|
|
|
|
delegate: BorderSurface {
|
|
required property var modelData
|
|
width: parent.width
|
|
implicitHeight: depRow.implicitHeight + Style.space(16)
|
|
radius: Style.cornerRadius
|
|
color: modelData.ready ? "transparent" : Util.alpha(root.urgent, 0.12)
|
|
borderSpec: Border.surfaceSpec("menu", "border",
|
|
modelData.ready ? Color.accent : root.urgent, 1)
|
|
|
|
Row {
|
|
id: depRow
|
|
anchors.fill: parent
|
|
anchors.margins: Style.space(8)
|
|
spacing: Style.space(10)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.ready ? "" : (modelData.required ? "" : "")
|
|
color: modelData.ready ? Color.accent : (modelData.required ? root.urgent : root.dim)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.subtitle
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - Style.space(170)
|
|
spacing: Style.space(2)
|
|
|
|
Row {
|
|
spacing: Style.space(6)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: modelData.label
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
font.bold: true
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.required ? "required" : "optional"
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: modelData.purpose
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: !!modelData.note
|
|
width: parent.width
|
|
text: modelData.note
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// The package being on PATH is not the finish line for a
|
|
// setup row: fingerprint unlock also wants an enrolled
|
|
// finger and the PAM stack, and only the setup command
|
|
// produces those.
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: modelData.setup && modelData.installed && !modelData.ready
|
|
width: parent.width
|
|
text: "Reader stack is installed, but no finger is enrolled yet."
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
|
|
// One button per row, whichever door this row goes through.
|
|
Button {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: modelData.setup ? !modelData.ready : !modelData.installed
|
|
text: modelData.setup ? "Set up" : "Install"
|
|
iconText: modelData.setup ? "" : ""
|
|
tooltipText: modelData.setup
|
|
? "omarchy setup security fingerprint"
|
|
: "omarchy install app " + modelData.pkg
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.installOne(modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: "Re-check"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.checkDependencies()
|
|
}
|
|
|
|
// The one button a first run needs. It covers the optional tools
|
|
// too, so a single trip through the terminal leaves every feature
|
|
// working rather than only the ones that block startup.
|
|
Button {
|
|
visible: root.installablePackages.length > 0
|
|
text: root.installablePackages.length > 1 ? "Install all missing" : "Install"
|
|
iconText: ""
|
|
selected: true
|
|
accent: Color.accent
|
|
tooltipText: "omarchy install app " + root.installablePackages.join(" ")
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.installMissing()
|
|
}
|
|
|
|
Button {
|
|
text: root.missingRequired.length > 0 ? "Continue anyway" : "Done"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.dismissSetup()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 0b: SETTINGS
|
|
// -------------------------------------------------------------------
|
|
// Scrolls rather than overflowing the panel: this screen is taller
|
|
// than the popup's height cap on smaller displays.
|
|
Column {
|
|
id: settingsScreen
|
|
visible: root.activeScreen === "settings"
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
// Pinned above the scroll area rather than scrolling with it. The
|
|
// right half is the way out, which should never require scrolling to
|
|
// find. The left half is the section the view is currently inside,
|
|
// and it folds that section -- so a user twenty rows into Security
|
|
// can shut it without first scrolling back to its heading.
|
|
Item {
|
|
width: parent.width
|
|
height: Style.space(26)
|
|
|
|
// An indicator, not a control. It says which section the view is
|
|
// inside; the heading it stands for is a plain heading too.
|
|
Row {
|
|
id: stickySection
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(6)
|
|
visible: root.settingsStickyEntry !== null
|
|
|
|
PanelSectionHeader {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.settingsStickyEntry
|
|
? String(root.settingsStickyEntry.label || "").toUpperCase() : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
}
|
|
}
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
// Flush with the scrolling rows below, which stop short of the
|
|
// scrollbar. Without this the Back button overhangs every
|
|
// control it sits above.
|
|
anchors.rightMargin: root.scrollGutter
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(8)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.settingsFlash !== ""
|
|
text: " " + root.settingsFlash
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
|
|
Button {
|
|
text: "Back (Esc)"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.closeSettings()
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
Flickable {
|
|
id: settingsFlick
|
|
width: parent.width
|
|
height: Math.min(Style.space(520), settingsCol.implicitHeight)
|
|
contentWidth: width
|
|
contentHeight: settingsCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar {
|
|
id: settingsScrollBar
|
|
policy: ScrollBar.AsNeeded
|
|
}
|
|
|
|
WheelScroll { view: settingsFlick }
|
|
|
|
// The pinned bar names the section the view is inside, so it has to
|
|
// be recomputed as the view moves and whenever the content resizes.
|
|
// The height case runs a frame later, after layout.
|
|
onContentYChanged: root.updateSettingsSticky()
|
|
onContentHeightChanged: Qt.callLater(root.updateSettingsSticky)
|
|
|
|
Column {
|
|
id: settingsCol
|
|
// Short of the scrollbar rather than under it. The bar is an
|
|
// overlay, so without this it sits on top of whatever is at the
|
|
// right edge -- which on this screen is every toggle and every
|
|
// number field. Reserved unconditionally: the width would
|
|
// otherwise change as the bar came and went, reflowing the rows
|
|
// underneath it.
|
|
width: settingsFlick.width - root.scrollGutter
|
|
spacing: Style.space(10)
|
|
|
|
Connections {
|
|
target: root
|
|
function onSettingsIndexChanged() {
|
|
var row = settingsRepeater.itemAt(root.settingsIndex)
|
|
if (!row) return
|
|
if (row.y < settingsFlick.contentY) {
|
|
settingsFlick.contentY = Math.max(0, row.y - Style.space(8))
|
|
} else if (row.y + row.height > settingsFlick.contentY + settingsFlick.height) {
|
|
settingsFlick.contentY = Math.min(
|
|
Math.max(0, settingsFlick.contentHeight - settingsFlick.height),
|
|
row.y + row.height - settingsFlick.height + Style.space(8))
|
|
}
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
id: settingsRepeater
|
|
model: root.settingsEntries
|
|
|
|
delegate: Column {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
readonly property bool cursored: index === root.settingsIndex
|
|
|
|
readonly property bool isGroup: modelData.kind === "group"
|
|
|
|
// This heading is the one the pinned bar is currently drawing.
|
|
// The bar stands in for it completely, so the row gives up its
|
|
// space rather than sitting there empty -- a transparent row
|
|
// left a heading-sized hole directly under the bar.
|
|
//
|
|
// Exactly one heading is ever in this state, so the content
|
|
// height does not change as the pinned section changes: the
|
|
// heading taking over collapses at the same moment the previous
|
|
// one is restored, and the view does not jump.
|
|
readonly property bool yieldsToBar: isGroup
|
|
&& Boolean(root.settingsStickyEntry)
|
|
&& root.settingsStickyEntry.group === modelData.group
|
|
|
|
// Breathing room above each heading, except the first.
|
|
Item {
|
|
visible: isGroup && index > 0 && !yieldsToBar
|
|
width: parent.width
|
|
height: visible ? Style.space(18) : 0
|
|
}
|
|
|
|
// A group heading is a row of its own rather than a label on
|
|
// the first setting under it: the pinned indicator reads
|
|
// delegate geometry to tell which section the view is inside,
|
|
// and a heading carried by another row has no position of its
|
|
// own to be found at.
|
|
Item {
|
|
visible: isGroup && !yieldsToBar
|
|
width: parent.width
|
|
height: visible ? Style.space(22) : 0
|
|
|
|
PanelSectionHeader {
|
|
textFormat: Text.PlainText
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: String(modelData.label || "").toUpperCase()
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
}
|
|
}
|
|
|
|
// A setting whose dependency is missing is shown but inert, with
|
|
// the reason stated rather than the control silently doing nothing.
|
|
readonly property bool blocked: !isGroup && root.settingBlocked(modelData)
|
|
|
|
Item {
|
|
visible: !isGroup
|
|
width: parent.width
|
|
implicitHeight: visible
|
|
? Math.max(settingTextCol.implicitHeight, settingControlRow.implicitHeight, Style.space(32))
|
|
: 0
|
|
|
|
// Keyboard cursor: a bar in the gutter, so the row it marks is
|
|
// unmistakable without recolouring the whole row.
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Style.space(3)
|
|
height: parent.height - Style.space(6)
|
|
radius: width / 2
|
|
color: Color.accent
|
|
visible: cursored
|
|
}
|
|
|
|
Column {
|
|
id: settingTextCol
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: cursored ? Style.space(10) : 0
|
|
anchors.right: settingControlRow.left
|
|
anchors.rightMargin: Style.space(12)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(2)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: modelData.label
|
|
color: blocked ? root.dim : root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
// `|| ""` because this binding also runs for the heading
|
|
// rows, which carry no description: an invisible item's
|
|
// bindings are evaluated all the same, and undefined
|
|
// reaches a QString property as a warning per frame.
|
|
text: blocked
|
|
? "Needs fingerprint setup -- see Dependencies below."
|
|
: (modelData.description || "")
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
|
|
Row {
|
|
id: settingControlRow
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(8)
|
|
|
|
ToggleSwitch {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: modelData.type === "bool"
|
|
checked: modelData.type === "bool" && root.settingValue(modelData)
|
|
interactive: !blocked
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
onToggled: {
|
|
if (blocked) return
|
|
// A PIN cannot simply be switched on: it has to be chosen,
|
|
// and encrypting it needs the master password.
|
|
if (modelData.action === "pin") {
|
|
if (checked) root.disablePinUnlock()
|
|
else root.beginPinSetup()
|
|
return
|
|
}
|
|
if (modelData.action === "fingerprint") {
|
|
if (checked) root.forgetFingerprintUnlock()
|
|
else root.beginFingerprintSetup()
|
|
return
|
|
}
|
|
root.writeSetting(modelData.key, !checked, "bool")
|
|
}
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: modelData.type === "int" && !!modelData.unit
|
|
text: modelData.unit || ""
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
|
|
NumberField {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: modelData.type === "int"
|
|
value: modelData.type === "int" ? root.settingValue(modelData) : 0
|
|
from: modelData.min || 0
|
|
to: modelData.max || 100
|
|
stepSize: modelData.step || 1
|
|
foreground: root.fg
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { root.writeSetting(modelData.key, v, "int") }
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: modelData.type === "int" && root.settingValue(modelData) === 0 && !!modelData.zeroLabel
|
|
text: (modelData.zeroLabel || "") + " -- this is disabled."
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
// The SSH agent has more to say than its four toggles: what the
|
|
// helper is doing, and whether the user's terminals will reach
|
|
// it. That block used to sit after all four groups, which was
|
|
// survivable while nothing folded -- now it would leave a
|
|
// collapsed SSH Agent section with its status still on screen,
|
|
// attached to nothing. It loads at the end of the group it
|
|
// belongs to, so folding the section folds the whole section.
|
|
//
|
|
// A Loader rather than a visible binding: this delegate is
|
|
// instantiated for every row, and only one of them wants it.
|
|
Loader {
|
|
width: parent.width
|
|
active: !isGroup && modelData.group === "sshAgent"
|
|
&& modelData.lastInGroup === true
|
|
visible: active
|
|
sourceComponent: SshAgentSettings { panel: root }
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { width: parent.width; height: Style.space(18) }
|
|
|
|
PanelSectionHeader {
|
|
textFormat: Text.PlainText
|
|
text: "MAINTENANCE"
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: "Dependencies"
|
|
iconText: ""
|
|
tooltipText: "Check the tools this plugin needs"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: {
|
|
root.setupDismissed = false
|
|
root.checkDependencies()
|
|
root.currentScreen = "setup"
|
|
}
|
|
}
|
|
|
|
Button {
|
|
visible: root.fingerprintStored
|
|
text: "Forget Fingerprint"
|
|
iconText: ""
|
|
tooltipText: "Remove the stored master password from the OS keyring"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.forgetFingerprintUnlock()
|
|
}
|
|
}
|
|
|
|
// Everything below this line destroys something. It was drawn in a
|
|
// row visually identical to the one above it, so "Dependencies" and
|
|
// "Remove Plugin Data" looked equally safe to press.
|
|
Item { width: parent.width; height: Style.space(18) }
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
PanelSectionHeader {
|
|
textFormat: Text.PlainText
|
|
text: "DANGER ZONE"
|
|
foreground: Color.urgent
|
|
fontFamily: root.fontFamily
|
|
}
|
|
|
|
// Its own row: this sits beside two buttons already, and a third
|
|
// one plus the two the confirmation adds overflow the panel width
|
|
// and elide their labels -- "Remove Plugin Data" reading as
|
|
// "Remove Plugin" is a considerably more alarming button.
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
visible: !root.pluginDataConfirmPending
|
|
text: "Remove Plugin Data"
|
|
iconText: ""
|
|
tooltipText: "Clear the keyring entries, learned suggestions and exported public keys this plugin stored"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
enabled: !root.pluginDataBusy
|
|
onClicked: root.beginPluginDataRemoval()
|
|
}
|
|
|
|
Button {
|
|
visible: root.pluginDataConfirmPending
|
|
text: "Remove Everything"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
enabled: !root.pluginDataBusy
|
|
onClicked: root.beginPluginDataRemoval()
|
|
}
|
|
|
|
Button {
|
|
visible: root.pluginDataConfirmPending
|
|
text: "Cancel"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.cancelPluginDataRemoval()
|
|
}
|
|
}
|
|
|
|
// Run this before removing the plugin: once the folder is gone
|
|
// there is no code left to do it, and `omarchy plugin remove` has
|
|
// no uninstall hook to call.
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
visible: root.pluginDataConfirmPending
|
|
text: "This clears the stored master password, learned suggestions and exported public keys. "
|
|
+ "Settings and your vault are untouched. It cannot be undone."
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
visible: root.pluginDataFlash !== ""
|
|
text: root.pluginDataFlash
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: "Saved to the plugin's entry in ~/.config/omarchy/shell.json via `omarchy bar set`."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// An SSH request waiting on an unlock. Shown above whatever unlock
|
|
// control the vault is configured for, so the reason for the prompt
|
|
// is visible without the unlock itself authorising anything.
|
|
Column {
|
|
// Stays up through the load as well as the unlock: the request is
|
|
// held across the vault read, so dropping the block the moment the
|
|
// vault unlocks would leave the user watching nothing for seconds.
|
|
visible: !root.sshAgentApprovalPopup && root.sshUnlockRequest !== null
|
|
&& (root.status === "locked" || root.sshAgentLoadActive)
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: " An SSH key is needed"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
|
|
SshCaption {
|
|
text: !root.sshUnlockRequest
|
|
? ""
|
|
: (root.sshUnlockRequest.keyName !== ""
|
|
? root.sshUnlockRequest.keyName + " · requested by "
|
|
+ root.sshUnlockRequest.processName
|
|
// An identity listing names no key: the client is asking
|
|
// which keys exist, and until the vault is open there is no
|
|
// answer to give.
|
|
: root.sshUnlockRequest.processName
|
|
+ " is asking which SSH keys are available")
|
|
color: root.fg
|
|
}
|
|
|
|
SshCaption {
|
|
text: root.sshAgentLoadActive
|
|
? Model.sshAgentLoadingNote()
|
|
: "Unlocking loads your keys. You will still be asked before anything is signed."
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
visible: !root.sshAgentLoadActive
|
|
text: "Not now (Esc)"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.denySshRequest()
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.sshPromptRemainingSec + "s left"
|
|
color: root.sshPromptRemainingSec <= 5 ? root.urgent : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
}
|
|
|
|
// SCREEN: SSH signing approval, in SshApprovalScreen.qml.
|
|
SshApprovalScreen {
|
|
panel: root
|
|
active: !root.sshAgentApprovalPopup && root.activeScreen === "sshApproval"
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 1: LOGIN VIEW (When unauthenticated)
|
|
// -------------------------------------------------------------------
|
|
Column {
|
|
visible: root.status === "unauthenticated" && root.activeScreen !== "settings" && root.activeScreen !== "setup" && root.activeScreen !== "pin" && root.activeScreen !== "fingerprint"
|
|
width: parent.width
|
|
spacing: Style.space(12)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Row {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: "Email & Password"
|
|
iconText: ""
|
|
selected: root.loginMethod === "email"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: {
|
|
root.invalidateEmailLoginPrewarm()
|
|
root.resetEmailLoginSecondFactor()
|
|
root.loginMethod = "email"
|
|
}
|
|
}
|
|
|
|
Button {
|
|
text: "API Key"
|
|
iconText: ""
|
|
selected: root.loginMethod === "apikey"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: {
|
|
root.invalidateEmailLoginPrewarm()
|
|
root.resetEmailLoginSecondFactor()
|
|
root.loginMethod = "apikey"
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
visible: root.loginMethod !== "email" || root.loginCredentialsStage
|
|
width: parent.width
|
|
spacing: Style.space(5)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "SERVER REGION"
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
|
|
Row {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
spacing: Style.space(6)
|
|
|
|
Button {
|
|
text: "US"
|
|
selected: root.loginServerRegion === "us"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.selectLoginServerRegion("us")
|
|
}
|
|
|
|
Button {
|
|
text: "EU"
|
|
selected: root.loginServerRegion === "eu"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.selectLoginServerRegion("eu")
|
|
}
|
|
|
|
Button {
|
|
text: "Custom"
|
|
selected: root.loginServerRegion === "custom"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.selectLoginServerRegion("custom")
|
|
}
|
|
}
|
|
|
|
TextField {
|
|
id: serverUrlField
|
|
visible: root.loginServerRegion === "custom"
|
|
width: parent.width
|
|
placeholderText: "https://vault.example.com"
|
|
text: root.loginServerUrl
|
|
onTextChanged: root.loginServerUrl = text
|
|
onTextEdited: {
|
|
root.loginServerUrl = text
|
|
root.resetEmailLoginSecondFactor()
|
|
root.invalidateEmailLoginPrewarm()
|
|
}
|
|
}
|
|
}
|
|
|
|
// METHOD A: Email & Password
|
|
Column {
|
|
visible: root.loginMethod === "email"
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
|
|
Column {
|
|
visible: root.loginCredentialsStage
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "EMAIL ADDRESS"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
id: emailField
|
|
width: parent.width
|
|
placeholderText: "you@example.com"
|
|
text: root.loginEmail
|
|
onTextChanged: root.loginEmail = text
|
|
onTextEdited: {
|
|
root.loginEmail = text
|
|
root.resetEmailLoginSecondFactor()
|
|
root.invalidateEmailLoginPrewarm()
|
|
}
|
|
onAccepted: loginPassField.forceActiveFocus()
|
|
}
|
|
}
|
|
|
|
Column {
|
|
visible: root.loginCredentialsStage
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "MASTER PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
TextField {
|
|
id: loginPassField
|
|
width: parent.width - eyeBtnLogin.width - Style.space(6)
|
|
placeholderText: "Master password..."
|
|
password: !eyeBtnLogin.revealed
|
|
text: root.loginPassword
|
|
onTextChanged: root.loginPassword = text
|
|
onTextEdited: {
|
|
root.loginPassword = text
|
|
if (root.show2faField) {
|
|
root.resetEmailLoginSecondFactor()
|
|
root.invalidateEmailLoginPrewarm()
|
|
}
|
|
}
|
|
onActiveFocusChanged: {
|
|
if (activeFocus) root.prepareEmailLogin()
|
|
}
|
|
onAccepted: root.show2faField ? code2faField.forceActiveFocus() : root.submitLogin()
|
|
}
|
|
Button {
|
|
id: eyeBtnLogin
|
|
property bool revealed: false
|
|
iconText: revealed ? "" : ""
|
|
tooltipText: revealed ? "Hide password" : "Show password"
|
|
fontFamily: root.fontFamily
|
|
onClicked: revealed = !revealed
|
|
}
|
|
}
|
|
}
|
|
|
|
// New-device verification. bw takes this code from a prompt and
|
|
// from nothing else, so answering it here is the difference
|
|
// between finishing the login in the panel and sending the user to
|
|
// a terminal to do it. See deviceVerificationLoginCommand().
|
|
Column {
|
|
visible: root.showDeviceCodeField
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "NEW DEVICE VERIFICATION"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: "Bitwarden has not seen this machine before and emailed a code to "
|
|
+ "your login address. This is asked once per device."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
TextField {
|
|
id: deviceCodeField
|
|
width: parent.width
|
|
placeholderText: "Code from your email..."
|
|
text: root.loginDeviceCode
|
|
onTextChanged: root.loginDeviceCode = text
|
|
onAccepted: root.submitDeviceVerification()
|
|
}
|
|
|
|
Button {
|
|
width: parent.width
|
|
text: root.isLoading ? "Verifying device..." : "Verify Device & Unlock"
|
|
iconText: root.isLoading ? "" : ""
|
|
iconSpinning: root.isLoading
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.isLoading
|
|
onClicked: root.submitDeviceVerification()
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Button {
|
|
text: "Back to credentials"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: {
|
|
root.errorMessage = ""
|
|
root.resetEmailLoginSecondFactor()
|
|
root.invalidateEmailLoginPrewarm()
|
|
Qt.callLater(function() { loginPassField.forceActiveFocus() })
|
|
}
|
|
}
|
|
|
|
// Still here, because bw in a real terminal can answer
|
|
// anything this path cannot.
|
|
Button {
|
|
text: "Use Terminal Instead"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.launchTerminalLogin()
|
|
}
|
|
}
|
|
}
|
|
|
|
// bw asks this question only when an account has more than one
|
|
// method it can use, and only a terminal ever got to see it. The
|
|
// pick is sent on its own, before any code is collected, so a
|
|
// wrong one costs a round trip rather than a typed code.
|
|
Column {
|
|
visible: root.show2faMethodPicker
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "TWO-STEP METHOD"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: "Which one do you use for this account? Bitwarden is asked for a code "
|
|
+ "only after you choose, and the choice is remembered for next time."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Repeater {
|
|
model: Model.twoFactorMethods()
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(2)
|
|
|
|
Button {
|
|
width: parent.width
|
|
text: modelData.label
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.isLoading
|
|
onClicked: root.chooseTwoFactorMethod(modelData.method)
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
width: parent.width
|
|
text: modelData.hint
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
text: "Back to credentials"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: {
|
|
root.errorMessage = ""
|
|
root.resetEmailLoginSecondFactor()
|
|
root.invalidateEmailLoginPrewarm()
|
|
Qt.callLater(function() { loginPassField.forceActiveFocus() })
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bitwarden tells us whether this account needs a second factor.
|
|
Column {
|
|
visible: root.show2faField
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: root.login2faMethodLabel
|
|
? "TWO-STEP CODE (" + root.login2faMethodLabel.toUpperCase() + ")"
|
|
: "TWO-STEP VERIFICATION CODE (2FA)"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
|
|
TextField {
|
|
id: code2faField
|
|
width: parent.width
|
|
placeholderText: "6-digit Authenticator / Email verification code..."
|
|
text: root.login2faCode
|
|
onTextChanged: {
|
|
root.login2faCode = text
|
|
root.invalidateEmailLoginPrewarm()
|
|
}
|
|
onAccepted: root.submitLogin()
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Button {
|
|
text: "Back to credentials"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: {
|
|
root.errorMessage = ""
|
|
root.resetEmailLoginSecondFactor()
|
|
root.invalidateEmailLoginPrewarm()
|
|
Qt.callLater(function() { loginPassField.forceActiveFocus() })
|
|
}
|
|
}
|
|
|
|
// The escape hatch from a remembered method. It is the only
|
|
// way back to the question once an account has answered it, so
|
|
// it stays available even when nothing has gone wrong yet.
|
|
Button {
|
|
text: "Change method"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: {
|
|
root.invalidateEmailLoginPrewarm()
|
|
root.reopenTwoFactorMethodPicker()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
// The picker stage submits by choosing, and the device stage has
|
|
// its own button, so this one belongs to the stages that share
|
|
// the ordinary login command.
|
|
visible: !root.show2faMethodPicker && !root.showDeviceCodeField
|
|
width: parent.width
|
|
text: root.emailLoginButtonText()
|
|
iconText: root.logoutCleanupFailed ? "" : ((root.logoutPending || root.isLoading) ? "" : "")
|
|
iconSpinning: !root.logoutCleanupFailed && (root.logoutPending || root.isLoading)
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: root.logoutCleanupFailed || (!root.logoutPending && !root.isLoading)
|
|
onClicked: root.logoutCleanupFailed ? root.retryLogoutCleanup() : root.submitLogin()
|
|
}
|
|
}
|
|
|
|
// METHOD B: API Key
|
|
Column {
|
|
visible: root.loginMethod === "apikey"
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "CLIENT ID"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
id: apiClientIdField
|
|
width: parent.width
|
|
placeholderText: "user.xxxxxxxx-xxxx-xxxx..."
|
|
text: root.loginClientId
|
|
onTextChanged: root.loginClientId = text
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "CLIENT SECRET"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
id: apiClientSecretField
|
|
width: parent.width
|
|
placeholderText: "Client secret string..."
|
|
password: true
|
|
text: root.loginClientSecret
|
|
onTextChanged: root.loginClientSecret = text
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "MASTER PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
id: apiMasterField
|
|
width: parent.width
|
|
placeholderText: "Master password to unlock vault..."
|
|
password: true
|
|
text: root.loginPassword
|
|
onTextChanged: root.loginPassword = text
|
|
onAccepted: root.submitLogin()
|
|
}
|
|
}
|
|
|
|
Button {
|
|
width: parent.width
|
|
text: root.logoutCleanupFailed ? "Retry Logout Cleanup" : (root.logoutPending ? "Finishing logout..." : (root.isLoading ? "Logging in..." : "Log In with API Key"))
|
|
iconText: root.logoutCleanupFailed ? "" : ((root.logoutPending || root.isLoading) ? "" : "")
|
|
iconSpinning: !root.logoutCleanupFailed && (root.logoutPending || root.isLoading)
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: root.logoutCleanupFailed || (!root.logoutPending && !root.isLoading)
|
|
onClicked: root.logoutCleanupFailed ? root.retryLogoutCleanup() : root.submitLogin()
|
|
}
|
|
}
|
|
|
|
// Normally the quieter of the two ways in. When Bitwarden has asked
|
|
// to verify this device it is the only one, so it stops being an
|
|
// aside and says what it is for.
|
|
Row {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
spacing: Style.space(6)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: root.loginDeviceVerification
|
|
? "Device verification needs a terminal:"
|
|
: "Prefer interactive TTY login?"
|
|
color: root.loginDeviceVerification ? Color.accent : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: root.loginDeviceVerification
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
Button {
|
|
text: root.loginDeviceVerification ? "Finish in Terminal" : "Launch Terminal"
|
|
iconText: ""
|
|
selected: root.loginDeviceVerification
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.launchTerminalLogin()
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 2: LOCKED VIEW (When authenticated, but vault locked)
|
|
// -------------------------------------------------------------------
|
|
Column {
|
|
visible: (root.status === "locked" || root.status === "checking")
|
|
&& root.currentScreen !== "settings" && root.currentScreen !== "setup" && root.currentScreen !== "pin" && root.currentScreen !== "fingerprint"
|
|
width: parent.width
|
|
spacing: Style.space(14)
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Item { height: Style.space(8); width: 1 }
|
|
|
|
Column {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
spacing: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.fingerprintScanning ? "" : ""
|
|
color: root.fingerprintScanning ? Color.accent : root.fg
|
|
opacity: 0.85
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.space(38)
|
|
|
|
SequentialAnimation on opacity {
|
|
running: root.fingerprintScanning
|
|
loops: Animation.Infinite
|
|
NumberAnimation { to: 0.35; duration: 700; easing.type: Easing.InOutQuad }
|
|
NumberAnimation { to: 0.95; duration: 700; easing.type: Easing.InOutQuad }
|
|
onStopped: parent.opacity = 0.85
|
|
}
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.fingerprintReady ? "Unlock Vault" : "Enter Master Password"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.userEmail !== ""
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.userEmail
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
}
|
|
|
|
// Fingerprint status / prompt
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.fingerprintMessage !== ""
|
|
width: parent.width
|
|
horizontalAlignment: Text.AlignHCenter
|
|
text: root.fingerprintMessage
|
|
color: root.fingerprintScanning ? Color.accent : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// Offered when fingerprint unlock is on but nothing is stored yet.
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.fingerprintUnlock && root.fingerprintAvailable && !root.fingerprintStored
|
|
width: parent.width
|
|
horizontalAlignment: Text.AlignHCenter
|
|
text: " Unlock once with your master password to enable fingerprint unlock."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// PIN entry, offered above the password field when one is set.
|
|
Column {
|
|
visible: root.pinReady
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Text { textFormat: Text.PlainText; text: "PIN"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
TextField {
|
|
id: pinField
|
|
width: parent.width - pinUnlockBtn.width - Style.space(8)
|
|
placeholderText: "Enter your PIN..."
|
|
password: true
|
|
text: root.pinEntry
|
|
onTextChanged: root.pinEntry = text.replace(/[^0-9]/g, "")
|
|
onAccepted: root.submitPinUnlock()
|
|
enabled: !root.pinBusy && !root.isUnlocking
|
|
}
|
|
|
|
Button {
|
|
id: pinUnlockBtn
|
|
text: root.pinBusy ? "Checking..." : "Unlock"
|
|
iconText: root.pinBusy ? "" : ""
|
|
iconSpinning: root.pinBusy
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.pinBusy && !root.isUnlocking
|
|
onClicked: root.submitPinUnlock()
|
|
}
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: root.pinError !== ""
|
|
width: parent.width
|
|
text: root.pinError
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "or use your master password below"
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
|
|
// A PIN was set but the vault rejected it -- surfaced even once
|
|
// pinReady has gone false, so the reason is not lost.
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: !root.pinReady && root.pinError !== ""
|
|
width: parent.width
|
|
horizontalAlignment: Text.AlignHCenter
|
|
text: root.pinError
|
|
color: root.urgent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
|
|
Button {
|
|
visible: root.fingerprintReady
|
|
width: parent.width
|
|
text: root.fingerprintScanning ? "Waiting for fingerprint..." : "Unlock with Fingerprint"
|
|
iconText: ""
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.isUnlocking && !root.fingerprintScanning
|
|
onClicked: root.startFingerprintUnlock()
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
TextField {
|
|
id: passField
|
|
width: parent.width - eyeBtnUnlock.width - Style.space(8)
|
|
placeholderText: "Master password..."
|
|
password: !eyeBtnUnlock.revealed
|
|
text: root.masterPassword
|
|
onTextChanged: root.masterPassword = text
|
|
onActiveFocusChanged: {
|
|
if (activeFocus) root.prepareUnlock()
|
|
}
|
|
onAccepted: root.unlockVault()
|
|
enabled: !root.isUnlocking
|
|
}
|
|
|
|
Button {
|
|
id: eyeBtnUnlock
|
|
property bool revealed: false
|
|
iconText: revealed ? "" : ""
|
|
tooltipText: revealed ? "Hide password" : "Show password"
|
|
fontFamily: root.fontFamily
|
|
onClicked: revealed = !revealed
|
|
}
|
|
}
|
|
|
|
Button {
|
|
width: parent.width
|
|
text: root.isUnlocking ? "Unlocking..." : "Unlock Vault"
|
|
iconText: root.isUnlocking ? "" : ""
|
|
iconSpinning: root.isUnlocking
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.isUnlocking
|
|
onClicked: root.unlockVault()
|
|
}
|
|
}
|
|
|
|
Row {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: "Switch / Log Out"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.logoutAccount()
|
|
}
|
|
|
|
Button {
|
|
visible: root.fingerprintStored
|
|
text: "Forget Fingerprint"
|
|
iconText: ""
|
|
tooltipText: "Remove the stored master password from the OS keyring"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.forgetFingerprintUnlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 3: UNLOCKED - ITEM LIST VIEW
|
|
// -------------------------------------------------------------------
|
|
Column {
|
|
visible: root.status === "unlocked" && root.activeScreen === "main"
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
// Search Field
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
TextField {
|
|
id: searchField
|
|
width: parent.width - (root.searchQuery ? clearSearchBtn.width + Style.space(6) : 0)
|
|
placeholderText: "Search items, usernames, URLs, public keys, fingerprints..."
|
|
text: root.searchQuery
|
|
onTextChanged: {
|
|
root.searchQuery = text
|
|
root.selectedIndex = 0
|
|
root.closeFilterGroup()
|
|
searchDebounceTimer.restart()
|
|
}
|
|
// Alt+letter runs the same shortcuts without leaving the box.
|
|
Keys.onPressed: function(event) {
|
|
if (!(event.modifiers & Qt.AltModifier)) return
|
|
if (!event.text) return
|
|
if (root.runAltShortcut(String(event.text).toLowerCase())) {
|
|
event.accepted = true
|
|
}
|
|
}
|
|
Keys.onDownPressed: {
|
|
keyCatcher.forceActiveFocus()
|
|
root.moveCursor(1)
|
|
}
|
|
Keys.onReturnPressed: {
|
|
var itm = root.getSelectedItem()
|
|
if (itm) root.handleSmartEnter(itm)
|
|
}
|
|
// Only while the search box is the screen. A hidden item keeps
|
|
// active focus in Qt, so without this guard the search field
|
|
// still owned Escape from behind the item form and closed the
|
|
// whole panel instead of cancelling the edit.
|
|
Keys.onEscapePressed: function(event) {
|
|
if (root.currentScreen !== "main") {
|
|
event.accepted = false // let it reach the panel's dispatch
|
|
return
|
|
}
|
|
if (text) text = ""
|
|
else root.handleEscape()
|
|
}
|
|
}
|
|
|
|
PanelActionButton {
|
|
id: clearSearchBtn
|
|
visible: root.searchQuery !== ""
|
|
iconText: ""
|
|
tooltipText: "Clear search"
|
|
fontFamily: root.fontFamily
|
|
onClicked: searchField.text = ""
|
|
}
|
|
}
|
|
|
|
// Contextual Suggestion Banner
|
|
BorderSurface {
|
|
visible: Boolean(root.suggestedItems.length > 0 && !root.suggestionsDismissed && root.searchQuery.trim() === "" && root.detectedContext && root.detectedContext.displayName)
|
|
width: parent.width
|
|
implicitHeight: Style.space(28)
|
|
radius: Style.cornerRadius
|
|
color: Style.selectedFillFor(root.fg, Color.accent)
|
|
borderSpec: Border.controlSpec("normal", Color.accent, Color.accent)
|
|
|
|
// A RowLayout, so the label can be told to take whatever the glyph
|
|
// and the dismiss button leave rather than a hand-measured slice of
|
|
// the banner. The name in it is a window title, so its length is
|
|
// not ours to predict.
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(8)
|
|
anchors.rightMargin: Style.space(6)
|
|
spacing: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
Layout.alignment: Qt.AlignVCenter
|
|
text: ""
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
Layout.alignment: Qt.AlignVCenter
|
|
Layout.fillWidth: true
|
|
text: "Suggested for " + (root.detectedContext ? root.detectedContext.displayName : "active window")
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
PanelActionButton {
|
|
Layout.alignment: Qt.AlignVCenter
|
|
iconText: ""
|
|
tooltipText: "Dismiss suggestion"
|
|
fontFamily: root.fontFamily
|
|
size: Style.space(18)
|
|
fontSize: Style.font.caption
|
|
onClicked: {
|
|
root.suggestionsDismissed = true
|
|
root.rebuildFilter()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
// Item List View (Fast Virtualized ListView with Delegate Recycling)
|
|
Item {
|
|
width: parent.width
|
|
height: Style.space(320)
|
|
|
|
ListView {
|
|
id: itemsListView
|
|
anchors.fill: parent
|
|
clip: true
|
|
model: root.filteredItems
|
|
spacing: Style.space(4)
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
reuseItems: true
|
|
currentIndex: root.selectedIndex
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: itemsListView }
|
|
|
|
delegate: BorderSurface {
|
|
id: itemRow
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property var itemData: modelData
|
|
readonly property bool isSelected: root.cursorActive && root.selectedIndex === index
|
|
readonly property bool isHovered: rowMouseArea.containsMouse
|
|
|
|
width: ListView.view.width - root.scrollGutter
|
|
implicitHeight: Style.space(46)
|
|
radius: Style.cornerRadius
|
|
color: isSelected
|
|
? Style.selectedFillFor(root.fg, Color.accent)
|
|
: (isHovered ? Style.hoverFillFor(root.fg, Color.accent) : "transparent")
|
|
borderSpec: isSelected
|
|
? Border.controlSpec("selected", root.fg, Color.accent)
|
|
: Border.none()
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.rightMargin: Style.space(8)
|
|
spacing: Style.space(10)
|
|
|
|
// Type Icon, or a spinner while the vault is being told about
|
|
// this row. The glyph is the row's identity, so the saving
|
|
// state borrows it rather than adding a second marker and
|
|
// reflowing everything beside it.
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: itemData.pending ? "" : Model.itemTypeGlyph(itemData.typeCode)
|
|
color: itemData.pending
|
|
? root.dim
|
|
: (itemData.favorite ? Color.accent : root.fg)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
width: Style.space(20)
|
|
// The glyph is narrower than the column it sits in, so
|
|
// without centring it the spin happens about the middle of
|
|
// the box and the icon orbits that point instead of
|
|
// turning on its own axis. Same shape the kit's own
|
|
// spinning button icon uses.
|
|
horizontalAlignment: Text.AlignHCenter
|
|
transformOrigin: Item.Center
|
|
|
|
RotationAnimation on rotation {
|
|
running: Boolean(itemData.pending)
|
|
loops: Animation.Infinite
|
|
from: 0
|
|
to: 360
|
|
duration: 900
|
|
}
|
|
}
|
|
|
|
// Labels (Title + Subtitle + Org Tag)
|
|
Column {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(20) - actionButtonsRow.implicitWidth - Style.space(28)
|
|
spacing: Style.space(1)
|
|
|
|
Row {
|
|
spacing: Style.space(4)
|
|
width: parent.width
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: itemData.name
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
font.bold: true
|
|
elide: Text.ElideRight
|
|
width: Math.min(implicitWidth, parent.width
|
|
- (itemData.favorite ? Style.space(16) : 0)
|
|
- (itemData.hasAttachments ? Style.space(18) : 0))
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: itemData.favorite
|
|
text: "★"
|
|
color: Color.accent
|
|
font.pixelSize: Style.font.bodySmall
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
// A paperclip is the whole badge: the file names live in
|
|
// the detail view, and the row only has to say they exist.
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: Boolean(itemData.hasAttachments)
|
|
text: ""
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
Row {
|
|
spacing: Style.space(4)
|
|
width: parent.width
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: Boolean(itemData.isSuggested)
|
|
text: root.learnedIds[itemData.id] ? " Suggested" : " Suggested"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: Boolean(itemData.organizationId)
|
|
text: " Org"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: rowSubtitle
|
|
text: itemData.subtitle || Model.itemTypeLabel(itemData.typeCode)
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
elide: Text.ElideRight
|
|
// Take only what is needed, so the folder tag that follows
|
|
// keeps its place instead of being pushed off the row.
|
|
width: Math.min(implicitWidth,
|
|
parent.width
|
|
- (itemData.organizationId ? Style.space(40) : 0)
|
|
- (itemData.isSuggested ? Style.space(75) : 0)
|
|
- (rowFolderTag.visible ? Style.space(90) : 0))
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: rowFolderTag
|
|
// Only worth showing when it is not already implied by the filter.
|
|
visible: Boolean(itemData.folderId) && root.selectedFolder === "all"
|
|
text: "· " + Model.folderName(root.folders, itemData.folderId)
|
|
color: Qt.darker(root.dim, 1.1)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
elide: Text.ElideRight
|
|
width: Math.min(implicitWidth, Style.space(90))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quick Action Buttons
|
|
Row {
|
|
id: actionButtonsRow
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(4)
|
|
visible: isSelected || isHovered
|
|
|
|
PanelActionButton {
|
|
visible: itemData.typeCode !== 5 && itemData.hasPassword
|
|
iconText: ""
|
|
tooltipText: "Copy password (Enter / y)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.handleSmartEnter(itemData)
|
|
}
|
|
|
|
PanelActionButton {
|
|
visible: itemData.typeCode !== 5 && itemData.username !== ""
|
|
iconText: ""
|
|
tooltipText: "Copy username (u)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.copyUsername(itemData)
|
|
}
|
|
|
|
PanelActionButton {
|
|
visible: itemData.typeCode !== 5 && itemData.hasTotp
|
|
iconText: ""
|
|
tooltipText: "Copy TOTP code (m)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.copyTotpCode(itemData)
|
|
}
|
|
|
|
PanelActionButton {
|
|
iconText: ""
|
|
tooltipText: itemData.typeCode === 5 ? "View public key" : "View / Edit item (e)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.openDetail(itemData)
|
|
}
|
|
|
|
PanelActionButton {
|
|
visible: itemData.typeCode !== 5 && itemData.uris && itemData.uris.length > 0
|
|
iconText: ""
|
|
tooltipText: "Open URL (w)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.openUrl(itemData.uris[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: rowMouseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
root.cursorActive = true
|
|
root.openFilterGroup = ""
|
|
root.selectedIndex = index
|
|
root.openDetail(itemData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Empty state overlay
|
|
Item {
|
|
visible: root.filteredItems.length === 0
|
|
anchors.fill: parent
|
|
|
|
Column {
|
|
anchors.centerIn: parent
|
|
spacing: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.isLoading && root.items.length === 0 ? "" : (root.items.length === 0 ? "" : "")
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.space(36)
|
|
RotationAnimation on rotation {
|
|
running: root.isLoading && root.items.length === 0
|
|
from: 0
|
|
to: 360
|
|
duration: 900
|
|
loops: Animation.Infinite
|
|
}
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.isLoading && root.items.length === 0
|
|
? "Loading items..."
|
|
: root.emptyListMessage()
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Bottom filter bar: Folders / Vaults / Types
|
|
// -----------------------------------------------------------------
|
|
// Three horizontally scrolling strips were easy to miss and awkward
|
|
// to reach. One collapsed row instead, each opening a vertical list
|
|
// in place; the item list gives back exactly the height the open
|
|
// list takes, so the panel does not jump.
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
// The open group's options: a pinned header naming the group, then up
|
|
// to five rows with the rest scrolling underneath it.
|
|
Column {
|
|
id: filterDrawer
|
|
width: parent.width
|
|
height: root.filterDrawerHeight
|
|
visible: height > 0
|
|
clip: true
|
|
spacing: 0
|
|
|
|
Behavior on height { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } }
|
|
|
|
// Pinned header -- stays put while the options scroll.
|
|
Row {
|
|
width: parent.width
|
|
height: Style.space(30)
|
|
spacing: Style.space(8)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.openFilterGroup === "folders" ? ""
|
|
: root.openFilterGroup === "organizations" ? ""
|
|
: ""
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.openFilterGroup === "folders" ? "FOLDERS"
|
|
: root.openFilterGroup === "organizations" ? "ORGANIZATIONS"
|
|
: "TYPES"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
|
|
Item { width: parent.width - Style.space(190); height: 1 }
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.currentFilterOptions.length > root.currentFilterVisibleRows
|
|
text: root.currentFilterOptions.length + " total"
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
|
|
Flickable {
|
|
id: filterOptionsList
|
|
width: parent.width
|
|
height: Math.min(root.currentFilterVisibleRows, root.currentFilterOptions.length) * root.filterRowHeight
|
|
contentWidth: width
|
|
contentHeight: filterOptionsCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: filterOptionsList }
|
|
|
|
// Keep the keyboard cursor in view when it runs past the fold.
|
|
function revealCursor() {
|
|
var y = root.filterOptionIndex * root.filterRowHeight
|
|
if (y < contentY) contentY = y
|
|
else if (y + root.filterRowHeight > contentY + height) {
|
|
contentY = y + root.filterRowHeight - height
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: root
|
|
function onFilterOptionIndexChanged() { filterOptionsList.revealCursor() }
|
|
}
|
|
|
|
Column {
|
|
id: filterOptionsCol
|
|
width: filterOptionsList.width - root.scrollGutter
|
|
spacing: 0
|
|
|
|
Repeater {
|
|
model: root.currentFilterOptions
|
|
|
|
delegate: BorderSurface {
|
|
required property var modelData
|
|
required property int index
|
|
width: filterOptionsCol.width
|
|
implicitHeight: root.filterRowHeight
|
|
radius: Style.cornerRadius
|
|
readonly property bool cursored: index === root.filterOptionIndex
|
|
color: modelData.active ? Style.selectedFillFor(root.fg, Color.accent)
|
|
: (cursored || optionMouse.containsMouse) ? Style.hoverFillFor(root.fg, Color.accent)
|
|
: "transparent"
|
|
borderSpec: Border.surfaceSpec("menu", "border",
|
|
(modelData.active || cursored) ? Color.accent : "transparent",
|
|
(modelData.active || cursored) ? 1 : 0)
|
|
|
|
MouseArea {
|
|
id: optionMouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onEntered: root.filterOptionIndex = index
|
|
onClicked: root.applyFilterOption(root.openFilterGroup, modelData.id)
|
|
}
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.rightMargin: Style.space(10)
|
|
spacing: Style.space(8)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.icon
|
|
color: modelData.active ? Color.accent : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(50)
|
|
text: modelData.label
|
|
color: modelData.active ? Color.accent : root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
font.bold: modelData.active
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: modelData.active
|
|
text: ""
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The three collapsed buttons. Identical shape, so none reads as a
|
|
// different kind of control from the others -- which is why they are
|
|
// one component declared three times rather than three buttons.
|
|
//
|
|
// A Flow rather than a Row. Two of the three carry a vault name, so
|
|
// their width is whatever the user typed, and a Row can neither
|
|
// shrink a child nor start a second line -- it lays the overflow out
|
|
// past the panel edge, off both sides at once because the group is
|
|
// centred. `width` is the group's own combined width while the three
|
|
// share a line, which is what keeps it centred, and the panel's when
|
|
// they cannot. It reads the buttons' implicitWidth, never their
|
|
// width, so the layout's width never depends on its own result.
|
|
//
|
|
// This is what pays for the labels naming their filters: the three
|
|
// fit one line in the ordinary case and take a second when they do
|
|
// not, instead of the names having to be dropped to guarantee one.
|
|
Flow {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
spacing: Style.space(6)
|
|
readonly property real naturalWidth: folderFilterButton.implicitWidth
|
|
+ organizationFilterButton.implicitWidth
|
|
+ typeFilterButton.implicitWidth
|
|
+ spacing * 2
|
|
width: Math.min(parent.width, naturalWidth)
|
|
|
|
VaultFilterButton {
|
|
id: folderFilterButton
|
|
group: "folders"
|
|
glyph: ""
|
|
name: "Folders"
|
|
value: root.folderFilterLabel()
|
|
shortcut: "f"
|
|
}
|
|
|
|
VaultFilterButton {
|
|
id: organizationFilterButton
|
|
group: "organizations"
|
|
glyph: ""
|
|
name: "Organizations"
|
|
value: root.organizationFilterLabel()
|
|
shortcut: "o"
|
|
}
|
|
|
|
VaultFilterButton {
|
|
id: typeFilterButton
|
|
group: "types"
|
|
glyph: ""
|
|
name: "Types"
|
|
value: root.typeFilterLabel()
|
|
shortcut: "t"
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 4: UNLOCKED - ITEM DETAIL VIEW
|
|
// -------------------------------------------------------------------
|
|
Column {
|
|
visible: root.status === "unlocked" && root.activeScreen === "detail"
|
|
width: parent.width
|
|
spacing: Style.space(12)
|
|
|
|
// Back Navigation & Action Header
|
|
//
|
|
// A Flow, not a Row, because how many buttons are here is decided at
|
|
// runtime: the suggestion button appears only on a recognised window,
|
|
// and it is the widest of the four. A Row cannot shrink a child or
|
|
// start a second line, so the fourth button was laid out past the
|
|
// panel's right edge and Delete simply left the panel -- worse still
|
|
// only once the suggestion was pinned, because "Suggested here" is a
|
|
// character wider than "Suggest here" and that character was the one
|
|
// that overflowed. The panel is also narrower than its 450 ask on a
|
|
// small screen (see fittedContentWidth), so no arrangement of fixed
|
|
// labels is safe; wrapping is. Everything still fits on one line at
|
|
// the default size, so this only shows itself when it has to.
|
|
Flow {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
// "Back to list" spelled out cost more width than the row could
|
|
// spare, and the Sends screen already says just "Back (Esc)".
|
|
text: "Back (Esc)"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.currentScreen = "main"
|
|
}
|
|
|
|
Button {
|
|
visible: Boolean(root.detectedContext && root.detectedContext.displayName && root.detailItem && root.detailItem.typeCode !== 5)
|
|
readonly property bool pinned: Boolean(root.detailItem
|
|
&& Model.isAssociated(root.associations, root.detectedContext, root.detailItem.id))
|
|
text: pinned ? "Suggested here" : "Suggest here"
|
|
iconText: pinned ? "" : ""
|
|
selected: pinned
|
|
accent: Color.accent
|
|
// The window title is no more trustworthy than a vault value,
|
|
// and the kit renders tooltips with an auto-detecting Text.
|
|
tooltipText: Model.plainLabel((pinned ? "Stop suggesting this for " : "Always suggest this for ")
|
|
+ (root.detectedContext ? root.detectedContext.displayName : ""))
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.toggleAssociation(root.detailItem)
|
|
}
|
|
|
|
Button {
|
|
visible: Boolean(root.detailItem && root.detailItem.typeCode !== 5)
|
|
text: "Edit"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: if (root.detailItem) root.startEditItem(root.detailItem)
|
|
}
|
|
|
|
Button {
|
|
visible: Boolean(root.detailItem && root.detailItem.typeCode !== 5)
|
|
text: "Delete"
|
|
iconText: ""
|
|
accent: Color.urgent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.showDeleteConfirm = true
|
|
}
|
|
}
|
|
|
|
// Delete Confirmation Banner
|
|
BorderSurface {
|
|
visible: root.showDeleteConfirm
|
|
width: parent.width
|
|
implicitHeight: Style.space(64)
|
|
color: Util.alpha(Color.urgent, 0.15)
|
|
radius: Style.cornerRadius
|
|
borderSpec: Border.surfaceSpec("menu", "border", Color.urgent, 1)
|
|
|
|
Row {
|
|
anchors.centerIn: parent
|
|
spacing: Style.space(12)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "Permanently delete this item?"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
font.bold: true
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Button {
|
|
text: "Confirm Delete"
|
|
iconText: ""
|
|
selected: true
|
|
accent: Color.urgent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.deleteCurrentItem()
|
|
}
|
|
|
|
Button {
|
|
text: "Cancel"
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.showDeleteConfirm = false
|
|
}
|
|
}
|
|
}
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Flickable {
|
|
id: detailFlickable
|
|
width: parent.width
|
|
height: Math.min(Style.space(380), detailContentColumn.implicitHeight)
|
|
contentWidth: width
|
|
contentHeight: detailContentColumn.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: detailFlickable }
|
|
|
|
Column {
|
|
id: detailContentColumn
|
|
width: detailFlickable.width - root.scrollGutter
|
|
spacing: Style.space(12)
|
|
|
|
// Item Header
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.detailItem ? Model.itemTypeGlyph(root.detailItem.typeCode) : ""
|
|
color: (root.detailItem && root.detailItem.favorite) ? Color.accent : root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.space(26)
|
|
}
|
|
|
|
Column {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - Style.space(40)
|
|
spacing: Style.space(2)
|
|
|
|
Row {
|
|
spacing: Style.space(6)
|
|
width: parent.width
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: root.detailItem ? root.detailItem.name : "Loading..."
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
font.bold: true
|
|
elide: Text.ElideRight
|
|
width: Math.min(implicitWidth, parent.width - Style.space(20))
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: Boolean(root.detailItem && root.detailItem.favorite)
|
|
text: "★"
|
|
color: Color.accent
|
|
font.pixelSize: Style.font.body
|
|
}
|
|
}
|
|
|
|
Row {
|
|
spacing: Style.space(6)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: root.detailItem ? Model.itemTypeLabel(root.detailItem.typeCode) : ""
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: Boolean(root.detailItem && root.detailItem.organizationId)
|
|
text: "• Shared Organization"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: Boolean(root.detailItem && root.detailItem.folderId)
|
|
text: root.detailItem
|
|
? "• " + Model.folderName(root.folders, root.detailItem.folderId)
|
|
: ""
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIELD: Public SSH key (type 5 is deliberately read-only)
|
|
Column {
|
|
visible: Boolean(root.detailItem && root.detailItem.typeCode === 5)
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
PanelSectionHeader { text: "PUBLIC KEY" }
|
|
BorderSurface {
|
|
width: parent.width
|
|
implicitHeight: Math.max(Style.space(54), sshPublicKeyText.implicitHeight + Style.space(20))
|
|
radius: Style.cornerRadius
|
|
color: Style.hoverFillFor(root.fg, Color.accent)
|
|
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: sshPublicKeyText
|
|
anchors.fill: parent
|
|
anchors.margins: Style.space(10)
|
|
text: root.detailItem ? (root.detailItem.publicKey || "No public key") : ""
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WrapAnywhere
|
|
}
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: Boolean(root.detailItem && root.detailItem.fingerprint)
|
|
text: "Fingerprint: " + (root.detailItem ? root.detailItem.fingerprint : "")
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WrapAnywhere
|
|
}
|
|
}
|
|
|
|
// FIELD: Username
|
|
DetailField {
|
|
visible: root.detailIsLoginLike && Boolean(root.detailItem) && root.detailItem.username !== ""
|
|
label: "Username / Email"
|
|
copyLabel: "Username"
|
|
shortcutHint: "u"
|
|
copyIcon: ""
|
|
value: root.detailItem ? root.detailItem.username : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCopyRequested: root.copyToClipboard(root.detailItem ? root.detailItem.username : "", "Username")
|
|
}
|
|
|
|
// FIELD: Password
|
|
Column {
|
|
visible: root.detailIsLoginLike && Boolean(root.detailItem) && (root.detailPassword !== "" || root.detailItem.hasPassword)
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
|
|
PanelSectionHeader { text: "PASSWORD" }
|
|
|
|
BorderSurface {
|
|
width: parent.width
|
|
implicitHeight: Style.space(34)
|
|
radius: Style.cornerRadius
|
|
color: Style.hoverFillFor(root.fg, Color.accent)
|
|
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.rightMargin: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.isFieldRevealed("password")
|
|
? root.detailPassword : Model.maskString(root.detailPassword || "password")
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
elide: Text.ElideRight
|
|
width: parent.width - passActions.width - Style.space(10)
|
|
}
|
|
|
|
Row {
|
|
id: passActions
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(4)
|
|
|
|
PanelActionButton {
|
|
iconText: root.isFieldRevealed("password") ? "" : ""
|
|
tooltipText: root.isFieldRevealed("password") ? "Hide password (v)" : "Reveal password (v)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.toggleFieldReveal("password")
|
|
}
|
|
|
|
PanelActionButton {
|
|
iconText: ""
|
|
tooltipText: "Copy password (y / Enter)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.copyToClipboard(root.detailPassword, "Password")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIELD: TOTP (2FA Code)
|
|
Column {
|
|
visible: root.detailIsLoginLike && Boolean(root.detailItem) && root.detailItem.hasTotp
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
|
|
RowLayout {
|
|
width: parent.width
|
|
PanelSectionHeader { text: "VERIFICATION CODE (TOTP)" }
|
|
Item { Layout.fillWidth: true }
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: root.totpSecRemaining + "s"
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
}
|
|
|
|
BorderSurface {
|
|
width: parent.width
|
|
implicitHeight: Style.space(44)
|
|
radius: Style.cornerRadius
|
|
color: Style.hoverFillFor(root.fg, Color.accent)
|
|
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
|
|
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.bottom: parent.bottom
|
|
height: Style.space(3)
|
|
radius: Style.cornerRadius
|
|
width: parent.width * (root.totpSecRemaining / 30.0)
|
|
color: Color.accent
|
|
}
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(12)
|
|
anchors.rightMargin: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.liveTotp ? (root.liveTotp.length === 6 ? root.liveTotp.slice(0, 3) + " " + root.liveTotp.slice(3) : root.liveTotp) : "Loading..."
|
|
color: Color.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
font.bold: true
|
|
font.letterSpacing: 2.0
|
|
width: parent.width - copyTotpBtn.width - Style.space(10)
|
|
}
|
|
|
|
PanelActionButton {
|
|
id: copyTotpBtn
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
tooltipText: "Copy TOTP code (m)"
|
|
fontFamily: root.fontFamily
|
|
enabled: root.liveTotp !== ""
|
|
onClicked: root.copyToClipboard(root.liveTotp, "TOTP code")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIELD: Website / URIs
|
|
Column {
|
|
visible: root.detailIsLoginLike && Boolean(root.detailItem) && root.detailItem.uris && root.detailItem.uris.length > 0
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
|
|
PanelSectionHeader { text: "WEBSITE" }
|
|
|
|
Repeater {
|
|
model: root.detailItem ? root.detailItem.uris : []
|
|
delegate: BorderSurface {
|
|
width: detailContentColumn.width
|
|
implicitHeight: Style.space(34)
|
|
radius: Style.cornerRadius
|
|
color: Style.hoverFillFor(root.fg, Color.accent)
|
|
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.rightMargin: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
elide: Text.ElideRight
|
|
width: parent.width - openUriBtn.width - Style.space(10)
|
|
}
|
|
|
|
PanelActionButton {
|
|
id: openUriBtn
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
tooltipText: "Open in browser (w)"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.openUrl(modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIELD: Attachments
|
|
//
|
|
// The metadata came down with the item, so the list is here the
|
|
// moment the detail view opens; only the bytes cost a CLI call,
|
|
// and only for the file the user actually asks for.
|
|
//
|
|
// Above NOTES on purpose. Notes is the one section with no height
|
|
// of its own -- it grows with the text -- and this Flickable is
|
|
// capped, so anything after it starts below the fold on exactly
|
|
// the items whose note is long. A secure note with a file
|
|
// attached is that case, and the files were the thing being
|
|
// pushed out of sight.
|
|
Column {
|
|
visible: Boolean(root.detailItem && root.detailItem.typeCode !== 5 && root.detailItem.hasAttachments)
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
|
|
RowLayout {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
PanelSectionHeader { text: "ATTACHMENTS" }
|
|
Item { Layout.fillWidth: true }
|
|
PanelActionButton {
|
|
visible: Boolean(root.detailItem && root.detailItem.attachments
|
|
&& root.detailItem.attachments.length > 1)
|
|
iconText: ""
|
|
tooltipText: "Save all attachments (a)"
|
|
size: Style.space(20)
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.saveAllAttachments()
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
model: root.detailItem ? root.detailItem.attachments : []
|
|
delegate: BorderSurface {
|
|
readonly property string savedPath: root.attachmentSavedPath(modelData.id)
|
|
readonly property bool busy: root.attachmentBusyId === modelData.id
|
|
readonly property bool queued: root.isAttachmentQueued(modelData.id)
|
|
|
|
width: detailContentColumn.width
|
|
implicitHeight: Style.space(34)
|
|
radius: Style.cornerRadius
|
|
color: Style.hoverFillFor(root.fg, Color.accent)
|
|
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(10)
|
|
anchors.rightMargin: Style.space(6)
|
|
spacing: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: attachmentGlyph
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: ""
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
|
|
// The file name is vault text, so it is drawn as text.
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.fileName
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
elide: Text.ElideRight
|
|
width: Math.max(0, parent.width - attachmentGlyph.width
|
|
- attachmentStatus.width - attachmentActions.width - Style.space(34))
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: attachmentStatus
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: busy ? "Saving..." : queued ? "Queued" : modelData.sizeName
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
|
|
Row {
|
|
id: attachmentActions
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(2)
|
|
|
|
PanelActionButton {
|
|
visible: savedPath === ""
|
|
enabled: !busy && !queued
|
|
iconText: ""
|
|
tooltipText: "Save to your download folder"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.queueAttachment(modelData)
|
|
}
|
|
|
|
PanelActionButton {
|
|
visible: savedPath !== ""
|
|
iconText: ""
|
|
tooltipText: "Open the saved file"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.openSavedAttachment(modelData.id)
|
|
}
|
|
|
|
PanelActionButton {
|
|
visible: savedPath !== ""
|
|
iconText: ""
|
|
// The path is ours -- a download directory plus a
|
|
// sanitised name -- but it is still drawn as text.
|
|
tooltipText: Model.plainLabel("Show in " + Model.parentDirectory(savedPath))
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.revealSavedAttachment(modelData.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIELD: Notes
|
|
Column {
|
|
visible: Boolean(root.detailItem && root.detailItem.typeCode !== 5 && root.detailItem.notes !== "")
|
|
width: parent.width
|
|
spacing: Style.space(4)
|
|
|
|
RowLayout {
|
|
width: parent.width
|
|
PanelSectionHeader { text: "NOTES" }
|
|
Item { Layout.fillWidth: true }
|
|
PanelActionButton {
|
|
iconText: ""
|
|
tooltipText: "Copy notes"
|
|
size: Style.space(20)
|
|
fontFamily: root.fontFamily
|
|
onClicked: if (root.detailItem) root.copyToClipboard(root.detailItem.notes, "Notes")
|
|
}
|
|
}
|
|
|
|
BorderSurface {
|
|
width: parent.width
|
|
implicitHeight: notesText.implicitHeight + Style.space(16)
|
|
radius: Style.cornerRadius
|
|
color: Style.hoverFillFor(root.fg, Color.accent)
|
|
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: notesText
|
|
anchors.fill: parent
|
|
anchors.margins: Style.space(10)
|
|
text: root.detailItem ? root.detailItem.notes : ""
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.Wrap
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------
|
|
// FIELDS: Card
|
|
// -----------------------------------------------------------
|
|
// Expiry is one field rather than two. It is written, read and
|
|
// typed as a unit, and a vault that shows "04" above "2030" in
|
|
// two labelled boxes is describing its storage rather than the
|
|
// card in your hand.
|
|
DetailField {
|
|
visible: root.detailIsCard
|
|
label: "Cardholder Name"
|
|
value: root.detailCard ? root.detailCard.cardholderName : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCopyRequested: root.copyToClipboard(root.detailCard ? root.detailCard.cardholderName : "", "Cardholder name")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsCard
|
|
label: "Brand"
|
|
value: root.detailCard ? root.detailCard.brand : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCopyRequested: root.copyToClipboard(root.detailCard ? root.detailCard.brand : "", "Brand")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsCard
|
|
label: "Card Number"
|
|
copyLabel: "Card number"
|
|
shortcutHint: "n / Enter"
|
|
revealHint: "v"
|
|
sensitive: true
|
|
revealed: root.isFieldRevealed("cardNumber")
|
|
value: root.detailCard ? root.detailCard.number : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onRevealToggled: root.toggleFieldReveal("cardNumber")
|
|
onCopyRequested: root.copyToClipboard(root.detailCard ? root.detailCard.number : "", "Card number")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsCard
|
|
label: "Expires"
|
|
value: root.detailCardExpiry
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCopyRequested: root.copyToClipboard(root.detailCardExpiry, "Expiry")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsCard
|
|
label: "Security Code"
|
|
copyLabel: "Security code"
|
|
shortcutHint: "k"
|
|
sensitive: true
|
|
revealed: root.isFieldRevealed("cardCode")
|
|
value: root.detailCard ? root.detailCard.code : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onRevealToggled: root.toggleFieldReveal("cardCode")
|
|
onCopyRequested: root.copyToClipboard(root.detailCard ? root.detailCard.code : "", "Security code")
|
|
}
|
|
|
|
// -----------------------------------------------------------
|
|
// FIELDS: Identity
|
|
// -----------------------------------------------------------
|
|
// Every field an identity can carry is declared; DetailField
|
|
// hides the empty ones. Most identities fill in a handful, and
|
|
// the alternative -- deciding here which are worth drawing --
|
|
// is how the useful one for somebody ends up missing.
|
|
DetailField {
|
|
visible: root.detailIsIdentity
|
|
label: "Name"
|
|
value: root.detailIdentityName
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCopyRequested: root.copyToClipboard(root.detailIdentityName, "Name")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsIdentity
|
|
label: "Username"
|
|
shortcutHint: "u"
|
|
value: root.detailIdentity ? root.detailIdentity.username : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.username : "", "Username")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsIdentity
|
|
label: "Company"
|
|
value: root.detailIdentity ? root.detailIdentity.company : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.company : "", "Company")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsIdentity
|
|
label: "Email"
|
|
shortcutHint: "c"
|
|
value: root.detailIdentity ? root.detailIdentity.email : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.email : "", "Email")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsIdentity
|
|
label: "Phone"
|
|
value: root.detailIdentity ? root.detailIdentity.phone : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.phone : "", "Phone")
|
|
}
|
|
|
|
// The three an identity item usually exists to hold. Masked for
|
|
// the same reason a password is: a shoulder is enough to lose
|
|
// them, and unlike a password they cannot be rotated.
|
|
DetailField {
|
|
visible: root.detailIsIdentity
|
|
label: "Social Security Number"
|
|
copyLabel: "SSN"
|
|
sensitive: true
|
|
revealed: root.isFieldRevealed("ssn")
|
|
value: root.detailIdentity ? root.detailIdentity.ssn : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onRevealToggled: root.toggleFieldReveal("ssn")
|
|
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.ssn : "", "SSN")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsIdentity
|
|
label: "Passport Number"
|
|
copyLabel: "Passport number"
|
|
sensitive: true
|
|
revealed: root.isFieldRevealed("passport")
|
|
value: root.detailIdentity ? root.detailIdentity.passportNumber : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onRevealToggled: root.toggleFieldReveal("passport")
|
|
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.passportNumber : "", "Passport number")
|
|
}
|
|
|
|
DetailField {
|
|
visible: root.detailIsIdentity
|
|
label: "Licence Number"
|
|
copyLabel: "Licence number"
|
|
sensitive: true
|
|
revealed: root.isFieldRevealed("licence")
|
|
value: root.detailIdentity ? root.detailIdentity.licenseNumber : ""
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
onRevealToggled: root.toggleFieldReveal("licence")
|
|
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.licenseNumber : "", "Licence number")
|
|
}
|
|
|
|
PanelSectionHeader {
|
|
visible: root.detailIsIdentity && root.detailIdentityAddress !== ""
|
|
text: "ADDRESS"
|
|
}
|
|
|
|
// One block, not seven rows. An address is copied as an address.
|
|
BorderSurface {
|
|
visible: root.detailIsIdentity && root.detailIdentityAddress !== ""
|
|
width: parent.width
|
|
implicitHeight: addressText.implicitHeight + Style.space(16)
|
|
radius: Style.cornerRadius
|
|
color: Style.hoverFillFor(root.fg, Color.accent)
|
|
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.margins: Style.space(8)
|
|
spacing: Style.space(6)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
id: addressText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.detailIdentityAddress
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
wrapMode: Text.Wrap
|
|
width: parent.width - copyAddressBtn.width - Style.space(10)
|
|
}
|
|
|
|
PanelActionButton {
|
|
id: copyAddressBtn
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconText: ""
|
|
tooltipText: "Copy address"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.copyToClipboard(root.detailIdentityAddress, "Address")
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// SCREEN 5: ADD / EDIT ITEM FORM VIEW
|
|
// -------------------------------------------------------------------
|
|
Column {
|
|
visible: root.status === "unlocked" && root.activeScreen === "edit"
|
|
width: parent.width
|
|
spacing: Style.space(10)
|
|
|
|
RowLayout {
|
|
width: parent.width
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: "Cancel (Esc)"
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.currentScreen = root.formIsEditing ? "detail" : "main"
|
|
}
|
|
|
|
Item { Layout.fillWidth: true }
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
Layout.alignment: Qt.AlignVCenter
|
|
text: root.formIsEditing ? "Edit Item" : "New Vault Item"
|
|
color: root.fg
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
font.bold: true
|
|
}
|
|
}
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Flickable {
|
|
id: editFlickable
|
|
width: parent.width
|
|
height: Math.min(Style.space(420), editFormCol.implicitHeight)
|
|
contentWidth: width
|
|
contentHeight: editFormCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: editFlickable }
|
|
|
|
Column {
|
|
id: editFormCol
|
|
width: editFlickable.width - root.scrollGutter
|
|
spacing: Style.space(10)
|
|
|
|
// Item Type Selector (only for new items)
|
|
Row {
|
|
visible: !root.formIsEditing
|
|
spacing: Style.space(8)
|
|
|
|
Button {
|
|
text: "Login"
|
|
iconText: ""
|
|
selected: root.formTypeCode === 1
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.formTypeCode = 1
|
|
}
|
|
|
|
Button {
|
|
text: "Secure Note"
|
|
iconText: ""
|
|
selected: root.formTypeCode === 2
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.formTypeCode = 2
|
|
}
|
|
Button {
|
|
text: "Card"
|
|
iconText: ""
|
|
selected: root.formTypeCode === 3
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.formTypeCode = 3
|
|
}
|
|
|
|
Button {
|
|
text: "Identity"
|
|
iconText: ""
|
|
selected: root.formTypeCode === 4
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.formTypeCode = 4
|
|
}
|
|
}
|
|
|
|
// FIELD: Title / Name
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "TITLE / NAME *"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
id: formNameField
|
|
width: parent.width
|
|
placeholderText: "e.g. GitHub, Google, Work Server..."
|
|
text: root.formName
|
|
onTextChanged: root.formName = text
|
|
}
|
|
}
|
|
|
|
// FIELD: Folder -- expandable list rather than a wrapping row of
|
|
// buttons, which grew unreadable once a vault had more than a few.
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "FOLDER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
|
|
Button {
|
|
width: parent.width
|
|
text: Model.plainLabel(root.formFolderLabel())
|
|
iconText: root.formPicker === "folder" ? "\u{F0140}" : "\u{F024B}"
|
|
selected: root.formPicker === "folder"
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
leftAlign: true
|
|
onClicked: root.toggleFormPicker("folder")
|
|
}
|
|
|
|
Flickable {
|
|
id: folderPickList
|
|
visible: root.formPicker === "folder"
|
|
width: parent.width
|
|
height: visible ? Math.min(Style.space(150), folderPickCol.implicitHeight) : 0
|
|
contentWidth: width
|
|
contentHeight: folderPickCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: folderPickList }
|
|
|
|
Column {
|
|
id: folderPickCol
|
|
width: folderPickList.width - root.scrollGutter
|
|
spacing: Style.space(2)
|
|
|
|
FormPickerRow {
|
|
width: parent.width
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
label: "No Folder"
|
|
glyph: "\u{F0256}"
|
|
picked: !root.formFolderId
|
|
onActivated: root.setFormFolder("")
|
|
}
|
|
|
|
Repeater {
|
|
model: root.folders
|
|
delegate: FormPickerRow {
|
|
required property var modelData
|
|
width: parent.width
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
label: modelData.name
|
|
glyph: "\u{F024B}"
|
|
picked: root.formFolderId === modelData.id
|
|
onActivated: root.setFormFolder(modelData.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Creating a folder here saves leaving the form to make one.
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
TextField {
|
|
width: parent.width - Style.space(96)
|
|
placeholderText: "New folder name..."
|
|
text: root.newFolderName
|
|
onTextChanged: root.newFolderName = text
|
|
onAccepted: root.submitNewFolder()
|
|
enabled: !root.creatingFolder
|
|
}
|
|
|
|
Button {
|
|
text: root.creatingFolder ? "Adding..." : "Add"
|
|
iconText: root.creatingFolder ? "\u{F0450}" : "\u{F0415}"
|
|
iconSpinning: root.creatingFolder
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
enabled: !root.creatingFolder && root.newFolderName.trim() !== ""
|
|
onClicked: root.submitNewFolder()
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIELD: Organization, and the collections it files items into.
|
|
Column {
|
|
visible: root.organizations.length > 0
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "ORGANIZATION"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
|
|
Button {
|
|
width: parent.width
|
|
text: Model.plainLabel(root.formOrgLabel())
|
|
iconText: root.formPicker === "organization" ? "\u{F0140}" : "\u{F0991}"
|
|
selected: root.formPicker === "organization"
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
leftAlign: true
|
|
onClicked: root.toggleFormPicker("organization")
|
|
}
|
|
|
|
Flickable {
|
|
id: orgPickList
|
|
visible: root.formPicker === "organization"
|
|
width: parent.width
|
|
height: visible ? Math.min(Style.space(150), orgPickCol.implicitHeight) : 0
|
|
contentWidth: width
|
|
contentHeight: orgPickCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: orgPickList }
|
|
|
|
Column {
|
|
id: orgPickCol
|
|
width: orgPickList.width - root.scrollGutter
|
|
spacing: Style.space(2)
|
|
|
|
FormPickerRow {
|
|
width: parent.width
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
label: "My Vault"
|
|
glyph: "\u{F0004}"
|
|
picked: !root.formOrgId || root.formOrgId === "personal"
|
|
onActivated: root.setFormOrganization("")
|
|
}
|
|
|
|
Repeater {
|
|
model: root.organizations
|
|
delegate: FormPickerRow {
|
|
required property var modelData
|
|
width: parent.width
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
label: modelData.name
|
|
glyph: "\u{F0991}"
|
|
picked: root.formOrgId === modelData.id
|
|
onActivated: root.setFormOrganization(modelData.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collections only exist for org-owned items, and Bitwarden
|
|
// requires at least one, so this appears with the choice.
|
|
Column {
|
|
visible: Boolean(root.formOrgId) && root.formOrgId !== "personal"
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
|
|
Item { width: 1; height: Style.space(4) }
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: "COLLECTIONS"
|
|
color: root.formCollectionIds.length === 0 ? root.urgent : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
text: root.formCollectionsLoading
|
|
? "loading..."
|
|
: (root.formCollectionIds.length === 0
|
|
? "pick at least one"
|
|
: root.formCollectionIds.length + " selected")
|
|
color: root.formCollectionIds.length === 0 ? root.urgent : root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
}
|
|
}
|
|
|
|
Flickable {
|
|
id: collectionList
|
|
width: parent.width
|
|
height: Math.min(Style.space(150), collectionCol.implicitHeight)
|
|
contentWidth: width
|
|
contentHeight: collectionCol.implicitHeight
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
WheelScroll { view: collectionList }
|
|
|
|
Column {
|
|
id: collectionCol
|
|
width: collectionList.width - root.scrollGutter
|
|
spacing: Style.space(2)
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
visible: !root.formCollectionsLoading && root.formCollections.length === 0
|
|
width: parent.width
|
|
text: "No collections available in this organization."
|
|
color: root.dim
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
Repeater {
|
|
model: root.formCollections
|
|
delegate: FormPickerRow {
|
|
required property var modelData
|
|
width: parent.width
|
|
foreground: root.fg
|
|
fontFamily: root.fontFamily
|
|
label: modelData.name
|
|
glyph: "\u{F0290}"
|
|
picked: root.isFormCollectionSelected(modelData.id)
|
|
// Several collections may hold one item, so these
|
|
// toggle instead of replacing the choice.
|
|
multi: true
|
|
onActivated: root.toggleFormCollection(modelData.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIELD: Username (Login only)
|
|
Column {
|
|
visible: root.formTypeCode === 1
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "USERNAME / EMAIL"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "username or email address..."
|
|
text: root.formUsername
|
|
onTextChanged: root.formUsername = text
|
|
}
|
|
}
|
|
|
|
// FIELD: Password with Generator (Login only)
|
|
Column {
|
|
visible: root.formTypeCode === 1
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
RowLayout {
|
|
width: parent.width
|
|
Text { textFormat: Text.PlainText; text: "PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
Item { Layout.fillWidth: true }
|
|
// Opens the real generator, which fills this field in and
|
|
// comes back. The ellipsis says it goes somewhere first.
|
|
Button {
|
|
text: "Generate..."
|
|
iconText: ""
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.caption
|
|
onClicked: root.openGenerator()
|
|
}
|
|
}
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
TextField {
|
|
id: formPassField
|
|
width: parent.width - eyeBtnForm.width - Style.space(6)
|
|
placeholderText: "Password..."
|
|
password: !root.formPasswordRevealed
|
|
text: root.formPassword
|
|
onTextChanged: root.formPassword = text
|
|
}
|
|
Button {
|
|
id: eyeBtnForm
|
|
iconText: root.formPasswordRevealed ? "" : ""
|
|
tooltipText: root.formPasswordRevealed ? "Hide password" : "Show password"
|
|
fontFamily: root.fontFamily
|
|
onClicked: root.formPasswordRevealed = !root.formPasswordRevealed
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIELD: TOTP Authenticator Key (Login only)
|
|
Column {
|
|
visible: root.formTypeCode === 1
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "AUTHENTICATOR KEY (TOTP SECRET)"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "e.g. JBSWY3DPEHPK3PXP (optional)..."
|
|
text: root.formTotp
|
|
onTextChanged: root.formTotp = text
|
|
}
|
|
}
|
|
|
|
// FIELD: Website URL (Login only)
|
|
Column {
|
|
visible: root.formTypeCode === 1
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "WEBSITE URL"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "https://example.com/login..."
|
|
text: root.formUri
|
|
onTextChanged: root.formUri = text
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------
|
|
// FORM FIELDS: Card
|
|
// -----------------------------------------------------------
|
|
// Expiry is split here, unlike the detail view, because these
|
|
// are two values the vault stores separately and a single box
|
|
// would have to guess where the boundary between them falls.
|
|
Column {
|
|
visible: root.formTypeCode === 3
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "CARDHOLDER NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "Name as printed on the card"
|
|
text: root.formCardholderName
|
|
onTextChanged: root.formCardholderName = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 3
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "BRAND"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "Visa, Mastercard, Amex..."
|
|
text: root.formCardBrand
|
|
onTextChanged: root.formCardBrand = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 3
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "CARD NUMBER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "1234 5678 9012 3456"
|
|
text: root.formCardNumber
|
|
onTextChanged: root.formCardNumber = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 3
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "EXPIRY MONTH"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "MM"
|
|
text: root.formCardExpMonth
|
|
onTextChanged: root.formCardExpMonth = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 3
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "EXPIRY YEAR"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "YYYY"
|
|
text: root.formCardExpYear
|
|
onTextChanged: root.formCardExpYear = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 3
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "SECURITY CODE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "CVV / CVC"
|
|
text: root.formCardCode
|
|
onTextChanged: root.formCardCode = text
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------
|
|
// FORM FIELDS: Identity
|
|
// -----------------------------------------------------------
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "TITLE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "Mr, Ms, Dr..."
|
|
text: root.formIdTitle
|
|
onTextChanged: root.formIdTitle = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "FIRST NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdFirstName
|
|
onTextChanged: root.formIdFirstName = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "MIDDLE NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdMiddleName
|
|
onTextChanged: root.formIdMiddleName = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "LAST NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdLastName
|
|
onTextChanged: root.formIdLastName = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "USERNAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdUsername
|
|
onTextChanged: root.formIdUsername = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "COMPANY"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdCompany
|
|
onTextChanged: root.formIdCompany = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "EMAIL"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "name@example.com"
|
|
text: root.formIdEmail
|
|
onTextChanged: root.formIdEmail = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "PHONE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdPhone
|
|
onTextChanged: root.formIdPhone = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "SOCIAL SECURITY NUMBER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdSsn
|
|
onTextChanged: root.formIdSsn = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "PASSPORT NUMBER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdPassport
|
|
onTextChanged: root.formIdPassport = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "LICENCE NUMBER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdLicense
|
|
onTextChanged: root.formIdLicense = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "ADDRESS LINE 1"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdAddress1
|
|
onTextChanged: root.formIdAddress1 = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "ADDRESS LINE 2"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdAddress2
|
|
onTextChanged: root.formIdAddress2 = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "ADDRESS LINE 3"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdAddress3
|
|
onTextChanged: root.formIdAddress3 = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "CITY / TOWN"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdCity
|
|
onTextChanged: root.formIdCity = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "STATE / COUNTY"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdState
|
|
onTextChanged: root.formIdState = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "POSTAL CODE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdPostalCode
|
|
onTextChanged: root.formIdPostalCode = text
|
|
}
|
|
}
|
|
Column {
|
|
visible: root.formTypeCode === 4
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "COUNTRY"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: ""
|
|
text: root.formIdCountry
|
|
onTextChanged: root.formIdCountry = text
|
|
}
|
|
}
|
|
|
|
// FIELD: Notes
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(3)
|
|
Text { textFormat: Text.PlainText; text: "NOTES"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
|
|
TextField {
|
|
width: parent.width
|
|
placeholderText: "Additional secure notes..."
|
|
text: root.formNotes
|
|
onTextChanged: root.formNotes = text
|
|
}
|
|
}
|
|
|
|
// Favorite Star Toggle
|
|
Row {
|
|
spacing: Style.space(8)
|
|
Button {
|
|
text: root.formFavorite ? "★ In Favorites" : "☆ Add to Favorites"
|
|
selected: root.formFavorite
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
onClicked: root.formFavorite = !root.formFavorite
|
|
}
|
|
}
|
|
|
|
// Enter saves from anywhere in the form, so a long item does not
|
|
// have to be scrolled to the bottom to be committed.
|
|
//
|
|
// A Shortcut rather than `onAccepted` on each field: there are
|
|
// more than thirty of them and the next one added would silently
|
|
// not save. It is scoped tightly instead -- only on this screen,
|
|
// and not while a picker is open, where Enter belongs to the
|
|
// list being picked from.
|
|
Shortcut {
|
|
sequences: ["Return", "Enter"]
|
|
enabled: root.activeScreen === "edit" && root.formPicker === ""
|
|
onActivated: root.saveItemForm()
|
|
}
|
|
|
|
// Save Action Button
|
|
Button {
|
|
width: parent.width
|
|
text: root.isLoading
|
|
? "Saving..."
|
|
: (root.formIsEditing ? "Save Changes (Enter)" : "Create Item (Enter)")
|
|
iconText: root.isLoading ? "" : ""
|
|
iconSpinning: root.isLoading
|
|
selected: true
|
|
accent: Color.accent
|
|
fontFamily: root.fontFamily
|
|
enabled: !root.isLoading
|
|
onClicked: root.saveItemForm()
|
|
}
|
|
|
|
Item { height: Style.space(12); width: 1 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Transient updates belong to the panel, but not to its layout. Keeping
|
|
// this beside mainColumn means fittedContentHeight never sees it, so an
|
|
// unlock, copy, save, or error cannot shove the active screen down and
|
|
// pull it back up when the message clears.
|
|
StatusNotice {
|
|
id: statusNotice
|
|
statusMessage: root.flashMessage
|
|
errorMessage: root.errorMessage
|
|
statusSuppressed: root.totpFollowupActive
|
|
foreground: root.fg
|
|
surfaceColor: root.bar ? root.bar.background : Color.background
|
|
accentColor: root.accent
|
|
urgentColor: root.urgent
|
|
fontFamily: root.fontFamily
|
|
actionLabel: root.failedSave ? "Reopen " + root.failedSave.name : ""
|
|
onActionRequested: root.reopenFailedSave()
|
|
onErrorDismissed: {
|
|
// Dismissing the message drops the recovery with it: the list is
|
|
// already back to what the vault holds, so what is being discarded
|
|
// is the attempt, and leaving a Reopen behind an invisible message
|
|
// would be a button for something the user has said they are done
|
|
// with.
|
|
root.failedSave = null
|
|
root.errorMessage = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|