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:
Gabriel Brown
2026-08-24 18:55:38 -04:00
parent 5a0643357f
commit 4ec8bd94d9
25 changed files with 4713 additions and 407 deletions
@@ -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