872 lines
37 KiB
QML
872 lines
37 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.
|
|
//
|
|
// 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: {
|
|
if (!UserAccounts.scanned)
|
|
UserAccounts.refresh();
|
|
// Probing fprintd bus-activates it, so it waits for the page rather
|
|
// than costing every shell launch.
|
|
if (!Fingerprint.scanned)
|
|
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.
|
|
ErrorRow {
|
|
label: "That change did not go through"
|
|
message: UserAccounts.lastError
|
|
}
|
|
|
|
// ── 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
|
|
|
|
StatusBadge {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "No reader"
|
|
tone: Theme.warn
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
SectionLabel { text: "Enrolled fingers" }
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
ErrorRow {
|
|
label: "The reader had something to say"
|
|
message: Fingerprint.lastError
|
|
}
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
}
|