Files
Panama/config/dot/quickshell/modules/settings/UsersPage.qml
T
Gabriel Brown a23b42841a Own user accounts and sharing
Two of the panels this desktop still handed to GNOME Settings.

Users manages the account through accountsservice -- the same daemon
GNOME's panel drives, so a name or picture set here is what the login
screen and lock screen read. Name, picture, account type, password,
automatic login, and adding or removing other accounts. Every change is
authorized by polkit through the agent this session already runs; a
dismissed prompt is a normal outcome and says so.

A new password is read from the helper's stdin, hashed by openssl
reading its own stdin, and handed over D-Bus from inside that process.
It is never an argument: argv is world-readable through /proc, so a
password passed that way is published to every process on the machine.
Removing an account takes two presses and says it destroys their files;
the last administrator cannot be removed or demoted, because a machine
nobody can administer is not a state to offer.

Sharing reports what is actually true, including "the software for this
is not installed" -- the honest answer for Samba here, and the case the
panel it replaces shows as a switch that does nothing. Password sign-in
is reported from sshd's configuration rather than assumed: claiming
"keys only" when the file is silent would state a security property that
cannot be backed up.

The Control Center now draws the account's real picture and name. A
generic glyph sat there while a real avatar was already set, which made
the desktop look like it did not know whose it was.

Also here: the KDE Connect contract no longer requires a phone to be
awake. kdeconnectd drops its device objects for a phone it has not seen
recently while the pairing survives in its config, so demanding one
failed whenever the phone was off.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 13:05:13 -04:00

380 lines
14 KiB
QML

// 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 ──────────────────────────────────────────────────────────────────
SettingsCard {
visible: root.me !== null
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
// The file is replaced in place when the picture
// changes, so the cache has to be told to let go.
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 => UserAccounts.setIcon(String(root.me?.userName ?? ""), path)
}
}