// 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. import Quickshell import Quickshell.Widgets import QtQuick import qs.config import qs.services SettingsPage { id: root objectName: "users" title: "Users" lede: "Your account, and anyone else who signs in to this machine." // 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 string newPassword: "" property string confirmPassword: "" property string newUserName: "" property string newRealName: "" property bool newUserIsAdministrator: false property string confirmingRemoval: "" readonly property var me: UserAccounts.me 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 function closePanels(): void { root.openPanel = ""; root.newPassword = ""; root.confirmPassword = ""; root.newUserName = ""; root.newRealName = ""; root.newUserIsAdministrator = false; } Component.onCompleted: UserAccounts.refresh() TextRow { visible: UserAccounts.lastError !== "" label: "Accounts need attention" 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 } SettingsButton { text: "Change picture…" enabled: !UserAccounts.busy onClicked: avatarPicker.open() } } } } SettingsCard { title: "Account" visible: root.me !== null TextFieldRow { 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 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 ? "This is the only administrator, so it cannot be changed" : "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: { if (root.openPanel === "password") root.closePanels(); else { root.closePanels(); root.openPanel = "password"; } } } Column { width: parent.width visible: root.openPanel === "password" PasswordRow { width: parent.width label: "New password" detail: "At least six characters" onChanged: value => root.newPassword = value } PasswordRow { width: parent.width label: "Confirm" detail: root.passwordProblem !== "" ? root.passwordProblem : "Type it a second time" 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: "Sign in without typing a password. The login keyring stays locked when this is on, so stored passwords are unavailable until something asks for them." checked: root.me?.automaticLogin === true enabled: !UserAccounts.busy divider: false onToggled: value => UserAccounts.setAutomaticLogin(String(root.me?.userName ?? ""), value) } } // ── 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 confirming: root.confirmingRemoval === otherBlock.userName SettingRow { width: otherBlock.width label: UserAccounts.displayName(otherBlock.modelData) detail: otherBlock.userName + " · " + (otherBlock.modelData.administrator ? "Administrator" : "Standard account") controlWidth: 210 divider: false Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { text: otherBlock.confirming ? "Keep" : "Remove…" enabled: !UserAccounts.busy onClicked: root.confirmingRemoval = otherBlock.confirming ? "" : otherBlock.userName } SettingsButton { visible: otherBlock.confirming text: "Delete account and files" tone: "danger" enabled: !UserAccounts.busy onClicked: { root.confirmingRemoval = ""; UserAccounts.deleteUser(otherBlock.userName, true); } } } } TextRow { width: otherBlock.width visible: otherBlock.confirming label: "This cannot be undone" detail: "Their home directory and everything in it is deleted." value: "" divider: false } 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: { if (root.openPanel === "newUser") root.closePanels(); else { root.closePanels(); root.openPanel = "newUser"; } } } Column { width: parent.width visible: root.openPanel === "newUser" TextFieldRow { width: parent.width label: "Full name" placeholder: "Their name" detail: "Shown on the login screen" text: root.newRealName onAccepted: value => root.newRealName = value } TextFieldRow { width: parent.width label: "Username" placeholder: "lowercase, no spaces" detail: "Their home directory is named after this and cannot be changed later" text: root.newUserName onAccepted: 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" 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" enabled: !UserAccounts.busy && /^[a-z_][a-z0-9_-]*$/.test(root.newUserName) 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 } }