Three surfaces the first laptop install showed were missing. The bar's battery icon gets an optional exact number beside it -- GNOME's "Show Battery Percentage", off by default for GNOME's reason, one color with the icon so it reads as one indicator. The Power page says what closing the lid does. The policy already existed (LidPolicy holds a suspend inhibitor while an external display is connected) but was surfaced nowhere, so the machine's most physical behavior was undiscoverable -- and the deliberate absence of an override deserves stating rather than leaving someone to hunt for a switch that does not exist. And the Users page grows a Fingerprint card, because fingerprint login is two systems that fail silently when they disagree: fprintd holds the enrolled prints, authselect decides whether PAM ever asks the reader. This machine arrived with a finger enrolled from its GNOME days and with-fingerprint off, which reads as "the reader is broken". The card shows both facts, flips the authselect feature through polkit with a stated reason, and hands enrollment to GNOME's Users panel, which owns the only good capture dialog -- a named exception in the handoff contract. Everything through scripts/panama-fingerprint, pinned by a stub-driven contract. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
461 lines
17 KiB
QML
461 lines
17 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();
|
|
// Probing fprintd bus-activates it, so it waits for the page rather
|
|
// than costing every shell launch.
|
|
Fingerprint.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)
|
|
}
|
|
}
|
|
|
|
// ── Fingerprint ──────────────────────────────────────────────────────────
|
|
//
|
|
// Hidden in full on a machine with no reader. Two systems make this work
|
|
// and the card keeps them honest with each other: fprintd holds the
|
|
// enrolled prints (GNOME's Users panel owns that dialog, so enrollment
|
|
// hands off the same way password-adjacent panels do), and authselect
|
|
// decides whether PAM asks the reader at all -- a print enrolled while
|
|
// that is off does nothing, which reads as "fingerprint is broken".
|
|
|
|
SettingsCard {
|
|
visible: Fingerprint.readerPresent
|
|
title: "Fingerprint"
|
|
subtitle: Fingerprint.readerName !== ""
|
|
? Fingerprint.readerName
|
|
: "A fingerprint reader is present."
|
|
|
|
SwitchRow {
|
|
label: "Unlock with a fingerprint"
|
|
detail: {
|
|
if (Fingerprint.enrolled.length === 0)
|
|
return "Enroll a finger below first; until then the password is the only way in";
|
|
return Fingerprint.pamEnabled
|
|
? "The lock screen and sudo accept an enrolled finger, with the password as fallback"
|
|
: "Enrolled fingers are ignored until this is on";
|
|
}
|
|
checked: Fingerprint.pamEnabled
|
|
enabled: !Fingerprint.busy
|
|
onToggled: value => Fingerprint.setUnlockEnabled(value)
|
|
}
|
|
|
|
ActionRow {
|
|
label: "Enrolled fingers"
|
|
detail: Fingerprint.enrolled.length === 0
|
|
? "None yet"
|
|
: Fingerprint.enrolled.map(finger => Fingerprint.fingerLabel(finger)).join(", ")
|
|
action: "Manage…"
|
|
divider: Fingerprint.lastError !== ""
|
|
// GNOME's Users panel owns the enrollment dialog; growing our own
|
|
// means reimplementing a guided capture flow fprintd already has
|
|
// a good one of.
|
|
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
|
}
|
|
|
|
TextRow {
|
|
visible: Fingerprint.lastError !== ""
|
|
label: "Fingerprint needs attention"
|
|
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 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
|
|
}
|
|
}
|