// Your account, and anyone else who signs in to this machine. // // The layout puts your own account first because that is what someone opens // this page for, and the avatar leads because it is the thing that shows up // elsewhere in the desktop -- the Control Center draws it, and the lock screen // and login screen read the same file. // // Everything privileged here prompts through polkit. A prompt that is dismissed // is a normal outcome and says so plainly rather than reporting a failure. // // This page hands nothing to GNOME. Fingerprint enrollment was the last thing // that did, and Panama drives fprintd's EnrollStart itself now -- see // fingerprint-contract, which pins the absence. import Quickshell import Quickshell.Widgets import QtQuick import qs.config import qs.services SettingsPage { id: root objectName: "users" title: "Users" lede: "Who this machine belongs to, and how it knows it's you." // One panel open at a time: changing a password and adding an account are // both multi-field, and two open at once reads as a form with no shape. property string openPanel: "" property bool pictureOpen: false property string newPassword: "" property string confirmPassword: "" property string newUserName: "" property string newRealName: "" property bool newUserIsAdministrator: false // Which other account is unfolded, which one is one press from deletion, // and what that deletion would do to their files. All three are page state // rather than row state, so opening a second row puts the first one's armed // Delete button away instead of leaving two of them armed at once. property string expandedUser: "" property string confirmingRemoval: "" // "keep" | "remove" property string removalDisposition: "keep" // Which enrolled finger is one press from being forgotten, and which one // the next enrollment would take. The finger being enrolled right now is // the service's business, not this page's. property string confirmingFinger: "" property string chosenFinger: "right-index-finger" readonly property var me: UserAccounts.me // The service owns fprintd's vocabulary and subtracts what is already // enrolled; this only pairs each one with its label for the picker. readonly property var fingerOptions: Fingerprint.availableFingers.map( finger => ({ value: finger, label: Fingerprint.fingerLabel(finger) })) // Keeps the picker pointing at something that can still be enrolled: the // default finger stops being offered the moment it is enrolled, and a // picker showing a blank current value reads as broken. onFingerOptionsChanged: { if (root.fingerOptions.length === 0) return; if (!root.fingerOptions.some(option => option.value === root.chosenFinger)) root.chosenFinger = String(root.fingerOptions[0].value); } readonly property bool fingerprintStuck: Fingerprint.unlockFeatureEnabled && !Fingerprint.readerPresent // What the last touch did, in words. fprintd's result strings are for // programs; this is the one line someone looking up from the reader reads. readonly property string enrollHint: { if (Fingerprint.enrollPhase === "claiming") return "Waking the reader…"; if (Fingerprint.enrollPhase === "failed") return "The reader gave up on that one — start again when you are ready."; const result = String(Fingerprint.enrollResult); if (result === "" || result === "enroll-stage-passed") return ""; if (/too-short|swipe-too-short/.test(result)) return "That touch was too brief — hold it there a moment longer."; if (/remove-and-retry|retry-scan/.test(result)) return "Lift your finger all the way off, then press again."; if (/centered|too-fast|duplicate/.test(result)) return "Land more of the pad on the reader and hold still."; return "That touch did not take. Try again."; } readonly property string passwordProblem: { if (root.newPassword === "") return ""; if (root.newPassword.length < 6) return "Use at least six characters."; if (root.confirmPassword !== "" && root.newPassword !== root.confirmPassword) return "The two entries do not match."; return ""; } readonly property bool passwordReady: root.newPassword.length >= 6 && root.newPassword === root.confirmPassword // The helper's own rule, character for character -- panama-users compiles // exactly this one, and a page that tests something slightly different // accepts a name accountsservice then refuses, with nothing on screen to // say why. The length cap is part of the expression rather than a separate // check beside it, for the same reason. // // Read while the name is being typed, by both the sentence under the field // and the Create button, so the two can never disagree. readonly property bool newUserNameValid: /^[a-z_][a-z0-9_-]{0,31}$/.test(root.newUserName) readonly property string userNameProblem: { const value = root.newUserName; if (value === "") return ""; if (!root.newUserNameValid) return "Start with a lowercase letter or _, then lowercase letters, digits, - and _ — up to 31 more."; if (UserAccounts.users.some(user => String(user.userName ?? "") === value)) return "An account with that username already exists."; return ""; } function closePanels(): void { root.openPanel = ""; root.newPassword = ""; root.confirmPassword = ""; newPasswordField.clear(); confirmPasswordField.clear(); root.newUserName = ""; root.newRealName = ""; root.newUserIsAdministrator = false; } // accountsservice reports the last session start in seconds; a helper that // hands back milliseconds is taken at its word rather than read as a date // fifty thousand years from now. function signInSummary(user: var): string { const raw = Number(user?.loginTime ?? 0); if (!Number.isFinite(raw) || raw <= 0) return "has not signed in yet"; const when = new Date((raw > 1e12 ? raw / 1000 : raw) * 1000); const days = Math.floor((Date.now() - when.getTime()) / 86400000); if (days <= 0) return "last signed in today"; if (days === 1) return "last signed in yesterday"; if (days < 30) return "last signed in " + days + " days ago"; return "last signed in " + when.toLocaleDateString(Qt.locale(), Locale.ShortFormat); } Component.onCompleted: { UserAccounts.refresh(); // Probing fprintd bus-activates it, so it waits for the page rather // than costing every shell launch. Fingerprint.refresh(); } // A write that failed is a row, not a page. Everything below stays usable, // because the thing that refused is almost never the thing being read. TextRow { visible: UserAccounts.lastError !== "" label: "That change did not go through" detail: UserAccounts.lastError value: "" divider: false } // ── You ────────────────────────────────────────────────────────────────── // Chosen but not yet framed. While this is set the cropper replaces the // account card, because framing is a decision to finish, not a setting to // leave half-made. property string pendingPicture: "" SettingsCard { visible: root.pendingPicture !== "" title: "Frame the picture" AvatarCropper { width: parent.width source: root.pendingPicture onCropped: (x, y, size) => { UserAccounts.setIconCropped(String(root.me?.userName ?? ""), root.pendingPicture, x, y, size); root.pendingPicture = ""; } onCancelled: root.pendingPicture = "" } } SettingsCard { visible: root.me !== null && root.pendingPicture === "" Row { width: parent.width spacing: 20 Item { width: 96 height: 96 ClippingRectangle { anchors.fill: parent radius: width / 2 color: Theme.alpha(Theme.fg, 0.08) Image { anchors.fill: parent source: UserAccounts.avatarUrl visible: UserAccounts.avatarUrl !== "" fillMode: Image.PreserveAspectCrop // accountsservice replaces the file in place, so the // path never changes. cache:false is not enough on its // own -- an unchanged source is never re-read at all -- // which is why avatarUrl carries a revision fragment. cache: false asynchronous: true sourceSize.width: 192 sourceSize.height: 192 } Text { anchors.centerIn: parent visible: UserAccounts.avatarUrl === "" text: UserAccounts.displayName(root.me).slice(0, 1).toUpperCase() color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: 38 font.weight: Font.DemiBold } } } Column { width: parent.width - 116 spacing: 4 anchors.verticalCenter: parent.verticalCenter Text { text: UserAccounts.displayName(root.me) color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeTitle font.weight: Font.DemiBold } Text { text: String(root.me?.userName ?? "") + " · " + (root.me?.administrator ? "Administrator" : "Standard account") color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSize } Item { width: 1; height: 6 } Row { spacing: 8 SettingsButton { text: root.pictureOpen ? "Done" : "Change picture…" enabled: !UserAccounts.busy onClicked: { // The gallery is a directory listing, read the // first time someone asks to see it. if (!root.pictureOpen) UserAccounts.loadStockAvatars(); root.pictureOpen = !root.pictureOpen; } } ConfirmAction { id: removePicture visible: UserAccounts.avatarUrl !== "" actionId: "remove-avatar" armText: "Remove…" confirmText: "Remove it" enabled: !UserAccounts.busy onConfirmed: { root.pictureOpen = false; UserAccounts.removeIcon(); } } } // The consequence, said before the confirming press: this // deletes the file accountsservice keeps, so the picture is // gone from the lock screen and every other account surface, // not merely from this page. Text { width: parent.width visible: removePicture.armed text: "The picture is deleted from your account — the lock screen and everywhere else showing it fall back to your initial." color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap } } } StockAvatarPicker { width: parent.width visible: root.pictureOpen avatars: UserAccounts.stockAvatars enabled: !UserAccounts.busy onPicked: path => { root.pictureOpen = false; UserAccounts.setIcon(String(root.me?.userName ?? ""), path); } // A photo is almost never square, so it goes through the cropper // rather than being squeezed into a circle by the image loader. onFileRequested: { root.pictureOpen = false; avatarPicker.open(); } } } SettingsCard { title: "Account" visible: root.me !== null && root.pendingPicture === "" LiveFieldRow { label: "Full name" detail: "Shown on the lock screen and in the Control Center" text: String(root.me?.realName ?? "") placeholder: "Your name" enabled: !UserAccounts.busy // Committed on Enter or when the field is left, never per // keystroke: this one goes through polkit. onAccepted: value => UserAccounts.setRealName(String(root.me?.userName ?? ""), value) } TextRow { label: "Username" detail: "Fixed when the account was created, because files and permissions are keyed to it" value: String(root.me?.userName ?? "") } SegmentRow { label: "Account type" detail: root.me?.administrator && UserAccounts.administratorCount <= 1 ? "The last administrator cannot step down" : "Administrators can install software and manage other accounts" options: [ { value: "standard", label: "Standard" }, { value: "administrator", label: "Administrator" } ] value: root.me?.administrator ? "administrator" : "standard" enabled: !UserAccounts.busy && !(root.me?.administrator && UserAccounts.administratorCount <= 1) onSelected: value => UserAccounts.setAccountType(String(root.me?.userName ?? ""), value) } ActionRow { label: "Password" detail: root.openPanel === "password" ? "Changing it asks for authorization first" : "Change the password used to sign in and to unlock the screen" action: root.openPanel === "password" ? "Cancel" : "Change…" enabled: !UserAccounts.busy divider: root.openPanel === "password" onTriggered: { const wasOpen = root.openPanel === "password"; root.closePanels(); if (!wasOpen) root.openPanel = "password"; } } Column { width: parent.width visible: root.openPanel === "password" SecretFieldRow { id: newPasswordField width: parent.width label: "New password" detail: "At least six characters" enabled: !UserAccounts.busy onChanged: value => root.newPassword = value } PasswordStrengthRow { width: parent.width password: root.newPassword } SecretFieldRow { id: confirmPasswordField width: parent.width label: "Confirm" detail: root.passwordProblem !== "" ? root.passwordProblem : "Type it a second time" enabled: !UserAccounts.busy onChanged: value => root.confirmPassword = value } ActionRow { width: parent.width label: "Set this password" detail: "You will be asked to authorize the change" action: "Set password" enabled: root.passwordReady && !UserAccounts.busy divider: false onTriggered: { UserAccounts.setPassword(String(root.me?.userName ?? ""), root.newPassword); root.closePanels(); } } } SwitchRow { label: "Automatic login" detail: "Anyone at the keyboard becomes you — the disk stays encrypted, the session does not. The login keyring stays locked too, so stored passwords wait until something asks for them." checked: root.me?.automaticLogin === true enabled: !UserAccounts.busy divider: false onToggled: value => UserAccounts.setAutomaticLogin(String(root.me?.userName ?? ""), value) } } // ── Fingerprint ────────────────────────────────────────────────────────── // // Two systems make a working fingerprint login and the card keeps them // honest with each other: fprintd holds the enrolled prints, and authselect // decides whether PAM asks the reader at all. A print enrolled while // authselect is off does nothing, and authselect left on with no reader // attached asks PAM for a finger nothing can read -- which is why the card // appears for either fact, not only when a reader is plugged in. SettingsCard { // `cardVisible` is the service's `readerPresent || unlockFeatureEnabled` // -- one expression, so the card and the state it describes cannot // disagree about when the stuck case exists. visible: Fingerprint.cardVisible title: "Fingerprint" subtitle: Fingerprint.readerPresent && Fingerprint.readerName !== "" ? Fingerprint.readerName : "" // The stuck state: the feature is on, and there is nothing to read a // finger with. Reachable here because it is unreachable anywhere else -- // the card used to hide itself on exactly the machine that needed it. SettingRow { visible: root.fingerprintStuck label: "Fingerprint unlock is on, but no reader is connected" detail: "authselect still asks PAM for a finger that can't be read — turn it off here, or plug the reader back in" controlWidth: 190 divider: false Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 Rectangle { anchors.verticalCenter: parent.verticalCenter width: noReader.implicitWidth + 14 height: 17 radius: Theme.pillRadius color: Theme.alpha(Theme.warn, 0.1) border.width: 1 border.color: Theme.alpha(Theme.warn, 0.3) Text { id: noReader anchors.centerIn: parent text: "NO READER" color: Theme.warn font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2) font.weight: Font.DemiBold font.letterSpacing: 0.5 } } SettingsButton { text: "Turn off" enabled: !Fingerprint.busy onClicked: Fingerprint.setUnlockEnabled(false) } } } Column { width: parent.width visible: Fingerprint.readerPresent SwitchRow { width: parent.width label: "Unlock with a fingerprint" detail: { if (Fingerprint.fingers.length === 0) return "Enroll a finger below first; until then the password is the only way in"; return "The lock screen and sudo — system-wide, via authselect, with the password as fallback"; } checked: Fingerprint.unlockFeatureEnabled enabled: !Fingerprint.busy && !Fingerprint.enrolling onToggled: value => Fingerprint.setUnlockEnabled(value) } Text { width: parent.width text: "ENROLLED FINGERS" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) font.weight: Font.DemiBold font.letterSpacing: 0.8 topPadding: 12 bottomPadding: 4 } TextRow { width: parent.width visible: Fingerprint.fingers.length === 0 label: "None yet" detail: "A finger has to be enrolled before the reader can let anyone in" value: "" divider: false } Repeater { model: Fingerprint.fingers delegate: SettingRow { id: fingerRow required property var modelData readonly property string finger: String(fingerRow.modelData) readonly property bool confirming: root.confirmingFinger === fingerRow.finger width: parent.width // md-fingerprint icon: "\u{F0306}" label: Fingerprint.fingerLabel(fingerRow.finger) detail: fingerRow.confirming ? "Forgetting it takes effect at once; it can be enrolled again afterwards" : "" controlWidth: 190 divider: false Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { text: fingerRow.confirming ? "Keep" : "Remove…" enabled: !Fingerprint.busy && !Fingerprint.enrolling onClicked: root.confirmingFinger = fingerRow.confirming ? "" : fingerRow.finger } SettingsButton { visible: fingerRow.confirming text: "Forget it" tone: "danger" enabled: !Fingerprint.busy onClicked: { root.confirmingFinger = ""; Fingerprint.removeFinger(fingerRow.finger); } } } } } OptionPickerRow { width: parent.width visible: !Fingerprint.enrolling && root.fingerOptions.length > 1 label: "Finger" detail: "Which one the reader is about to learn" options: root.fingerOptions current: root.chosenFinger onPicked: value => root.chosenFinger = String(value) } ActionRow { width: parent.width visible: !Fingerprint.enrolling label: "Add a fingerprint" detail: root.fingerOptions.length === 0 ? "Every finger the reader knows about is already enrolled" : "Around ten touches, guided here — the reader does the work" action: "Enroll…" enabled: !Fingerprint.busy && root.fingerOptions.length !== 0 divider: false onTriggered: { root.confirmingFinger = ""; Fingerprint.startEnroll(root.chosenFinger); } } FingerprintEnrollPanel { width: parent.width visible: Fingerprint.enrolling // The finger the service is actually working on, not the one // the picker happens to be showing. finger: Fingerprint.fingerLabel(Fingerprint.enrollFinger) stage: Fingerprint.enrollStage total: Fingerprint.enrollTotal message: root.enrollHint onCancelled: Fingerprint.cancelEnroll() } } TextRow { visible: Fingerprint.lastError !== "" label: "The reader had something to say" detail: Fingerprint.lastError value: "" divider: false } } // ── Everyone else ──────────────────────────────────────────────────────── SettingsCard { title: "Other accounts" subtitle: UserAccounts.others.length === 0 ? "Only your account exists on this machine." : UserAccounts.others.length + " other account" + (UserAccounts.others.length === 1 ? "" : "s") Repeater { model: UserAccounts.others delegate: Column { id: otherBlock required property var modelData width: parent.width readonly property string userName: String(otherBlock.modelData.userName ?? "") readonly property bool expanded: root.expandedUser === otherBlock.userName readonly property bool confirming: root.confirmingRemoval === otherBlock.userName readonly property bool locked: otherBlock.modelData.locked === true readonly property bool lastAdministrator: otherBlock.modelData.administrator && UserAccounts.administratorCount <= 1 // The real path, from accountsservice, rather than the home root // joined to the account name. Every sentence below used to // construct that guess, the armed confirmation for deleting the // files among them — so a home that had been moved was named // confidently and wrongly exactly where being wrong costs the // most. When no path is reported, the sentences name the // directory in words rather than inventing one. readonly property string homePath: UserAccounts.homeDirectory(otherBlock.modelData) readonly property bool homeKnown: otherBlock.homePath !== "" readonly property string homePhrase: otherBlock.homeKnown ? otherBlock.homePath : "their home directory" readonly property string homeSubject: otherBlock.homeKnown ? otherBlock.homePath : "Their home directory" SettingRow { width: otherBlock.width label: UserAccounts.displayName(otherBlock.modelData) detail: otherBlock.userName + " · " + (otherBlock.modelData.administrator ? "Administrator" : "Standard") + " · " + (otherBlock.locked ? "locked" : root.signInSummary(otherBlock.modelData)) controlWidth: 26 divider: !otherBlock.expanded activatable: true onActivated: { root.expandedUser = otherBlock.expanded ? "" : otherBlock.userName; root.confirmingRemoval = ""; root.removalDisposition = "keep"; } Text { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: otherBlock.expanded ? "▴" : "▾" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } Column { width: otherBlock.width leftPadding: 14 visible: otherBlock.expanded SegmentRow { width: otherBlock.width - 14 label: "Account type" detail: otherBlock.lastAdministrator ? "The last administrator cannot step down" : "Administrators can install software and manage other accounts" options: [ { value: "standard", label: "Standard" }, { value: "administrator", label: "Administrator" } ] value: otherBlock.modelData.administrator ? "administrator" : "standard" enabled: !UserAccounts.busy && !otherBlock.lastAdministrator onSelected: value => UserAccounts.setAccountTypeFor(otherBlock.userName, value) } ActionRow { width: otherBlock.width - 14 visible: otherBlock.locked label: "This account is locked" detail: "Nothing signs in to it until it is unlocked again" action: "Unlock" enabled: !UserAccounts.busy onTriggered: UserAccounts.setLocked(otherBlock.userName, false) } ActionRow { width: otherBlock.width - 14 label: "Reset password" detail: "They set a new one at next sign-in. Nothing is typed here, and no password is chosen for them." action: "Reset…" enabled: !UserAccounts.busy onTriggered: UserAccounts.resetPassword(otherBlock.userName) } OptionPickerRow { width: otherBlock.width - 14 label: "Their files" detail: "What deleting the account does to " + otherBlock.homePhrase options: [ { value: "keep", label: "Keep the files", detail: otherBlock.homeSubject + " stays exactly where it is, and you can hand it to someone later" }, { value: "remove", label: "Remove everything", detail: "The home directory and everything inside it is deleted with the account" } ] current: root.removalDisposition enabled: !UserAccounts.busy onPicked: value => root.removalDisposition = String(value) } SettingRow { width: otherBlock.width - 14 label: otherBlock.confirming ? "Delete " + UserAccounts.displayName(otherBlock.modelData) + "?" : "Delete this account" detail: { if (!otherBlock.confirming) return "The account stops existing. What happens to their files is the choice above."; if (root.removalDisposition === "remove") return "This cannot be undone — " + otherBlock.homePhrase + " and everything in it is deleted along with the account."; return "Their files stay in " + otherBlock.homePhrase + ", so only the account itself goes."; } controlWidth: 260 divider: false Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { text: otherBlock.confirming ? "Keep the account" : "Delete…" enabled: !UserAccounts.busy onClicked: root.confirmingRemoval = otherBlock.confirming ? "" : otherBlock.userName } SettingsButton { visible: otherBlock.confirming text: root.removalDisposition === "remove" ? "Delete account and files" : "Delete the account" tone: "danger" enabled: !UserAccounts.busy onClicked: { // The helper's second argument is // keep-files, not remove-files: the two // read alike at a glance and mean opposite // things to someone's home directory. const keepFiles = root.removalDisposition === "keep"; root.confirmingRemoval = ""; root.expandedUser = ""; UserAccounts.deleteUser(otherBlock.userName, keepFiles); } } } } } Item { width: 1; height: 6 } } } ActionRow { label: "Add an account" detail: "Creating an account asks for authorization first" action: root.openPanel === "newUser" ? "Cancel" : "Add…" enabled: !UserAccounts.busy divider: root.openPanel === "newUser" onTriggered: { const wasOpen = root.openPanel === "newUser"; root.closePanels(); if (!wasOpen) root.openPanel = "newUser"; } } // Live, on purpose. These fields report every keystroke -- they are // LiveFieldRow rather than TextFieldRow -- because the Create button // has to darken and light as the username is typed. With blur-committed // fields it stayed dark until focus left, which reads as a dead button. Column { width: parent.width visible: root.openPanel === "newUser" LiveFieldRow { width: parent.width label: "Full name" placeholder: "Their name" detail: "Shown on the login screen" text: root.newRealName enabled: !UserAccounts.busy onEdited: value => root.newRealName = value } LiveFieldRow { width: parent.width label: "Username" placeholder: "riley" detail: root.userNameProblem !== "" ? root.userNameProblem : "A lowercase letter or _, then lowercase letters, digits, - and _ · up to 31 more. Their home directory is named after it and cannot be changed later." invalid: root.userNameProblem !== "" maximumLength: 32 text: root.newUserName enabled: !UserAccounts.busy onEdited: value => root.newUserName = value } SegmentRow { width: parent.width label: "Account type" detail: "Standard accounts cannot install software or manage other accounts" options: [ { value: "standard", label: "Standard" }, { value: "administrator", label: "Administrator" } ] value: root.newUserIsAdministrator ? "administrator" : "standard" enabled: !UserAccounts.busy onSelected: value => root.newUserIsAdministrator = value === "administrator" } ActionRow { width: parent.width label: "Create the account" detail: "They set their own password the first time they sign in" action: "Create" // The same rule the sentence under the field states, read from // the one place it is written down. enabled: !UserAccounts.busy && root.newUserNameValid && root.userNameProblem === "" divider: false onTriggered: { UserAccounts.createUser(root.newUserName, root.newRealName, root.newUserIsAdministrator ? "administrator" : "standard"); root.closePanels(); } } } } // Picking a picture goes through the desktop portal, which is the same // chooser every other application gets and needs no privilege of its own. AvatarPicker { id: avatarPicker onPicked: path => root.pendingPicture = path } }