Give identity its due: native enrollment, honest deletion, and sign-in that stays home
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -7,6 +7,10 @@
|
||||
//
|
||||
// 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
|
||||
@@ -19,20 +23,74 @@ SettingsPage {
|
||||
|
||||
objectName: "users"
|
||||
title: "Users"
|
||||
lede: "Your account, and anyone else who signs in to this machine."
|
||||
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 "";
|
||||
@@ -46,15 +104,56 @@ SettingsPage {
|
||||
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
|
||||
@@ -62,9 +161,11 @@ SettingsPage {
|
||||
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: "Accounts need attention"
|
||||
label: "That change did not go through"
|
||||
detail: UserAccounts.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
@@ -160,25 +261,64 @@ SettingsPage {
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
|
||||
SettingsButton {
|
||||
text: "Change picture…"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: avatarPicker.open()
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: UserAccounts.avatarUrl !== ""
|
||||
text: "Remove"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: {
|
||||
root.pictureOpen = false;
|
||||
UserAccounts.removeIcon();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
visible: root.me !== null && root.pendingPicture === ""
|
||||
|
||||
TextFieldRow {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -191,7 +331,7 @@ SettingsPage {
|
||||
SegmentRow {
|
||||
label: "Account type"
|
||||
detail: root.me?.administrator && UserAccounts.administratorCount <= 1
|
||||
? "This is the only administrator, so it cannot be changed"
|
||||
? "The last administrator cannot step down"
|
||||
: "Administrators can install software and manage other accounts"
|
||||
options: [
|
||||
{ value: "standard", label: "Standard" },
|
||||
@@ -212,12 +352,10 @@ SettingsPage {
|
||||
enabled: !UserAccounts.busy
|
||||
divider: root.openPanel === "password"
|
||||
onTriggered: {
|
||||
if (root.openPanel === "password")
|
||||
root.closePanels();
|
||||
else {
|
||||
root.closePanels();
|
||||
const wasOpen = root.openPanel === "password";
|
||||
root.closePanels();
|
||||
if (!wasOpen)
|
||||
root.openPanel = "password";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,19 +363,30 @@ SettingsPage {
|
||||
width: parent.width
|
||||
visible: root.openPanel === "password"
|
||||
|
||||
PasswordRow {
|
||||
SecretFieldRow {
|
||||
id: newPasswordField
|
||||
|
||||
width: parent.width
|
||||
label: "New password"
|
||||
detail: "At least six characters"
|
||||
enabled: !UserAccounts.busy
|
||||
onChanged: value => root.newPassword = value
|
||||
}
|
||||
|
||||
PasswordRow {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -257,7 +406,7 @@ SettingsPage {
|
||||
|
||||
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."
|
||||
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
|
||||
@@ -267,50 +416,195 @@ SettingsPage {
|
||||
|
||||
// ── 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".
|
||||
// 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 {
|
||||
visible: Fingerprint.readerPresent
|
||||
// `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.readerName !== ""
|
||||
subtitle: Fingerprint.readerPresent && 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";
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
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")
|
||||
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: "Fingerprint needs attention"
|
||||
label: "The reader had something to say"
|
||||
detail: Fingerprint.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
@@ -337,48 +631,149 @@ SettingsPage {
|
||||
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
|
||||
|
||||
SettingRow {
|
||||
width: otherBlock.width
|
||||
label: UserAccounts.displayName(otherBlock.modelData)
|
||||
detail: otherBlock.userName + " · "
|
||||
+ (otherBlock.modelData.administrator ? "Administrator" : "Standard account")
|
||||
controlWidth: 210
|
||||
divider: false
|
||||
+ (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";
|
||||
}
|
||||
|
||||
Row {
|
||||
Text {
|
||||
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);
|
||||
}
|
||||
}
|
||||
text: otherBlock.expanded ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
Column {
|
||||
width: otherBlock.width
|
||||
visible: otherBlock.confirming
|
||||
label: "This cannot be undone"
|
||||
detail: "Their home directory and everything in it is deleted."
|
||||
value: ""
|
||||
divider: false
|
||||
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 /home/" + otherBlock.userName
|
||||
options: [
|
||||
{
|
||||
value: "keep",
|
||||
label: "Keep the files",
|
||||
detail: "/home/" + otherBlock.userName
|
||||
+ " 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 — /home/" + otherBlock.userName
|
||||
+ " and everything in it is deleted along with the account.";
|
||||
return "Their files stay in /home/" + otherBlock.userName
|
||||
+ ", 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 }
|
||||
@@ -392,35 +787,43 @@ SettingsPage {
|
||||
enabled: !UserAccounts.busy
|
||||
divider: root.openPanel === "newUser"
|
||||
onTriggered: {
|
||||
if (root.openPanel === "newUser")
|
||||
root.closePanels();
|
||||
else {
|
||||
root.closePanels();
|
||||
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"
|
||||
|
||||
TextFieldRow {
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Full name"
|
||||
placeholder: "Their name"
|
||||
detail: "Shown on the login screen"
|
||||
text: root.newRealName
|
||||
onAccepted: value => root.newRealName = value
|
||||
enabled: !UserAccounts.busy
|
||||
onEdited: value => root.newRealName = value
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Username"
|
||||
placeholder: "lowercase, no spaces"
|
||||
detail: "Their home directory is named after this and cannot be changed later"
|
||||
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
|
||||
onAccepted: value => root.newUserName = value
|
||||
enabled: !UserAccounts.busy
|
||||
onEdited: value => root.newUserName = value
|
||||
}
|
||||
|
||||
SegmentRow {
|
||||
@@ -432,6 +835,7 @@ SettingsPage {
|
||||
{ value: "administrator", label: "Administrator" }
|
||||
]
|
||||
value: root.newUserIsAdministrator ? "administrator" : "standard"
|
||||
enabled: !UserAccounts.busy
|
||||
onSelected: value => root.newUserIsAdministrator = value === "administrator"
|
||||
}
|
||||
|
||||
@@ -440,7 +844,11 @@ SettingsPage {
|
||||
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)
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user