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:
@@ -0,0 +1,126 @@
|
||||
// Guided enrollment, in place, for as long as it takes.
|
||||
//
|
||||
// fprintd wants the same finger pressed several times from slightly different
|
||||
// angles, and it says how many it still needs. That count is the whole content
|
||||
// of this panel: a person mid-enrollment is looking at the reader, not the
|
||||
// screen, and the one thing they come back to the screen for is whether it
|
||||
// worked and how much is left.
|
||||
//
|
||||
// Nothing here loops. The bar advances when a touch is accepted and then holds,
|
||||
// so a panel left open on a desktop that never got touched costs no frames --
|
||||
// see the note about repainting animations in the Appearance page.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Human label for the finger being enrolled, already resolved by the
|
||||
// service so nothing here has to know fprintd's vocabulary.
|
||||
property string finger: ""
|
||||
property int stage: 0
|
||||
property int total: 0
|
||||
// Whatever the last stream line said, when it said anything worth showing --
|
||||
// "enroll-retry-scan", a failure, a completion.
|
||||
property string message: ""
|
||||
|
||||
signal cancelled
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: body.implicitHeight + 28
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgDark, 0.55)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.3)
|
||||
|
||||
Column {
|
||||
id: body
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 14
|
||||
spacing: 7
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
// md-fingerprint
|
||||
text: "\u{F0306}"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 38
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: root.finger === ""
|
||||
? "Touch the reader"
|
||||
: "Touch the reader with your " + root.finger.toLowerCase()
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: root.total > 0
|
||||
? "Lift and press again — " + root.stage + " of " + root.total + " touches"
|
||||
: "Lift and press again"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Item { width: 1; height: 3 }
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 5
|
||||
radius: 3
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
border.width: 0
|
||||
|
||||
Rectangle {
|
||||
width: root.total > 0
|
||||
? parent.width * Math.max(0, Math.min(1, root.stage / root.total))
|
||||
: 0
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
color: Theme.accent
|
||||
border.width: 0
|
||||
|
||||
// One step per accepted touch, then still.
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.message !== ""
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: root.message
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
topPadding: 4
|
||||
}
|
||||
|
||||
Item { width: 1; height: 3 }
|
||||
|
||||
SettingsButton {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: "Cancel"
|
||||
onClicked: root.cancelled()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// A row whose trailing control is a free-text field that reports every
|
||||
// keystroke.
|
||||
//
|
||||
// LiveFieldRow {
|
||||
// label: "Username"
|
||||
// detail: root.userNameProblem !== "" ? root.userNameProblem : "Lowercase…"
|
||||
// invalid: root.userNameProblem !== ""
|
||||
// onEdited: value => root.newUserName = value
|
||||
// }
|
||||
//
|
||||
// TextFieldRow commits on Enter or blur, which is right when every commit is a
|
||||
// privileged write that prompts. It is wrong for a form that has to say whether
|
||||
// what is being typed is acceptable *while* it is being typed: the add-user
|
||||
// form used TextFieldRow and its Create button stayed dark until the field lost
|
||||
// focus, which reads as a broken button rather than as a field awaiting blur.
|
||||
//
|
||||
// So this reports twice. `edited` fires per keystroke, for validation and for
|
||||
// state a page holds until a submit button is pressed. `accepted` fires on
|
||||
// Enter or when focus leaves, for the values that go straight to a privileged
|
||||
// write and must not prompt once per character.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property string placeholder: ""
|
||||
property bool enabled: true
|
||||
// Draws the field as rejected. The page decides what invalid means; this
|
||||
// only shows it, next to the detail line that says why.
|
||||
property bool invalid: false
|
||||
property int maximumLength: 0
|
||||
|
||||
signal edited(value: string)
|
||||
signal accepted(value: string)
|
||||
|
||||
controlWidth: 220
|
||||
|
||||
function clear(): void {
|
||||
input.text = "";
|
||||
}
|
||||
|
||||
// An external change replaces what is shown, unless it would yank the field
|
||||
// out from under someone mid-edit.
|
||||
onTextChanged: if (!input.activeFocus && input.text !== root.text) input.text = root.text
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.controlWidth
|
||||
height: 32
|
||||
radius: 9
|
||||
color: Theme.alpha(Theme.fg, root.enabled ? 0.06 : 0.03)
|
||||
border.width: input.activeFocus ? 2 : 1
|
||||
border.color: root.invalid
|
||||
? Theme.alpha(Theme.danger, 0.6)
|
||||
: (input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.1))
|
||||
opacity: root.enabled ? 1 : 0.5
|
||||
|
||||
TextInput {
|
||||
id: input
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 11
|
||||
anchors.rightMargin: 11
|
||||
enabled: root.enabled
|
||||
activeFocusOnTab: true
|
||||
text: root.text
|
||||
color: root.invalid ? Theme.danger : Theme.fg
|
||||
selectByMouse: true
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
maximumLength: root.maximumLength > 0 ? root.maximumLength : 32767
|
||||
clip: true
|
||||
|
||||
onTextEdited: root.edited(input.text)
|
||||
onAccepted: if (input.text !== root.text) root.accepted(input.text)
|
||||
|
||||
// Unchanged text is not re-committed: a page whose `accepted`
|
||||
// handler writes through polkit would otherwise prompt every time
|
||||
// the field was tabbed past.
|
||||
onActiveFocusChanged: {
|
||||
if (input.activeFocus)
|
||||
return;
|
||||
if (input.text !== root.text)
|
||||
root.accepted(input.text);
|
||||
else
|
||||
input.text = root.text;
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: input.text === ""
|
||||
text: root.placeholder
|
||||
color: Theme.fgMuted
|
||||
font: input.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// One GNOME Online Accounts account: a line you can read, unfolding into the
|
||||
// services it lends to the rest of the desktop.
|
||||
//
|
||||
// Built like InstalledAppRow rather than out of SettingRow, because the head
|
||||
// carries the provider's own icon on the left and SettingRow's only icon slot
|
||||
// is a mono glyph. What unfolds is small -- a toggle per service and a removal
|
||||
// -- but it is per-account state, and four accounts each showing four toggles
|
||||
// was most of a screen of switches with no way to tell which belonged to whom.
|
||||
//
|
||||
// Nothing here writes. Every control leaves through a signal so the page owns
|
||||
// the service calls, which keeps the removal confirmation one piece of page
|
||||
// state instead of something each row remembers for itself.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// { path, provider, providerName, providerIcons, identity, needsAttention,
|
||||
// services: [{ key, label, enabled }] }
|
||||
required property var account
|
||||
|
||||
property bool expanded: false
|
||||
property bool busy: false
|
||||
property bool confirmingRemoval: false
|
||||
property bool divider: true
|
||||
|
||||
// The words on the re-authorisation button come from the page, because the
|
||||
// page is what that button leads to: it is the one hand-off to GNOME's own
|
||||
// dialog, and the sentence and the call belong together rather than one of
|
||||
// them living out here. Left empty the button does not appear at all.
|
||||
property string signInAction: ""
|
||||
|
||||
signal activated
|
||||
signal serviceToggled(key: string, enabled: bool)
|
||||
signal removeArmed
|
||||
signal removeCancelled
|
||||
signal removeConfirmed
|
||||
signal signInAgainRequested
|
||||
|
||||
readonly property bool needsAttention: root.account?.needsAttention === true
|
||||
readonly property string providerName: String(root.account?.providerName ?? "")
|
||||
readonly property string identity: String(root.account?.identity ?? "")
|
||||
readonly property var services: root.account?.services ?? []
|
||||
|
||||
// GOA hands back a preference-ordered chain; this walks it and takes the
|
||||
// first name the active icon theme actually has. Taking the first blindly,
|
||||
// or the last as a fallback, both looked right and were not: on Adwaita the
|
||||
// tail of these chains ("mail", "goa-symbolic") does not exist, so a miss
|
||||
// would have rendered nothing at all.
|
||||
readonly property string iconName: {
|
||||
const chain = root.account?.providerIcons ?? [];
|
||||
for (const name of chain) {
|
||||
if (Quickshell.iconPath(String(name), true) !== "")
|
||||
return String(name);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
id: head
|
||||
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(56, copy.implicitHeight + 20)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.bottomMargin: 1
|
||||
radius: 9
|
||||
z: -1
|
||||
visible: headHover.hovered
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 32
|
||||
height: 32
|
||||
radius: 9
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: 0
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
icon: root.iconName
|
||||
iconFallback: "avatar-default-symbolic"
|
||||
tint: root.needsAttention ? Theme.warn : Theme.fg
|
||||
size: 19
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
|
||||
anchors.left: tile.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: trailing.left
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.identity !== "" ? root.identity : root.providerName
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.needsAttention
|
||||
? root.providerName + " · sign-in expired, so this account has stopped syncing"
|
||||
: (root.services.length === 0
|
||||
? root.providerName
|
||||
: root.providerName + " · "
|
||||
+ root.services.filter(service => service.enabled)
|
||||
.map(service => String(service.label)).join(", "))
|
||||
color: root.needsAttention ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: trailing
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.needsAttention && root.signInAction !== ""
|
||||
text: root.signInAction
|
||||
enabled: !root.busy
|
||||
onClicked: root.signInAgainRequested()
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.expanded ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: copy.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider && !root.expanded
|
||||
color: Theme.alpha(Theme.fg, 0.065)
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: headHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.activated()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: expansion
|
||||
|
||||
width: parent.width
|
||||
leftPadding: 44
|
||||
visible: root.expanded
|
||||
|
||||
Repeater {
|
||||
model: root.services
|
||||
|
||||
delegate: SettingRow {
|
||||
id: serviceRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: expansion.width - expansion.leftPadding
|
||||
label: String(serviceRow.modelData.label ?? "")
|
||||
detail: serviceRow.modelData.enabled
|
||||
? "Applications using " + String(serviceRow.modelData.label ?? "").toLowerCase()
|
||||
+ " can see this account"
|
||||
: "Hidden from applications"
|
||||
controlWidth: 54
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: serviceRow.modelData.enabled === true
|
||||
// A toggle that moves while GOA is still answering the last
|
||||
// one springs back, which reads as a broken switch.
|
||||
enabled: !root.busy
|
||||
onToggled: value => root.serviceToggled(
|
||||
String(serviceRow.modelData.key ?? ""), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: expansion.width - expansion.leftPadding
|
||||
label: root.confirmingRemoval ? "Remove this account?" : "Remove this account"
|
||||
detail: root.confirmingRemoval
|
||||
? "It signs out here and disappears from every application that was using it. Nothing on the provider's side is touched."
|
||||
: "Signs out and removes it from every application that was using it"
|
||||
controlWidth: 230
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.confirmingRemoval ? "Keep it" : "Remove…"
|
||||
enabled: !root.busy
|
||||
onClicked: root.confirmingRemoval ? root.removeCancelled() : root.removeArmed()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: root.confirmingRemoval
|
||||
text: "Remove"
|
||||
tone: "danger"
|
||||
enabled: !root.busy
|
||||
onClicked: root.removeConfirmed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,22 @@
|
||||
//
|
||||
// GNOME's panel of the same name, except for one step. The accounts are GNOME
|
||||
// Online Accounts objects on D-Bus, the daemon already runs here, and listing,
|
||||
// per-service toggles and removal are all done natively on this page.
|
||||
// per-service toggles, removal and password-provider sign-in are all done
|
||||
// natively on this page.
|
||||
//
|
||||
// Signing in to an OAuth provider is handed to GNOME's panel, because the
|
||||
// credentials are OAuth tokens and the code that obtains them ships without a
|
||||
// scriptable binding. That is stated on the page rather than hidden behind a
|
||||
// button that looks native, because a hand-off the user does not expect reads
|
||||
// as a bug.
|
||||
// The one hand-off left is OAuth. Google's and Microsoft's tokens are obtained
|
||||
// by code inside libgoa-backend, which Fedora ships without a GIR binding, so
|
||||
// no amount of D-Bus gets at it. That is stated on the page rather than hidden
|
||||
// behind a button that looks native, because a hand-off nobody expects reads as
|
||||
// a bug -- and it goes through openProviderDialog() below, which is the only
|
||||
// place in this file that names a GNOME panel.
|
||||
//
|
||||
// Accounts needing attention are surfaced first. GOA knows when a token has
|
||||
// expired and nothing outside its own panel says so, which is how an account
|
||||
// quietly stops syncing for weeks.
|
||||
// What Panama itself signs in to leads, because those two integrations are the
|
||||
// ones this desktop actually uses. GOA's accounts are for the applications.
|
||||
//
|
||||
// A failed write is a row. The page used to replace itself with "Accounts
|
||||
// unavailable" whenever `lastError` was set, so one refused toggle hid every
|
||||
// account on the machine.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
@@ -22,118 +27,452 @@ import qs.services
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "accounts"
|
||||
title: "Online Accounts"
|
||||
lede: OnlineAccounts.attentionCount > 0
|
||||
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
|
||||
? " account needs you to sign in again."
|
||||
: " accounts need you to sign in again.")
|
||||
: "Accounts your mail, calendar, contacts, and files come from."
|
||||
: "What this desktop signs in to — Panama's own integrations first."
|
||||
|
||||
Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh()
|
||||
// "" | "nextcloud" | "imap"
|
||||
property string openForm: ""
|
||||
property string expandedAccount: ""
|
||||
property string confirmingRemoval: ""
|
||||
|
||||
SettingsCard {
|
||||
visible: !OnlineAccounts.available
|
||||
title: "Accounts unavailable"
|
||||
subtitle: OnlineAccounts.lastError
|
||||
}
|
||||
property string ncServer: ""
|
||||
property string ncUser: ""
|
||||
property string ncPassword: ""
|
||||
|
||||
SettingsCard {
|
||||
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0
|
||||
title: "No accounts yet"
|
||||
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in."
|
||||
property string imapEmail: ""
|
||||
property string imapHost: ""
|
||||
property string smtpHost: ""
|
||||
property string imapUser: ""
|
||||
property string imapPassword: ""
|
||||
|
||||
ActionRow {
|
||||
label: "Add an account"
|
||||
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||
action: "Add account"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
readonly property bool nextcloudReady: /^https?:\/\/.+/.test(root.ncServer.trim())
|
||||
&& root.ncUser.trim() !== "" && root.ncPassword !== ""
|
||||
|
||||
readonly property bool imapReady: /^[^@\s]+@[^@\s]+$/.test(root.imapEmail.trim())
|
||||
&& root.imapHost.trim() !== "" && root.smtpHost.trim() !== ""
|
||||
&& root.imapUser.trim() !== "" && root.imapPassword !== ""
|
||||
|
||||
readonly property string homeSummary: {
|
||||
if (!HomeAssistantConfig.configured)
|
||||
return "Not set up";
|
||||
if (HomeAssistant.phase === "ready") {
|
||||
const named = (HomeAssistant.rooms ?? []).filter(room => String(room.name ?? "") !== "").length;
|
||||
const host = root.hostOf(HomeAssistantConfig.url);
|
||||
return named > 0
|
||||
? host + " · " + named + " room" + (named === 1 ? "" : "s")
|
||||
: host + " · " + HomeAssistant.discoveredCount + " lights";
|
||||
}
|
||||
if (HomeAssistant.phase === "degraded")
|
||||
return root.hostOf(HomeAssistantConfig.url) + " · answering slowly";
|
||||
if (HomeAssistant.phase === "loading")
|
||||
return "Asking " + root.hostOf(HomeAssistantConfig.url) + "…";
|
||||
return root.hostOf(HomeAssistantConfig.url) + " · not answering";
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: OnlineAccounts.accounts
|
||||
readonly property string phoneName: KdeConnect.preferredPhone
|
||||
? String(KdeConnect.preferredPhone.name)
|
||||
: "Phone"
|
||||
|
||||
SettingsCard {
|
||||
id: accountCard
|
||||
required property var modelData
|
||||
readonly property string phoneSummary: {
|
||||
if (!KdeConnect.available)
|
||||
return "Not set up";
|
||||
if (!KdeConnect.preferredPhone)
|
||||
return "KDE Connect · no phone paired yet";
|
||||
const battery = KdeConnect.phoneBattery;
|
||||
const where = KdeConnect.phoneReachable ? "nearby on Wi-Fi" : "not nearby";
|
||||
return battery
|
||||
? "KDE Connect · " + where + " · battery " + battery.charge + "%"
|
||||
: "KDE Connect · " + where;
|
||||
}
|
||||
|
||||
// GOA hands back a preference-ordered chain; this walks it and takes
|
||||
// the first name the active icon theme actually has. Taking the
|
||||
// first name blindly, or the last as a fallback, both looked right
|
||||
// and were not: on Adwaita the tail of these chains ("mail",
|
||||
// "goa-symbolic") does not exist, so a miss would have rendered
|
||||
// nothing at all.
|
||||
icon: {
|
||||
const chain = accountCard.modelData.providerIcons ?? [];
|
||||
for (const name of chain) {
|
||||
if (Quickshell.iconPath(String(name), true) !== "")
|
||||
return String(name);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
iconFallback: "avatar-default-symbolic"
|
||||
// Just the host, because a full URL in a subtitle is mostly scheme and
|
||||
// trailing slash.
|
||||
function hostOf(url: string): string {
|
||||
const text = String(url ?? "").trim();
|
||||
if (text === "")
|
||||
return "Home Assistant";
|
||||
return text.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||||
}
|
||||
|
||||
title: accountCard.modelData.identity || accountCard.modelData.providerName
|
||||
subtitle: accountCard.modelData.needsAttention
|
||||
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
|
||||
: accountCard.modelData.providerName
|
||||
// The single hand-off. Both the OAuth add row and a needs-attention row
|
||||
// arrive here, so this file names a GNOME panel exactly once -- see
|
||||
// gnome-handoff-contract, which allows this one and nothing else.
|
||||
function openProviderDialog(): void {
|
||||
SystemSettings.openGnomePanel("online-accounts");
|
||||
}
|
||||
|
||||
// Only when it is true, because it is the one thing on this page
|
||||
// that needs acting on.
|
||||
ActionRow {
|
||||
visible: accountCard.modelData.needsAttention
|
||||
label: "Sign in again"
|
||||
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts"
|
||||
action: "Sign in"
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
function closeForms(): void {
|
||||
root.openForm = "";
|
||||
root.ncServer = "";
|
||||
root.ncUser = "";
|
||||
root.ncPassword = "";
|
||||
nextcloudPassword.clear();
|
||||
root.imapEmail = "";
|
||||
root.imapHost = "";
|
||||
root.smtpHost = "";
|
||||
root.imapUser = "";
|
||||
root.imapPassword = "";
|
||||
imapPasswordField.clear();
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: accountCard.modelData.services
|
||||
// Every open, not only the first: accounts are added and removed by people
|
||||
// -- sometimes in the very dialog this page hands them to -- so coming back
|
||||
// to this page is exactly when the list is most likely to be stale.
|
||||
Component.onCompleted: {
|
||||
OnlineAccounts.refresh();
|
||||
// The phone row reads a service that is only refreshed when the
|
||||
// Control Center's phone shelf opens, which this page cannot assume
|
||||
// has happened. Home Assistant refreshes itself.
|
||||
KdeConnect.refresh();
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
id: serviceRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
// Two errors, kept apart because they mean different things. A refused
|
||||
// write is about the one thing somebody just did; a listing that failed is
|
||||
// why the card below might be empty. Neither takes the page away.
|
||||
TextRow {
|
||||
visible: OnlineAccounts.writeError !== ""
|
||||
label: "That did not go through"
|
||||
detail: OnlineAccounts.writeError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
label: serviceRow.modelData.label
|
||||
detail: serviceRow.modelData.enabled
|
||||
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account"
|
||||
: "Hidden from applications"
|
||||
controlWidth: 48
|
||||
TextRow {
|
||||
visible: OnlineAccounts.snapshotError !== ""
|
||||
label: "The account list could not be read"
|
||||
detail: OnlineAccounts.snapshotError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: serviceRow.modelData.enabled
|
||||
onToggled: value => OnlineAccounts.setService(
|
||||
accountCard.modelData.path, serviceRow.modelData.key, value)
|
||||
// ── What Panama itself uses ──────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "This desktop"
|
||||
subtitle: "The two services Panama signs in to on its own behalf."
|
||||
|
||||
SettingRow {
|
||||
// The same glyph the My Home page and its Control Center tile use.
|
||||
icon: "\u{F0335}"
|
||||
label: "Home Assistant"
|
||||
detail: root.homeSummary
|
||||
controlWidth: 210
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: HomeAssistant.phase === "ready"
|
||||
width: homeBadge.implicitWidth + 14
|
||||
height: 17
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.ok, 0.1)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.ok, 0.3)
|
||||
|
||||
Text {
|
||||
id: homeBadge
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: "CONNECTED"
|
||||
color: Theme.ok
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Remove this account"
|
||||
detail: "Signs out and removes it from every application that was using it"
|
||||
action: "Remove"
|
||||
divider: false
|
||||
onTriggered: OnlineAccounts.remove(accountCard.modelData.path)
|
||||
SettingsButton {
|
||||
text: HomeAssistantConfig.configured ? "Open Home" : "Set up"
|
||||
onClicked: ShellState.openSettings("my-home")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
// md-cellphone
|
||||
icon: "\u{F011C}"
|
||||
label: root.phoneName
|
||||
detail: root.phoneSummary
|
||||
controlWidth: 210
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: KdeConnect.pairedCount > 0
|
||||
width: phoneBadge.implicitWidth + 14
|
||||
height: 17
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.ok, 0.1)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.ok, 0.3)
|
||||
|
||||
Text {
|
||||
id: phoneBadge
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: "PAIRED"
|
||||
color: Theme.ok
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: KdeConnect.pairedCount > 0 ? "Open Phone" : "Set up"
|
||||
onClicked: ShellState.openSettings("phone")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Everything the applications use ──────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0
|
||||
title: "Add another account"
|
||||
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
|
||||
title: "Accounts"
|
||||
subtitle: "Signed in through GNOME Online Accounts, which is what mail, calendar, contacts and file managers read."
|
||||
|
||||
TextRow {
|
||||
visible: !OnlineAccounts.scanned
|
||||
label: "Reading the account list…"
|
||||
detail: ""
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: OnlineAccounts.scanned && OnlineAccounts.accounts.length === 0
|
||||
label: "No accounts yet"
|
||||
detail: "Adding one lets mail, calendar, contacts and file managers share a single sign-in."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: OnlineAccounts.accounts
|
||||
|
||||
delegate: OnlineAccountRow {
|
||||
id: accountRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
account: accountRow.modelData
|
||||
busy: OnlineAccounts.busy
|
||||
// Said here rather than in the row, next to the hand-off it
|
||||
// runs: GOA knows the token expired and nothing outside its own
|
||||
// panel says so, which is how an account quietly stops syncing
|
||||
// for weeks.
|
||||
signInAction: "Sign in again"
|
||||
expanded: root.expandedAccount === String(accountRow.modelData.path ?? "")
|
||||
confirmingRemoval: root.confirmingRemoval === String(accountRow.modelData.path ?? "")
|
||||
divider: accountRow.index < OnlineAccounts.accounts.length - 1
|
||||
|
||||
onActivated: {
|
||||
const path = String(accountRow.modelData.path ?? "");
|
||||
root.expandedAccount = accountRow.expanded ? "" : path;
|
||||
root.confirmingRemoval = "";
|
||||
}
|
||||
onServiceToggled: (key, enabled) => OnlineAccounts.setService(
|
||||
String(accountRow.modelData.path ?? ""), key, enabled)
|
||||
onRemoveArmed: root.confirmingRemoval = String(accountRow.modelData.path ?? "")
|
||||
onRemoveCancelled: root.confirmingRemoval = ""
|
||||
onRemoveConfirmed: {
|
||||
root.confirmingRemoval = "";
|
||||
root.expandedAccount = "";
|
||||
OnlineAccounts.remove(String(accountRow.modelData.path ?? ""));
|
||||
}
|
||||
onSignInAgainRequested: root.openProviderDialog()
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Add an account"
|
||||
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||
action: "Add account"
|
||||
label: "Check again"
|
||||
detail: "Reads the list back from GOA, for when an account changed somewhere else"
|
||||
action: OnlineAccounts.busy ? "Checking…" : "Refresh"
|
||||
enabled: !OnlineAccounts.busy
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
onTriggered: OnlineAccounts.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adding one ───────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Add an account"
|
||||
|
||||
ActionRow {
|
||||
label: "Nextcloud"
|
||||
detail: "Server, username, password — signed in right here"
|
||||
action: root.openForm === "nextcloud" ? "Cancel" : "Add…"
|
||||
enabled: !OnlineAccounts.busy
|
||||
divider: root.openForm === "nextcloud"
|
||||
onTriggered: {
|
||||
const wasOpen = root.openForm === "nextcloud";
|
||||
root.closeForms();
|
||||
if (!wasOpen)
|
||||
root.openForm = "nextcloud";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.openForm === "nextcloud"
|
||||
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Server"
|
||||
detail: "The address you open Nextcloud at, scheme and all"
|
||||
placeholder: "https://cloud.example.org"
|
||||
text: root.ncServer
|
||||
enabled: !OnlineAccounts.busy
|
||||
onEdited: value => root.ncServer = value
|
||||
}
|
||||
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Username"
|
||||
placeholder: "you"
|
||||
text: root.ncUser
|
||||
enabled: !OnlineAccounts.busy
|
||||
onEdited: value => root.ncUser = value
|
||||
}
|
||||
|
||||
SecretFieldRow {
|
||||
id: nextcloudPassword
|
||||
|
||||
width: parent.width
|
||||
label: "Password"
|
||||
detail: "Your password, or an app password — GOA signs in with Basic auth, so either works, and Nextcloud's Security settings issue an app password per device. It is handed over on stdin and never becomes a command-line argument."
|
||||
placeholder: "Password or app password"
|
||||
enabled: !OnlineAccounts.busy
|
||||
onChanged: value => root.ncPassword = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Sign in to Nextcloud"
|
||||
detail: root.nextcloudReady
|
||||
? "Files, calendar and contacts arrive with the account"
|
||||
: "A server address, a username and an app password are needed first"
|
||||
action: OnlineAccounts.busy ? "Signing in…" : "Sign in"
|
||||
enabled: root.nextcloudReady && !OnlineAccounts.busy
|
||||
divider: false
|
||||
onTriggered: {
|
||||
OnlineAccounts.addNextcloud(root.ncServer.trim(), root.ncUser.trim(),
|
||||
root.ncPassword);
|
||||
root.closeForms();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Mail (IMAP & SMTP)"
|
||||
detail: "Server details and password — native form"
|
||||
action: root.openForm === "imap" ? "Cancel" : "Add…"
|
||||
enabled: !OnlineAccounts.busy
|
||||
divider: root.openForm === "imap"
|
||||
onTriggered: {
|
||||
const wasOpen = root.openForm === "imap";
|
||||
root.closeForms();
|
||||
if (!wasOpen)
|
||||
root.openForm = "imap";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.openForm === "imap"
|
||||
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Email address"
|
||||
placeholder: "[email protected]"
|
||||
text: root.imapEmail
|
||||
enabled: !OnlineAccounts.busy
|
||||
onEdited: value => root.imapEmail = value
|
||||
}
|
||||
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Incoming server"
|
||||
detail: "IMAP"
|
||||
placeholder: "imap.example.com"
|
||||
text: root.imapHost
|
||||
enabled: !OnlineAccounts.busy
|
||||
onEdited: value => root.imapHost = value
|
||||
}
|
||||
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Outgoing server"
|
||||
detail: "SMTP"
|
||||
placeholder: "smtp.example.com"
|
||||
text: root.smtpHost
|
||||
enabled: !OnlineAccounts.busy
|
||||
onEdited: value => root.smtpHost = value
|
||||
}
|
||||
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Username"
|
||||
detail: "Often the address again, sometimes not"
|
||||
placeholder: "[email protected]"
|
||||
text: root.imapUser
|
||||
enabled: !OnlineAccounts.busy
|
||||
onEdited: value => root.imapUser = value
|
||||
}
|
||||
|
||||
SecretFieldRow {
|
||||
id: imapPasswordField
|
||||
|
||||
width: parent.width
|
||||
label: "Password"
|
||||
detail: "Handed over on stdin, never as a command-line argument"
|
||||
enabled: !OnlineAccounts.busy
|
||||
onChanged: value => root.imapPassword = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Add this mail account"
|
||||
detail: root.imapReady
|
||||
? "GOA stores it and mail clients pick it up from there"
|
||||
: "An address, both servers, a username and a password are needed first"
|
||||
action: OnlineAccounts.busy ? "Adding…" : "Add account"
|
||||
enabled: root.imapReady && !OnlineAccounts.busy
|
||||
divider: false
|
||||
onTriggered: {
|
||||
OnlineAccounts.addImap(root.imapEmail.trim(), root.imapHost.trim(),
|
||||
root.smtpHost.trim(), root.imapUser.trim(),
|
||||
root.imapPassword);
|
||||
root.closeForms();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Google or Microsoft"
|
||||
detail: "OAuth needs the provider's own browser sign-in, which only GOA's dialog can run — the one thing this page hands off"
|
||||
action: "Open GNOME dialog…"
|
||||
divider: false
|
||||
onTriggered: root.openProviderDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Four segments saying how much a password has going for it.
|
||||
//
|
||||
// The score is computed here, from length and which character classes appear,
|
||||
// and from nothing else. There is no dictionary, no breach list and no network
|
||||
// call, so the row claims exactly what it can back up: it is headed "Strength"
|
||||
// and says in its own detail line that it only measures shape. A meter that
|
||||
// says "Strong" about a leaked password is worse than no meter, so this one
|
||||
// never uses that word.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property string password: ""
|
||||
|
||||
readonly property int score: {
|
||||
const value = root.password;
|
||||
if (value === "")
|
||||
return 0;
|
||||
|
||||
let classes = 0;
|
||||
if (/[a-z]/.test(value))
|
||||
classes += 1;
|
||||
if (/[A-Z]/.test(value))
|
||||
classes += 1;
|
||||
if (/[0-9]/.test(value))
|
||||
classes += 1;
|
||||
if (/[^A-Za-z0-9]/.test(value))
|
||||
classes += 1;
|
||||
|
||||
let points = Math.max(0, classes - 1);
|
||||
if (value.length >= 8)
|
||||
points += 1;
|
||||
if (value.length >= 12)
|
||||
points += 1;
|
||||
if (value.length >= 16)
|
||||
points += 1;
|
||||
|
||||
if (value.length < 8)
|
||||
return 1;
|
||||
return Math.max(1, Math.min(4, points));
|
||||
}
|
||||
|
||||
readonly property color tone: {
|
||||
if (root.score <= 1)
|
||||
return Theme.danger;
|
||||
if (root.score === 2)
|
||||
return Theme.warn;
|
||||
return Theme.ok;
|
||||
}
|
||||
|
||||
label: "Strength"
|
||||
detail: "Length and variety of characters, measured here — nothing is sent anywhere, and nothing is checked against known passwords"
|
||||
controlWidth: 150
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
model: 4
|
||||
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
|
||||
width: 32
|
||||
height: 5
|
||||
radius: 2.5
|
||||
color: index < root.score ? root.tone : Theme.alpha(Theme.fg, 0.12)
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// A row whose trailing control is a masked field that can be emptied on demand.
|
||||
//
|
||||
// PasswordRow already reports every keystroke, which is what comparing two
|
||||
// entries as they are typed needs. What it cannot do is forget: its field is
|
||||
// private, so a form that closed left the typed password sitting in a hidden
|
||||
// TextInput for the rest of the session.
|
||||
//
|
||||
// This exposes `clear()` so the page that owns a form can empty it when the
|
||||
// form succeeds or closes, which is the only difference between the two.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property string placeholder: "Password"
|
||||
property bool enabled: true
|
||||
|
||||
signal changed(value: string)
|
||||
signal accepted
|
||||
|
||||
controlWidth: 220
|
||||
|
||||
function clear(): void {
|
||||
field.clear();
|
||||
root.changed("");
|
||||
}
|
||||
|
||||
PasswordField {
|
||||
id: field
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.controlWidth
|
||||
enabled: root.enabled
|
||||
opacity: root.enabled ? 1 : 0.5
|
||||
placeholder: root.placeholder
|
||||
onTextChanged: root.changed(field.text)
|
||||
onAccepted: root.accepted()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// The pictures already on the machine, offered before the file chooser.
|
||||
//
|
||||
// /usr/share/pixmaps/faces is what GNOME's Users panel shows first, and it is
|
||||
// the answer for most people: a picture that is already the right shape, needs
|
||||
// no cropping, and costs one click. The file chooser stays right beside it for
|
||||
// everyone else, so choosing a photo is not a second-class path -- it is simply
|
||||
// the one that needs a crop step afterwards.
|
||||
//
|
||||
// An empty gallery is a normal state on a machine with no faces package
|
||||
// installed, and says so rather than rendering an empty strip.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// [{ name, path }] -- whatever the helper found, in the order it found it.
|
||||
property var avatars: []
|
||||
property bool enabled: true
|
||||
|
||||
signal picked(path: string)
|
||||
signal fileRequested
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 10
|
||||
bottomPadding: 6
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: root.avatars
|
||||
|
||||
delegate: Item {
|
||||
id: tile
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string path: String(tile.modelData?.path ?? "")
|
||||
|
||||
width: 46
|
||||
height: 46
|
||||
|
||||
ClippingRectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
radius: width / 2
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: tile.path === "" ? "" : "file://" + tile.path
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
sourceSize.width: 96
|
||||
sourceSize.height: 96
|
||||
}
|
||||
}
|
||||
|
||||
// The ring is its own item rather than a border on the clipping
|
||||
// rectangle: that one clips its children and does not carry a
|
||||
// border group of its own.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
border.width: tileHover.hovered ? 2 : 1
|
||||
border.color: tileHover.hovered
|
||||
? Theme.alpha(Theme.accent, 0.7)
|
||||
: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: tileHover
|
||||
enabled: root.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: root.enabled && tile.path !== ""
|
||||
onTapped: root.picked(tile.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapped so it sits on the tiles' centre line rather than on their top
|
||||
// edge: Flow lays children out by their own height, and the button is
|
||||
// fifteen pixels shorter than a picture.
|
||||
Item {
|
||||
width: chooseFile.implicitWidth
|
||||
height: 46
|
||||
|
||||
SettingsButton {
|
||||
id: chooseFile
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Choose a file…"
|
||||
enabled: root.enabled
|
||||
onClicked: root.fileRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.avatars.length === 0
|
||||
text: "No pictures are installed on this machine, so a file of your own is the only choice here."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -124,3 +124,9 @@ ThemeEditorWells 1.0 ThemeEditorWells.qml
|
||||
ThemeSaturationRow 1.0 ThemeSaturationRow.qml
|
||||
ThemeStartChips 1.0 ThemeStartChips.qml
|
||||
ThemeSaveRow 1.0 ThemeSaveRow.qml
|
||||
LiveFieldRow 1.0 LiveFieldRow.qml
|
||||
SecretFieldRow 1.0 SecretFieldRow.qml
|
||||
PasswordStrengthRow 1.0 PasswordStrengthRow.qml
|
||||
StockAvatarPicker 1.0 StockAvatarPicker.qml
|
||||
FingerprintEnrollPanel 1.0 FingerprintEnrollPanel.qml
|
||||
OnlineAccountRow 1.0 OnlineAccountRow.qml
|
||||
|
||||
Reference in New Issue
Block a user