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
+348 -11
View File
@@ -12,22 +12,45 @@ turning individual services on and off, and removing an account. That is the
whole Online Accounts panel apart from one OAuth handshake.
Adding an account is the part that splits. The daemon's AddAccount takes
credentials as an ARGUMENT -- it stores them, it does not obtain them. For
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a
password, which a settings app can reasonably collect. For Google it is an OAuth
token, and the code that runs that exchange lives in libgoa-backend, which
Fedora ships without a GIR binding -- reachable from C only. Reimplementing it
would mean our own Google client credentials. So Google sign-in is handed to
GNOME's panel, and only the sign-in.
credentials as an ARGUMENT -- it stores them, it does not obtain them, and every
part of that storing happens inside goa-daemon, which does have the backend.
For password-based providers (Nextcloud, IMAP) that argument is a username and a
password, which a settings app can reasonably collect, so those are added here.
For Google it is an OAuth token, and the code that runs that exchange lives in
libgoa-backend, which Fedora ships without a GIR binding -- reachable from C
only. Reimplementing it would mean our own Google client credentials. So OAuth
sign-in is handed to GNOME's panel, and only the sign-in.
A password reaches this script on stdin and nowhere else. argv is world-readable
through /proc, so a password passed as an argument is published to every process
on the machine.
Usage:
panama-accounts list
panama-accounts list | snapshot
panama-accounts set <object-path> <service> <true|false>
panama-accounts remove <object-path>
panama-accounts add-nextcloud SERVER USERNAME (password on stdin)
panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME (password on stdin)
"""
import json
import os
import re
import sys
import urllib.parse
class BoundaryError(RuntimeError):
"""A user-visible validation or sign-in failure."""
# A canned GOA, for contract runs: a JSON file in the shape `list` prints. With
# it set, nothing here imports gi or touches the session bus, and every change a
# verb would have made is appended to PANAMA_ACCOUNTS_LOG instead -- with the
# password replaced by its length, so a contract can prove it was read from
# stdin without a secret ever reaching a file.
FIXTURE_ENV = "PANAMA_ACCOUNTS_FIXTURE"
LOG_ENV = "PANAMA_ACCOUNTS_LOG"
# Every service GOA models. The account object carries one interface per service
# it supports, so presence of the interface is what "this account can do mail"
@@ -111,8 +134,319 @@ def find(client, path):
return None
# ── Adding a password account ────────────────────────────────────────────────
#
# The keys below are not invented. They are the keys goa-daemon writes into
# ~/.config/goa-1.0/accounts.conf, which is to say the ones each provider's
# build_object reads back -- taken from GOA 3.58's goaowncloudprovider.c and
# goaimapsmtpprovider.c, and confirmed against the accounts already on this
# machine. A key GOA does not recognize is silently ignored, so getting one
# wrong produces an account that exists and does nothing.
def read_password() -> str:
"""The password, from stdin. Never an argument, at any point in the chain."""
secret = sys.stdin.buffer.read()
# A trailing newline from a pipe is not part of the password.
if secret.endswith(b"\n"):
secret = secret[:-1]
if not secret:
raise BoundaryError("No password was provided.")
return secret.decode("utf-8", "surrogateescape")
# RFC 1123 hostname (or a dotted IPv4). These strings end up in accounts.conf
# and in URIs other processes fetch, so a shell metacharacter or a space is a
# malformed address whichever way it got here -- refuse it at the boundary.
HOSTNAME = re.compile(
r"^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
r"(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$")
def valid_host(text: str) -> bool:
return bool(text) and len(text) <= 253 and bool(HOSTNAME.match(text))
def nextcloud_uris(server: str) -> tuple[str, str, str]:
"""(WebDAV URI, DAV URI, host) for a Nextcloud address.
`remote.php/webdav` and `remote.php/dav` are Nextcloud's fixed layout, and
are exactly what GOA's own provider derives from the server it was given.
"""
text = (server or "").strip()
if not text:
raise BoundaryError("Enter the address of your Nextcloud server.")
if "://" not in text:
text = "https://" + text
parsed = urllib.parse.urlsplit(text)
if parsed.scheme not in ("http", "https"):
raise BoundaryError("A server address starts with https://.")
if not parsed.hostname or not valid_host(parsed.hostname):
raise BoundaryError("That is not a server address.")
# urlsplit is permissive about what follows the host; a path is fine, but
# spaces or shell punctuation anywhere in the address are not an URL that
# a Nextcloud server has.
if re.search(r"[\s;|&`$<>\"']", text):
raise BoundaryError("That is not a server address.")
base = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
return base + "/remote.php/webdav/", base + "/remote.php/dav/", parsed.hostname
def nextcloud_account(server: str, username: str) -> tuple[str, str, dict]:
"""(identity, presentation identity, details). Validation and nothing else,
so the same refusals run whether or not there is a GOA to talk to."""
identity = (username or "").strip()
if not identity:
raise BoundaryError("Enter the user name for that server.")
webdav, dav, host = nextcloud_uris(server)
return (
identity,
# What the account is shown as. A bare user name says nothing about
# which server it is on, and people keep more than one.
identity if "@" in identity else f"{identity}@{host}",
{
"Uri": webdav,
"FilesEnabled": "true",
"CalendarEnabled": "true",
"CalDavUri": dav,
"ContactsEnabled": "true",
"CardDavUri": dav,
"AcceptSslErrors": "false",
},
)
def add_nextcloud(server: str, username: str, password: str) -> str:
from gi.repository import GLib
identity, presentation, details = nextcloud_account(server, username)
return add_account("owncloud", identity, presentation,
{"password": GLib.Variant("s", password)},
details, "Nextcloud")
def imap_account(email: str, imap_host: str, smtp_host: str,
username: str) -> tuple[str, str, dict]:
address = (email or "").strip()
if "@" not in address or address.startswith("@") or address.endswith("@"):
raise BoundaryError("Enter the email address for this account.")
imap = (imap_host or "").strip()
smtp = (smtp_host or "").strip()
if not imap or not smtp:
raise BoundaryError("Enter both the incoming and outgoing server.")
if not valid_host(imap) or not valid_host(smtp):
raise BoundaryError("Mail servers are host names, like imap.example.org.")
identity = (username or "").strip() or address
# The modern defaults, and the ones every mail provider this is likely to
# meet uses: IMAP over TLS on 993, submission with STARTTLS on 587. GOA
# offers a dropdown for the other combinations; a settings page that asked
# four encryption questions to add a mailbox would be worse than one that
# gets it right and lets GNOME's panel handle the unusual server.
return (
address, address,
{
"Enabled": "true",
"EmailAddress": address,
"Name": address,
"ImapHost": imap,
"ImapUserName": identity,
"ImapUseSsl": "true",
"ImapUseTls": "false",
"ImapAcceptSslErrors": "false",
"SmtpHost": smtp,
"SmtpUseAuth": "true",
"SmtpUserName": identity,
"SmtpAuthLogin": "false",
"SmtpAuthPlain": "true",
"SmtpUseSsl": "false",
"SmtpUseTls": "true",
"SmtpAcceptSslErrors": "false",
},
)
def add_imap(email: str, imap_host: str, smtp_host: str, username: str,
password: str) -> str:
from gi.repository import GLib
identity, presentation, details = imap_account(email, imap_host, smtp_host, username)
# One password for both servers: submission almost always takes the same
# credentials as the mailbox, and asking twice for the same secret is how a
# form gets abandoned. GOA stores them under separate keys regardless.
return add_account("imap_smtp", identity, presentation,
{"imap-password": GLib.Variant("s", password),
"smtp-password": GLib.Variant("s", password)},
details, "mail")
def add_account(provider: str, identity: str, presentation: str,
credentials: dict, details: dict, label: str) -> str:
"""Hand GOA a new account, then make it prove the credentials work.
The daemon does not check what it is given -- it writes the account out and
stores the password. Without the second step a mistyped password produces an
account that exists, looks fine, and never syncs, which is the failure this
page is supposed to be able to explain.
"""
from gi.repository import GLib
client = load_client()
manager = client.get_manager()
if manager is None:
raise BoundaryError("GNOME Online Accounts is not answering.")
try:
if not manager.call_is_supported_provider_sync(provider, None):
raise BoundaryError(f"This system cannot add {label} accounts.")
path = manager.call_add_account_sync(
provider, identity, presentation,
GLib.Variant("a{sv}", credentials),
GLib.Variant("a{ss}", details), None)
except GLib.Error as failure:
raise BoundaryError(clean(failure.message)) from failure
verify(path, label)
return path
def verify(path: str, label: str) -> None:
"""Sign in once, and undo the account if that fails."""
from gi.repository import GLib
# A fresh client: the one that made the call has not necessarily seen the
# object appear yet, and this is the cheapest way to wait for it properly.
obj = find(load_client(), path)
if obj is None:
# It was added; this process simply cannot see it yet. Reporting a
# failure here would be a lie, and the account list will show it.
return
account = obj.get_account()
try:
account.set_default_timeout(60000)
account.call_ensure_credentials_sync(None)
except GLib.Error as failure:
try:
account.call_remove_sync(None)
except GLib.Error:
pass
raise BoundaryError(
f"Those {label} details were not accepted: {clean(failure.message)}"
) from failure
def clean(message: str) -> str:
"""The useful sentence of a GOA error, without the D-Bus type prefix."""
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", str(message or "")).strip()
return trimmed.splitlines()[0][:200] if trimmed else "That did not work."
# ── The canned GOA ───────────────────────────────────────────────────────────
def fixture() -> dict | None:
path = os.environ.get(FIXTURE_ENV)
if not path:
return None
try:
with open(path, encoding="utf-8") as handle:
listing = json.load(handle)
except (OSError, ValueError) as failure:
return {"accounts": [], "error": f"The accounts fixture could not be read: {failure}"}
listing.setdefault("accounts", [])
listing.setdefault("error", "")
return listing
def record(verb: str, arguments: list, password: str | None = None) -> None:
path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
if not path:
return
entry = {"verb": verb, "arguments": arguments}
if password is not None:
# Its length, never the thing itself: the point of the log is to prove
# the password came in on stdin, not to keep a copy of it.
entry["passwordBytes"] = len(password)
try:
with open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, separators=(",", ":")) + "\n")
except OSError:
pass
def added(operation) -> int:
"""Run an add, and answer with the fresh account list either way.
Errors come back inside that list rather than on stderr, so the page has
both facts -- what went wrong and what is there now -- from one read.
"""
error = ""
try:
operation()
except BoundaryError as failure:
error = str(failure)
try:
accounts = [describe(obj) for obj in load_client().get_accounts()]
except Exception: # noqa: BLE001 - the error above is the one worth saying
accounts = []
print(json.dumps({"accounts": accounts, "error": error}))
return 0
def replay(action: str, arguments: list, canned: dict) -> int:
"""Every verb, against the canned GOA. No bus, no gi, no account changed."""
error = ""
try:
if action in ("list", "snapshot"):
pass
elif action in ("set", "remove"):
record(action, arguments[1:])
elif action == "add-nextcloud":
if len(arguments) != 3:
raise BoundaryError("Usage: add-nextcloud SERVER USERNAME")
password = read_password()
nextcloud_account(arguments[1], arguments[2])
record(action, arguments[1:], password)
elif action == "add-imap":
if len(arguments) != 5:
raise BoundaryError("Usage: add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME")
password = read_password()
imap_account(arguments[1], arguments[2], arguments[3], arguments[4])
record(action, arguments[1:], password)
else:
raise BoundaryError(f"Unknown command {action!r}.")
except BoundaryError as failure:
error = str(failure)
print(json.dumps({**canned, "error": error or canned.get("error", "")}))
return 0
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "list"
arguments = sys.argv[1:]
action = arguments[0] if arguments else "list"
canned = fixture()
if canned is not None:
return replay(action, arguments, canned)
if action == "add-nextcloud":
if len(arguments) != 3:
print("usage: panama-accounts add-nextcloud SERVER USERNAME", file=sys.stderr)
return 2
return added(lambda: add_nextcloud(arguments[1], arguments[2], read_password()))
if action == "add-imap":
if len(arguments) != 5:
print("usage: panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME",
file=sys.stderr)
return 2
return added(lambda: add_imap(arguments[1], arguments[2], arguments[3],
arguments[4], read_password()))
try:
client = load_client()
@@ -124,7 +458,9 @@ def main():
}))
return 0
if action == "list":
# "snapshot" is what every other Panama helper calls this, and what the
# service asks for; "list" is what this one has always been called.
if action in ("list", "snapshot"):
print(json.dumps({
"accounts": [describe(obj) for obj in client.get_accounts()],
"error": "",
@@ -162,7 +498,8 @@ def main():
obj.get_account().call_remove_sync(None)
return 0
print("usage: panama-accounts [list|set|remove]", file=sys.stderr)
print("usage: panama-accounts [list|snapshot|set|remove|add-nextcloud|add-imap]",
file=sys.stderr)
return 2
+478 -77
View File
@@ -1,89 +1,490 @@
#!/usr/bin/env bash
#!/usr/bin/env python3
# Fingerprint state and the one privileged switch, for the Users page.
#
# Two independent facts make a working fingerprint login, and conflating them
# is how the feature usually confuses people: fprintd must hold at least one
# enrolled print (GNOME's Users panel owns that dialog, and Panama hands off
# to it), and PAM must be told to ask the reader at all, which on Fedora is
# authselect's `with-fingerprint` feature. This helper reports both and can
# flip the second.
#
# Usage:
# panama-fingerprint status -> {"reader":bool,"readerName":"","enrolled":[],"pamEnabled":bool,"error":""}
# panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit)
#
# authselect is baseline Fedora (it manages PAM for the whole install), and
# fprintd ships with Workstation; a machine with neither simply reports no
# reader, which hides the card.
"""Fingerprint login: the enrolled prints, and the one privileged switch.
set -uo pipefail
Two independent facts make a working fingerprint login, and conflating them is
how the feature usually confuses people. fprintd must hold at least one enrolled
print, and PAM must be told to ask the reader at all, which on Fedora is
authselect's `with-fingerprint` feature. This helper reports both, enrolls and
removes prints, and can flip the second.
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
Both facts are reported unconditionally, including on a machine with no reader.
The state that used to be invisible -- the feature switched on with nothing
enrolled and no reader attached -- is exactly the state someone needs to see and
turn off, and reporting `pamEnabled: false` because there was no reader to ask
made it unreachable.
emit() {
jq -cn \
--argjson reader "$1" \
--arg readerName "$2" \
--argjson enrolled "$3" \
--argjson pamEnabled "$4" \
--arg error "$5" \
'{reader: $reader, readerName: $readerName, enrolled: $enrolled,
pamEnabled: $pamEnabled, error: $error}'
}
Enrollment talks to fprintd over D-Bus (net.reactivated.Fprint) rather than
handing the person to GNOME's Users panel: Claim, EnrollStart, then one
`EnrollStatus` signal per touch until the device says it is done. Progress is
printed as one JSON object per line, the same streaming shape panama-dictate's
setup uses, so the page can count touches while they happen. That signal loop is
why this is Python and no longer bash -- `status` and `set-unlock` still shell
out to exactly the same tools, and answer in exactly the same shapes.
cmd_status() {
command -v fprintd-list >/dev/null 2>&1 || { emit false "" '[]' false ""; return; }
panama-fingerprint status
panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit)
panama-fingerprint enroll FINGER (streams {stage,done,total,result})
panama-fingerprint remove FINGER
panama-fingerprint remove-all
# fprintd-list both answers "is there a reader" (fprintd is bus-activated,
# so this also copes with the daemon not running yet) and names the
# enrolled fingers in one call.
local listing
# LC_ALL=C: the "no devices" match below reads fprintd's message, and a
# translated daemon would turn every readerless non-English machine into
# a permanent error card.
if ! listing="$(LC_ALL=C timeout 10 fprintd-list "$USER" 2>&1)"; then
# "No devices available" is the normal no-reader machine; anything
# else is a real problem worth surfacing.
if grep -qi 'no devices' <<<"$listing"; then
emit false "" '[]' false ""
else
emit false "" '[]' false "fprintd did not answer: $(head -1 <<<"$listing")"
fi
authselect is baseline Fedora (it manages PAM for the whole install) and fprintd
ships with Workstation; a machine with neither reports no reader and no feature.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import time
FPRINT = "net.reactivated.Fprint"
MANAGER_PATH = "/net/reactivated/Fprint/Manager"
MANAGER_INTERFACE = "net.reactivated.Fprint.Manager"
DEVICE_INTERFACE = "net.reactivated.Fprint.Device"
# The authselect feature that decides whether PAM asks the reader at unlock.
FEATURE = "with-fingerprint"
# How long a reader may sit waiting for a finger before enrollment is given up
# on. The page has a Cancel button; this is only for a session left open.
IDLE_TIMEOUT_SECONDS = 90
# A canned fprintd, for contract runs. Points at a JSON file; see replay() for
# the shape. Every call that would have gone to the bus is appended to
# PANAMA_FINGERPRINT_LOG instead, so a test can pin the order of
# Claim / EnrollStart / EnrollStop / Release without a reader in the room.
FIXTURE_ENV = "PANAMA_FINGERPRINT_FIXTURE"
LOG_ENV = "PANAMA_FINGERPRINT_LOG"
PANAMA_PATH = os.environ.get("PANAMA_PATH") or os.path.expanduser("~/.local/share/Panama")
# fprintd's own vocabulary, only far enough to reject nonsense before it becomes
# a D-Bus error. What a finger is CALLED is presented by services/Fingerprint.qml,
# which is the single place that decides how these read.
FINGER = re.compile(r"^(left|right)-(thumb|(index|middle|ring|little)-finger)$")
class BoundaryError(RuntimeError):
"""A user-visible failure: no reader, a refused claim, a bad finger name."""
# ── status ───────────────────────────────────────────────────────────────────
def unlock_feature_enabled() -> bool:
"""Whether PAM has been told to ask the reader.
Asked unconditionally. This is a property of the PAM configuration and has
nothing to do with whether a reader is plugged in, which is the whole point:
the feature left on with no reader is a state someone has to be able to see.
"""
if not shutil.which("authselect"):
return False
try:
current = subprocess.run(["authselect", "current"], capture_output=True,
text=True, timeout=10, check=False)
except (OSError, subprocess.SubprocessError):
return False
return FEATURE in (current.stdout or "")
def status() -> dict:
reader, name, enrolled, error = False, "", [], ""
if shutil.which("fprintd-list"):
# fprintd-list answers "is there a reader" (fprintd is bus-activated, so
# this copes with the daemon not running yet) and names the enrolled
# fingers in one call.
#
# LC_ALL=C: the "no devices" match below reads fprintd's message, and a
# translated daemon would turn every readerless non-English machine into
# a permanent error card.
environment = dict(os.environ, LC_ALL="C")
try:
listing = subprocess.run(["fprintd-list", os.environ.get("USER", "")],
capture_output=True, text=True, timeout=10,
check=False, env=environment)
output = (listing.stdout or "") + (listing.stderr or "")
except (OSError, subprocess.SubprocessError) as failure:
listing, output = None, str(failure)
if listing is None or listing.returncode != 0:
# "No devices available" is the normal no-reader machine; anything
# else is a real problem worth surfacing.
if "no devices" not in output.lower():
first = next((line for line in output.splitlines() if line.strip()), "")
error = f"fprintd did not answer: {first}"
else:
reader = True
# "Fingerprints for user gib on FocalTech ... (press):" carries the
# reader product name; " - #0: right-index-finger" the enrollment.
match = re.search(r"^Fingerprints for user \S+ on (.*) \(\w*\):$",
output, re.MULTILINE)
name = match.group(1) if match else ""
enrolled = re.findall(r"^ *- #\d+: (.+)$", output, re.MULTILINE)
feature = unlock_feature_enabled()
return {
"reader": reader,
"readerName": name,
"enrolled": enrolled,
# Two names for one fact, on purpose: `pamEnabled` is what this helper
# has always called it, `unlockFeatureEnabled` is what it is.
"pamEnabled": feature,
"unlockFeatureEnabled": feature,
"error": error,
}
# ── the privileged switch ────────────────────────────────────────────────────
def set_unlock(state: str) -> int:
if state == "on":
verb = "enable-feature"
reason = ("Turning on fingerprint login: telling PAM (via authselect) to "
"ask the fingerprint reader when unlocking")
elif state == "off":
verb = "disable-feature"
reason = ("Turning off fingerprint login: telling PAM (via authselect) to "
"stop asking the fingerprint reader")
else:
print("panama-fingerprint set-unlock takes on|off", file=sys.stderr)
return 1
escalate = os.path.join(PANAMA_PATH, "bin", "panama-sudo")
prefix = ([escalate, "--reason", reason, "--"]
if os.access(escalate, os.X_OK) else ["sudo"])
return subprocess.run(prefix + ["authselect", verb, FEATURE], check=False).returncode
# ── talking to fprintd ───────────────────────────────────────────────────────
def emit(**fields) -> None:
"""One JSON object, one line, flushed. The page reads these as they arrive."""
print(json.dumps(fields, separators=(",", ":")), flush=True)
def log_call(method: str, *arguments) -> None:
"""Record a call the fixture stood in for, so a contract can pin the order."""
path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
if not path:
return
fi
try:
with open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps({"method": method, "arguments": list(arguments)},
separators=(",", ":")) + "\n")
except OSError:
pass
# "Fingerprints for user gib on FocalTech ... (press):" carries the reader
# product name; " - #0: right-index-finger" lines carry the enrollment.
local name enrolled pam
name="$(sed -n 's/^Fingerprints for user [^ ]* on \(.*\) (\w*):$/\1/p' <<<"$listing" | head -1)"
enrolled="$(sed -n 's/^ *- #[0-9]*: //p' <<<"$listing" | jq -Rn '[inputs]')"
pam=false
authselect current 2>/dev/null | grep -q 'with-fingerprint' && pam=true
emit true "$name" "$enrolled" "$pam" ""
}
def fixture() -> dict | None:
"""The canned fprintd, or None when there is a real bus to talk to.
cmd_set_unlock() {
local verb reason
case "$1" in
on) verb=enable-feature
reason="Turning on fingerprint login: telling PAM (via authselect) to ask the fingerprint reader when unlocking" ;;
off) verb=disable-feature
reason="Turning off fingerprint login: telling PAM (via authselect) to stop asking the fingerprint reader" ;;
*) echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1 ;;
esac
{"enrollStages": 5,
"results": ["enroll-stage-passed", "enroll-retry-scan-too-short", ...],
"error": ""} <- non-empty stands in for a refused claim
"""
path = os.environ.get(FIXTURE_ENV)
if not path:
return None
try:
with open(path, encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError) as failure:
raise BoundaryError(f"The fingerprint fixture could not be read: {failure}")
local sudo_cmd=(sudo)
[[ -x "$PANAMA_PATH/bin/panama-sudo" ]] && sudo_cmd=(
"$PANAMA_PATH/bin/panama-sudo" --reason "$reason" --
)
"${sudo_cmd[@]}" authselect "$verb" with-fingerprint
}
case "${1:-}" in
status) cmd_status ;;
set-unlock) [[ -n "${2:-}" ]] || { echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1; }
cmd_set_unlock "$2" ;;
*) echo 'usage: panama-fingerprint status | set-unlock on|off' >&2; exit 1 ;;
esac
def bus():
try:
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
return Gio, GLib, Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
except Exception as failure: # noqa: BLE001 - no bus is a legitimate state
raise BoundaryError("The fingerprint service is not answering.") from failure
def call(path: str, interface: str, method: str, parameters=None, reply=None):
Gio, GLib, connection = bus()
try:
result = connection.call_sync(
FPRINT, path, interface, method, parameters,
GLib.VariantType(reply) if reply else None,
Gio.DBusCallFlags.NONE, 30000, None)
except Exception as failure: # noqa: BLE001
raise BoundaryError(_clean(str(failure))) from failure
return result.unpack() if result is not None else None
def _clean(message: str) -> str:
"""The useful sentence out of a D-Bus error, without the type prefix."""
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", message).strip()
if "no devices" in trimmed.lower():
return "No fingerprint reader is connected."
if "permission denied" in trimmed.lower() or "not authorized" in trimmed.lower():
return "That was not authorized."
if "already in use" in trimmed.lower() or "claimed" in trimmed.lower():
return "The fingerprint reader is busy with something else."
return trimmed.splitlines()[0][:200] if trimmed else "The fingerprint reader failed."
def default_device() -> str:
return call(MANAGER_PATH, MANAGER_INTERFACE, "GetDefaultDevice", None, "(o)")[0]
def enroll_stages(device: str) -> int:
Gio, GLib, connection = bus()
try:
result = connection.call_sync(
FPRINT, device, "org.freedesktop.DBus.Properties", "Get",
GLib.Variant("(ss)", (DEVICE_INTERFACE, "num-enroll-stages")),
GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 10000, None)
return max(1, int(result.unpack()[0]))
except Exception: # noqa: BLE001 - a device that will not say is not fatal
# Readers overwhelmingly want five touches, and a counter that is wrong
# is better than a page with no counter at all.
return 5
# ── enroll ───────────────────────────────────────────────────────────────────
# fprintd says "enroll-stage-passed" for a touch that counted and
# "enroll-completed" when there are no more to take. Everything else beginning
# "enroll-retry" or naming a placement problem is a touch to do again, and the
# terminal failures arrive with done=true.
STAGE_PASSED = "enroll-stage-passed"
COMPLETED = "enroll-completed"
# The results that end an enrollment badly. The device says so itself over the
# wire (the signal's `done` flag), so this list is only what the canned fprintd
# has to recognize on its own.
TERMINAL_FAILURES = (
"enroll-failed", "enroll-data-full", "enroll-disconnected",
"enroll-duplicate", "enroll-unknown-error",
)
def enroll(finger: str) -> int:
if not FINGER.fullmatch(finger or ""):
raise BoundaryError("That is not a finger fprintd knows.")
canned = fixture()
if canned is not None:
return enroll_replay(finger, canned)
device = default_device()
total = enroll_stages(device)
emit(ok=True, stage="claiming", done=0, total=total, result="", error="")
Gio, GLib, connection = bus()
call(device, DEVICE_INTERFACE, "Claim",
GLib.Variant("(s)", (os.environ.get("USER", ""),)))
loop = GLib.MainLoop()
state = {"done": 0, "result": "", "error": "", "ok": False, "seen": time.monotonic()}
def on_status(_connection, _sender, _path, _interface, _signal, parameters):
result, finished = parameters.unpack()
state["seen"] = time.monotonic()
state["result"] = result
if result == STAGE_PASSED:
state["done"] = min(state["done"] + 1, total)
if finished:
state["ok"] = result == COMPLETED
if not state["ok"]:
state["error"] = describe_result(result)
loop.quit()
return
emit(ok=True, stage="scanning", done=state["done"], total=total,
result=result, error="")
subscription = connection.signal_subscribe(
None, DEVICE_INTERFACE, "EnrollStatus", device, None,
Gio.DBusSignalFlags.NONE, on_status)
# A cancelled enrollment is a terminated process -- the page drops the
# Process and Quickshell sends a signal. The device must still be released,
# or the next attempt finds the reader busy with a session that is gone.
def cancelled(_data=None):
state["error"] = ""
state["result"] = "cancelled"
loop.quit()
return GLib.SOURCE_REMOVE
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 15, cancelled, None)
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 2, cancelled, None)
def watchdog(_data=None):
if time.monotonic() - state["seen"] > IDLE_TIMEOUT_SECONDS:
state["error"] = "The reader stopped answering."
loop.quit()
return GLib.SOURCE_REMOVE
return GLib.SOURCE_CONTINUE
GLib.timeout_add_seconds(5, watchdog, None)
try:
call(device, DEVICE_INTERFACE, "EnrollStart", GLib.Variant("(s)", (finger,)))
emit(ok=True, stage="scanning", done=0, total=total, result="", error="")
loop.run()
finally:
connection.signal_unsubscribe(subscription)
# Both are best-effort: the interesting failure already happened, and a
# reader left claimed is worse than a second error nobody can act on.
for method in ("EnrollStop", "Release"):
try:
call(device, DEVICE_INTERFACE, method)
except BoundaryError:
pass
return finish(state, total)
def finish(state: dict, total: int) -> int:
if state["ok"]:
emit(ok=True, stage="done", done=total, total=total,
result=COMPLETED, error="")
return 0
stage = "cancelled" if state["result"] == "cancelled" else "failed"
emit(ok=False, stage=stage, done=state["done"], total=total,
result=state["result"], error=state["error"])
return 1
def describe_result(result: str) -> str:
"""fprintd's terminal results, in words someone can act on."""
return {
"enroll-failed": "That finger could not be read. Try enrolling it again.",
"enroll-data-full": "The reader has no room for another fingerprint.",
"enroll-disconnected": "The fingerprint reader was disconnected.",
"enroll-duplicate": "That finger is already enrolled.",
"enroll-unknown-error": "The fingerprint reader failed.",
}.get(result, "Enrolling that finger did not finish.")
def enroll_replay(finger: str, canned: dict) -> int:
"""The same stream, from a file. No bus, no reader, no waiting."""
total = max(1, int(canned.get("enrollStages", 5)))
emit(ok=True, stage="claiming", done=0, total=total, result="", error="")
log_call("Claim", os.environ.get("USER", ""))
refusal = str(canned.get("error") or "")
if refusal:
emit(ok=False, stage="failed", done=0, total=total, result="", error=refusal)
return 1
log_call("EnrollStart", finger)
emit(ok=True, stage="scanning", done=0, total=total, result="", error="")
state = {"done": 0, "result": "", "error": "", "ok": False}
for result in canned.get("results", []):
state["result"] = result
if result == STAGE_PASSED:
state["done"] = min(state["done"] + 1, total)
if result == COMPLETED or result in TERMINAL_FAILURES:
state["ok"] = result == COMPLETED
if not state["ok"]:
state["error"] = describe_result(result)
break
emit(ok=True, stage="scanning", done=state["done"], total=total,
result=result, error="")
log_call("EnrollStop")
log_call("Release")
return finish(state, total)
# ── remove ───────────────────────────────────────────────────────────────────
def remove(finger: str | None) -> dict:
"""Delete one enrolled finger, or all of them, and report the fresh state."""
if finger is not None and not FINGER.fullmatch(finger):
raise BoundaryError("That is not a finger fprintd knows.")
canned = fixture()
if canned is not None:
log_call("Claim", os.environ.get("USER", ""))
if finger is None:
log_call("DeleteEnrolledFingers2")
else:
log_call("DeleteEnrolledFinger", finger)
log_call("Release")
return {**status(), "error": str(canned.get("error") or "")}
from gi.repository import GLib
device = default_device()
call(device, DEVICE_INTERFACE, "Claim",
GLib.Variant("(s)", (os.environ.get("USER", ""),)))
try:
if finger is None:
# DeleteEnrolledFingers2 works on the claimed user; its predecessor
# took a name and is deprecated for exactly the confusion that
# invited -- deleting someone else's prints by typo.
call(device, DEVICE_INTERFACE, "DeleteEnrolledFingers2")
else:
call(device, DEVICE_INTERFACE, "DeleteEnrolledFinger",
GLib.Variant("(s)", (finger,)))
finally:
try:
call(device, DEVICE_INTERFACE, "Release")
except BoundaryError:
pass
return status()
# ── entry ────────────────────────────────────────────────────────────────────
def main(arguments: list[str]) -> int:
verb = arguments[0] if arguments else ""
try:
if verb == "status" and len(arguments) == 1:
print(json.dumps(status(), separators=(",", ":")))
return 0
if verb == "set-unlock" and len(arguments) == 2:
return set_unlock(arguments[1])
if verb == "enroll" and len(arguments) == 2:
return enroll(arguments[1])
if verb == "remove" and len(arguments) == 2:
print(json.dumps(remove(arguments[1]), separators=(",", ":")))
return 0
if verb == "remove-all" and len(arguments) == 1:
print(json.dumps(remove(None), separators=(",", ":")))
return 0
except BoundaryError as failure:
# Enrollment streams, so its failure has to arrive in the same shape as
# its progress; the others answer with the state plus the message, so a
# page never has to ask twice to find out what happened.
if verb == "enroll":
emit(ok=False, stage="failed", done=0, total=0, result="",
error=str(failure))
else:
print(json.dumps({**status(), "error": str(failure)},
separators=(",", ":")))
return 1
print("usage: panama-fingerprint status | set-unlock on|off | enroll FINGER | "
"remove FINGER | remove-all", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+124 -14
View File
@@ -13,14 +13,17 @@ accountsservice over D-Bus from inside this process. It is never an argument:
argv is world-readable through /proc, so a password -- or even its hash --
passed that way is published to every process on the machine.
panama-accounts snapshot
panama-accounts set-real-name USER NAME
panama-accounts set-icon USER PATH [X Y SIZE]
panama-accounts set-account-type USER standard|administrator
panama-accounts set-automatic-login USER true|false
panama-accounts set-password USER (new password on stdin)
panama-accounts create-user USERNAME REALNAME standard|administrator
panama-accounts delete-user USERNAME [keep-files|remove-files]
panama-users snapshot
panama-users stock-avatars
panama-users set-real-name USER NAME
panama-users set-icon USER PATH [X Y SIZE] (PATH "" clears the picture)
panama-users set-account-type USER standard|administrator
panama-users set-automatic-login USER true|false
panama-users set-locked USER true|false
panama-users set-password USER (new password on stdin)
panama-users reset-password USER (no password material at all)
panama-users create-user USERNAME REALNAME standard|administrator
panama-users delete-user USERNAME keep|remove
"""
from __future__ import annotations
@@ -39,6 +42,16 @@ USER_INTERFACE = "org.freedesktop.Accounts.User"
# Account types as accountsservice numbers them.
STANDARD, ADMINISTRATOR = 0, 1
# Password modes, likewise. 1 is "the account has no usable password and must
# choose one at the next sign-in" -- which is why resetting a password here
# involves no password at all, not even one this process saw for a moment.
PASSWORD_MODE_SET_AT_LOGIN = 1
# Where a distribution keeps the pictures its login screen offers. Fedora ships
# fifteen; a machine with none is normal and yields an empty list.
STOCK_AVATAR_DIR = "/usr/share/pixmaps/faces"
STOCK_AVATAR_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp", ".svg")
USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
@@ -137,6 +150,40 @@ def snapshot() -> dict:
}
def stock_avatars() -> dict:
"""The pictures the distribution ships, as {name, path} pairs.
Absolute paths, because the caller draws them straight from disk and then
hands the same path back to set-icon. Sorted by name so the gallery does
not reshuffle itself between openings.
"""
entries = []
try:
names = os.listdir(STOCK_AVATAR_DIR)
except OSError:
# No such directory is an ordinary machine, not a failure worth a row.
names = []
for name in names:
if not name.lower().endswith(STOCK_AVATAR_SUFFIXES):
continue
path = os.path.join(STOCK_AVATAR_DIR, name)
if not os.path.isfile(path):
continue
entries.append({"name": _avatar_label(name), "path": path})
entries.sort(key=lambda entry: entry["name"].lower())
return {"avatars": entries, "error": ""}
def _avatar_label(filename: str) -> str:
""""coffee2.jpg" -> "Coffee 2". A file name is not a caption, but it is the
only thing these pictures carry, so it is tidied rather than invented."""
stem = os.path.splitext(filename)[0]
words = re.sub(r"(\d+)$", r" \1", stem.replace("-", " ").replace("_", " ")).strip()
return words[:1].upper() + words[1:]
def set_real_name(username: str, name: str) -> None:
from gi.repository import GLib
@@ -195,6 +242,14 @@ def crop_square(path: str, x: int, y: int, size: int) -> str:
def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None:
from gi.repository import GLib
# An empty path is how accountsservice is told to forget the picture: the
# same call, with nothing in it. There is no separate "clear" method, and
# inventing a verb for it here would only hide that.
if path == "":
call(user_path(username), USER_INTERFACE, "SetIconFile",
GLib.Variant("(s)", ("",)))
return
if not os.path.isfile(path):
raise BoundaryError("That picture no longer exists.")
@@ -221,7 +276,21 @@ def set_account_type(username: str, kind: str) -> None:
if kind not in ("standard", "administrator"):
raise BoundaryError("That is not an account type.")
call(user_path(username), USER_INTERFACE, "SetAccountType",
path = user_path(username)
# The same reason deleting the last administrator is refused: a machine
# whose only administrator has just been demoted cannot be administered,
# and the demotion itself is the last thing that needed authorization.
if kind == "standard":
state = snapshot()
target = next((user for user in state["users"]
if user["userName"] == username), None)
if target is not None and target["administrator"] \
and state["administratorCount"] <= 1:
raise BoundaryError(
"That is the only administrator; the machine would have none.")
call(path, USER_INTERFACE, "SetAccountType",
GLib.Variant("(i)", (ADMINISTRATOR if kind == "administrator" else STANDARD,)))
@@ -232,6 +301,29 @@ def set_automatic_login(username: str, enabled: bool) -> None:
GLib.Variant("(b)", (enabled,)))
def set_locked(username: str, locked: bool) -> None:
"""Lock or unlock an account. A locked account cannot sign in at all, which
is what someone is looking at when a user row says nothing works for them."""
from gi.repository import GLib
call(user_path(username), USER_INTERFACE, "SetLocked",
GLib.Variant("(b)", (locked,)))
def reset_password(username: str) -> None:
"""Require a new password at the next sign-in.
Deliberately not "set a password for them": no password is chosen, typed,
hashed, or transmitted. accountsservice is told the account's password mode
is "set at login", and the login screen collects the new one from the person
who is going to use it.
"""
from gi.repository import GLib
call(user_path(username), USER_INTERFACE, "SetPasswordMode",
GLib.Variant("(i)", (PASSWORD_MODE_SET_AT_LOGIN,)))
def set_password(username: str) -> None:
"""Set a new password, read from stdin and never named on a command line."""
secret = sys.stdin.buffer.read()
@@ -269,10 +361,17 @@ def create_user(username: str, real_name: str, kind: str) -> None:
"(o)")
# What to do with the home directory, spelled either way. "keep"/"remove" is
# what the page says out loud; the longer pair is what this helper has always
# taken, and callers older than the page still pass it.
KEEP_FILES = ("keep", "keep-files")
REMOVE_FILES = ("remove", "remove-files")
def delete_user(username: str, files: str) -> None:
from gi.repository import GLib
if files not in ("keep-files", "remove-files"):
if files not in KEEP_FILES + REMOVE_FILES:
raise BoundaryError("Say whether to keep or remove the home directory.")
if username == (os.environ.get("USER") or ""):
raise BoundaryError("You cannot delete the account you are signed in to.")
@@ -285,7 +384,7 @@ def delete_user(username: str, files: str) -> None:
raise BoundaryError("That is the only administrator; the machine would have none.")
call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser",
GLib.Variant("(xb)", (target["uid"], files == "remove-files")))
GLib.Variant("(xb)", (target["uid"], files in REMOVE_FILES)))
def main(arguments: list[str]) -> int:
@@ -294,6 +393,12 @@ def main(arguments: list[str]) -> int:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
# Its own shape rather than a snapshot: this asks the filesystem what
# pictures exist, which has nothing to do with who has an account.
if arguments == ["stock-avatars"]:
print(json.dumps(stock_avatars(), separators=(",", ":")))
return 0
if len(arguments) == 3 and arguments[0] == "set-real-name":
set_real_name(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-icon":
@@ -308,19 +413,24 @@ def main(arguments: list[str]) -> int:
set_account_type(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-automatic-login":
set_automatic_login(arguments[1], arguments[2] == "true")
elif len(arguments) == 3 and arguments[0] == "set-locked":
set_locked(arguments[1], arguments[2] == "true")
elif len(arguments) == 2 and arguments[0] == "set-password":
set_password(arguments[1])
elif len(arguments) == 2 and arguments[0] == "reset-password":
reset_password(arguments[1])
elif len(arguments) == 4 and arguments[0] == "create-user":
create_user(arguments[1], arguments[2], arguments[3])
elif len(arguments) == 3 and arguments[0] == "delete-user":
delete_user(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-accounts snapshot | set-real-name USER NAME | "
"Usage: panama-users snapshot | stock-avatars | set-real-name USER NAME | "
"set-icon USER PATH [X Y SIZE] | set-account-type USER standard|administrator | "
"set-automatic-login USER true|false | set-password USER | "
"set-automatic-login USER true|false | set-locked USER true|false | "
"set-password USER | reset-password USER | "
"create-user USERNAME REALNAME standard|administrator | "
"delete-user USERNAME keep-files|remove-files")
"delete-user USERNAME keep|remove")
except BoundaryError as error:
# Answers with the fresh state plus the message, so a page never has to
# ask twice to find out what happened.
+155 -32
View File
@@ -2,12 +2,22 @@ pragma Singleton
// The fingerprint reader, for the Users page.
//
// Two facts, owned by two different systems: fprintd holds the enrolled
// prints (GNOME's Users panel owns the enrollment dialog and Panama hands off
// to it), and authselect decides whether PAM asks the reader at unlock. Both
// come through scripts/panama-fingerprint, and the one privileged change --
// flipping authselect's with-fingerprint feature -- prompts through polkit
// with a stated reason, like everything else on that page.
// Two facts, owned by two different systems: fprintd holds the enrolled prints,
// and authselect decides whether PAM asks the reader at unlock. Both come
// through scripts/panama-fingerprint, and the one privileged change -- flipping
// authselect's with-fingerprint feature -- prompts through polkit with a stated
// reason, like everything else on that page.
//
// The two facts are kept apart deliberately. `unlockFeatureEnabled` is a
// property of the PAM configuration and is reported whether or not a reader
// exists, because the state worth seeing most is the one where the feature is
// on and the reader is gone: nothing works, nothing says why, and the switch
// that would fix it used to be hidden behind the missing hardware. Hence
// `cardVisible`.
//
// Enrollment happens here now rather than in GNOME's Users panel. The helper
// drives fprintd's own Claim/EnrollStart cycle and prints one line of JSON per
// touch, which is what `enrollStage of enrollTotal` counts.
//
// Read when the page opens rather than at shell startup: probing fprintd
// bus-activates the daemon, and most sessions never open this page.
@@ -23,55 +33,134 @@ Singleton {
property bool readerPresent: false
property string readerName: ""
property var enrolled: []
property bool pamEnabled: false
// The fingers fprintd currently holds a print for, by fprintd's own names.
property var fingers: []
property bool unlockFeatureEnabled: false
property bool scanned: false
property bool busy: false
property string lastError: ""
// Guards read the Process objects; this is only for the page to bind to.
readonly property bool busy: apply.running || change.running
// The card is worth drawing when there is a reader OR when PAM has been
// told to use one. The second half is the stuck state: no reader, nothing
// enrolled, and a feature switched on that quietly slows every unlock down.
readonly property bool cardVisible: root.readerPresent || root.unlockFeatureEnabled
// ── What a finger is called ──────────────────────────────────────────────
//
// fprintd's vocabulary lives here and only here, so nothing can disagree
// about what a finger is named or how it reads.
readonly property var allFingers: [
"right-index-finger", "right-middle-finger", "right-ring-finger",
"right-little-finger", "right-thumb",
"left-index-finger", "left-middle-finger", "left-ring-finger",
"left-little-finger", "left-thumb"
]
// The ones still worth offering: enrolling a finger twice replaces the
// print rather than adding one, which is not what "Add a fingerprint" says.
readonly property var availableFingers:
root.allFingers.filter(finger => root.fingers.indexOf(finger) === -1)
// "right-index-finger" -> "Right index finger". Presentation lives here
// rather than in the page, the way PowerProfiles.label does, so nothing
// can disagree about what a finger is called.
// rather than in the page, the way PowerProfiles.label does.
function fingerLabel(finger: string): string {
const words = String(finger).split("-").join(" ");
return words.slice(0, 1).toUpperCase() + words.slice(1);
}
// ── Enrollment ───────────────────────────────────────────────────────────
property bool enrolling: false
// Touches taken, of touches the reader wants. Both zero until the device
// has said how many it needs.
property int enrollStage: 0
property int enrollTotal: 0
// The finger being enrolled, and fprintd's last word about the last touch
// ("enroll-stage-passed", "enroll-retry-scan-too-short", ...). The page
// turns the retries into "Try again, a little slower".
property string enrollFinger: ""
property string enrollResult: ""
// claiming | scanning | done | failed | cancelled
property string enrollPhase: ""
function refresh(): void {
if (!query.running)
query.running = true;
}
function setUnlockEnabled(on: bool): void {
if (root.busy)
if (apply.running)
return;
root.busy = true;
root.lastError = "";
apply.command = [root.helperPath, "set-unlock", on ? "on" : "off"];
apply.running = true;
}
function startEnroll(finger: string): void {
if (enroll.running)
return;
root.lastError = "";
root.enrollFinger = finger;
root.enrollStage = 0;
root.enrollTotal = 0;
root.enrollResult = "";
root.enrollPhase = "claiming";
root.enrolling = true;
enroll.command = [root.helperPath, "enroll", finger];
enroll.running = true;
}
// Stopping the process is the cancellation: the helper catches the signal,
// stops the enrollment and releases the reader, which is the part that
// matters -- a claimed device belonging to a dead process refuses the next
// attempt.
function cancelEnroll(): void {
if (enroll.running)
enroll.running = false;
}
function removeFinger(finger: string): void {
if (change.running)
return;
root.lastError = "";
change.command = [root.helperPath, "remove", finger];
change.running = true;
}
function removeAll(): void {
if (change.running)
return;
root.lastError = "";
change.command = [root.helperPath, "remove-all"];
change.running = true;
}
// Every verb that reports state answers in the same shape, so there is one
// place that reads it.
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.readerPresent = parsed.reader === true;
root.readerName = String(parsed.readerName ?? "");
root.fingers = Array.isArray(parsed.enrolled) ? parsed.enrolled : [];
root.unlockFeatureEnabled = parsed.unlockFeatureEnabled === true;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.readerPresent = false;
root.lastError = "Could not read the fingerprint helper's output.";
console.warn("Fingerprint: could not parse helper output:", error);
}
root.scanned = true;
}
Process {
id: query
command: [root.helperPath, "status"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.readerPresent = parsed.reader === true;
root.readerName = String(parsed.readerName ?? "");
root.enrolled = Array.isArray(parsed.enrolled) ? parsed.enrolled : [];
root.pamEnabled = parsed.pamEnabled === true;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.readerPresent = false;
root.lastError = "Could not read the fingerprint helper's output.";
console.warn("Fingerprint: could not parse helper output:", error);
}
root.scanned = true;
}
}
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
Process {
@@ -87,8 +176,42 @@ Singleton {
}
// Re-read rather than assuming: authselect may refuse, and the
// prompt may have been dismissed.
onExited: root.refresh()
}
Process {
id: change
// Removing answers with the fresh state, so the list updates from the
// removal itself and never has to ask again.
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
Process {
id: enroll
stdout: SplitParser {
// One JSON object per touch. A reader takes five or so, seconds
// apart, so this is a trickle rather than a stream to drain.
onRead: line => {
try {
const update = JSON.parse(line);
root.enrollPhase = String(update.stage ?? root.enrollPhase);
root.enrollTotal = Number(update.total ?? root.enrollTotal);
root.enrollStage = Number(update.done ?? root.enrollStage);
root.enrollResult = String(update.result ?? "");
if (update.ok === false)
root.lastError = String(update.error ?? "That finger could not be enrolled.");
} catch (error) {
// A line that is not JSON is not worth abandoning an
// enrollment over; the exit status is what decides.
}
}
}
onExited: {
root.busy = false;
root.enrolling = false;
// Cancelled by stopping the process: the helper had no chance to
// say anything, and there is nothing to report.
if (root.enrollPhase !== "done" && root.enrollPhase !== "failed")
root.enrollPhase = "cancelled";
root.refresh();
}
}
+112 -33
View File
@@ -5,13 +5,19 @@ pragma Singleton
// The daemon already runs in this session -- gvfs activates it, and accounts
// work without gnome-shell anywhere. Only the panel was GNOME's; the accounts
// are D-Bus objects anything may read and modify. So listing, per-service
// toggles, and removal all happen here, natively.
// toggles, removal, and adding a password account all happen here, natively.
//
// Signing in is the exception, and only for OAuth providers. The daemon's
// AddAccount takes credentials as an argument rather than obtaining them, and
// the code that runs Google's OAuth exchange lives in libgoa-backend, which
// Fedora ships without a GIR binding. So that one step is handed to GNOME's
// panel and the user comes straight back here.
// Signing in is the exception, and only for OAuth providers. GOA's AddAccount
// takes credentials as an argument rather than obtaining them, which is exactly
// what a Nextcloud or IMAP form can supply; what it cannot supply is a Google
// token, because the code that runs that exchange lives in libgoa-backend,
// which Fedora ships without a GIR binding. So that one step is handed to
// GNOME's panel and the user comes straight back here.
//
// `available` means GOA answered the last time it was asked, and nothing else.
// It used to mean "lastError is empty", so a toggle GOA refused turned the
// whole page into "Online Accounts is not available" and hid the four working
// accounts behind it. A write that failed is a row; it is not the page.
//
// Read on demand and after every change: accounts are added and removed by
// people, not by the system, so there is nothing to poll for.
@@ -29,68 +35,141 @@ Singleton {
// services: [{key,label,enabled}] }]
property var accounts: []
property bool scanned: false
property bool busy: false
property string lastError: ""
// Whether GOA answered the last snapshot. True to begin with because
// nothing has said otherwise yet; `scanned` is what says whether anything
// has been asked at all.
property bool available: true
// Kept apart on purpose. The first is why the page might be empty; the
// second is why one thing someone just did did not happen. Only the first
// has any business deciding whether the page works.
property string snapshotError: ""
property string writeError: ""
readonly property string lastError:
root.writeError !== "" ? root.writeError : root.snapshotError
// Guards read the Process objects; this is only for the page to bind to,
// so rows can go quiet while a change is in flight.
readonly property bool busy: list.running || write.running || add.running
// Accounts whose stored credentials have stopped working -- an expired
// token, a changed password. GOA knows, and nothing outside its own panel
// ever says so, which is how an account quietly stops syncing for weeks.
readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length
readonly property bool available: root.lastError === ""
// Cheap enough for every page open: one short-lived process reading D-Bus
// objects that are already in memory.
function refresh(): void {
if (!list.running)
list.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
root.snapshotError = String(parsed.error ?? "");
root.available = root.snapshotError === "";
} catch (error) {
root.accounts = [];
root.snapshotError = "Could not read the accounts helper's output.";
root.available = false;
console.warn("OnlineAccounts: could not parse helper output:", error);
}
root.scanned = true;
}
// Enabling a service clears GOA's "disabled" flag; the helper owns that
// inversion so the UI can speak in terms of what is on.
function setService(path: string, service: string, enabled: bool): void {
if (root.busy)
if (write.running)
return;
root.busy = true;
root.writeError = "";
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
write.running = true;
}
function remove(path: string): void {
if (root.busy)
if (write.running)
return;
root.busy = true;
root.writeError = "";
write.command = [root.helperPath, "remove", path];
write.running = true;
}
// ── Adding a password account ────────────────────────────────────────────
//
// The password goes to the helper's stdin and nowhere else: never an
// argument, because argv is readable by every process on this machine.
// It is written from onStarted, because a process has no stdin to write to
// until it is running -- the same pattern UserAccounts uses for a new
// password, and HomeAssistantConfig for its token.
property string pendingPassword: ""
function addNextcloud(server: string, user: string, password: string): void {
root.beginAdd(["add-nextcloud", server, user], password);
}
function addImap(email: string, imapHost: string, smtpHost: string,
user: string, password: string): void {
root.beginAdd(["add-imap", email, imapHost, smtpHost, user], password);
}
function beginAdd(arguments: var, password: string): void {
if (add.running)
return;
root.writeError = "";
root.pendingPassword = password;
add.command = [root.helperPath].concat(arguments);
add.stdinEnabled = true;
add.running = true;
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.accounts = [];
root.lastError = "Could not read the accounts helper's output.";
console.warn("OnlineAccounts: could not parse helper output:", error);
}
root.scanned = true;
}
}
command: [root.helperPath, "snapshot"]
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
Process {
id: write
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim();
}
// Re-read rather than assuming the write landed: GOA may refuse, and a
// toggle that sprang back is the honest outcome.
onExited: {
root.busy = false;
root.refresh();
onExited: root.refresh()
}
Process {
id: add
stdinEnabled: true
onStarted: {
add.write(root.pendingPassword + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassword = "";
add.stdinEnabled = false;
}
// The helper answers with the fresh account list plus whatever went
// wrong, so a successful add lands on the page without a second read.
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : root.accounts;
// An add that failed says why here. It is a write error:
// GOA answered, so the page is still working.
root.writeError = String(parsed.error ?? "");
} catch (error) {
root.writeError = "Could not read the accounts helper's output.";
}
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim();
}
onExited: root.pendingPassword = ""
}
}
@@ -108,6 +108,15 @@ Singleton {
{ label: "Add a user", detail: "Create another account on this machine", page: "users" },
{ label: "Automatic login", detail: "Sign in without typing a password", page: "users" },
{ label: "Administrator", detail: "Which accounts can manage this machine", page: "users" },
// Users owns fingerprint enrollment now rather than handing it to
// GNOME's panel, and it owns the other-account verbs it used to bury
// inside an expanded row. Each of those is something people come
// looking for by name, so each gets a name to be found by.
{ label: "Fingerprint", detail: "Unlock and authorize with the reader on this machine", page: "users" },
{ label: "Enroll a fingerprint", detail: "Record a finger, one touch at a time", page: "users" },
{ label: "Delete a user", detail: "Remove an account, keeping or destroying its files", page: "users" },
{ label: "Account type", detail: "Whether an account may administer this machine", page: "users" },
{ label: "Reset a password", detail: "They set a new one at their next sign-in", page: "users" },
{ label: "Printers", detail: "Add a printer and see what is queued", page: "printers" },
{ label: "Print queue", detail: "What is waiting to print, and cancelling it", page: "printers" },
{ label: "Add a printer", detail: "Find a printer on the network or enter its address", page: "printers" },
@@ -170,6 +179,14 @@ Singleton {
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
// Adding an account is the thing people search for, and they search for
// it by the name of the service. Nextcloud and mail are added on the
// page itself; Google still goes through the provider's own sign-in,
// which is a result worth having rather than a dead end.
{ label: "Nextcloud", detail: "Add a Nextcloud server for files, calendar, and contacts", page: "accounts" },
{ label: "Google account", detail: "Sign in to Google for mail, calendar, and contacts", page: "accounts" },
{ label: "Add a mail account", detail: "Connect a mailbox by its IMAP and SMTP servers", page: "accounts" },
{ label: "Remove an account", detail: "Sign out and take an online account off this machine", page: "accounts" },
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" },
{ label: "Lights", detail: "Toggle and dim lights, grouped by room", page: "my-home" },
{ label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" },
@@ -25,6 +25,12 @@ Singleton {
property bool scanned: false
property string lastError: ""
// The pictures the distribution ships, as [{name, path}]. Read once and
// kept: it is a directory listing that does not change while a session is
// running, and the gallery it fills is opened and closed repeatedly.
property var stockAvatars: []
property bool stockAvatarsLoaded: false
// Guards read the Process objects rather than a derived "busy" binding. A
// binding hands back its cached value inside the handler that changes it,
// which silently turns a refresh after a successful change into a no-op --
@@ -115,7 +121,27 @@ Singleton {
String(Math.round(x)), String(Math.round(y)), String(Math.round(size))]);
}
function setAccountType(userName: string, kind: string): void {
// Clearing the picture is the same call with nothing in it -- there is no
// separate method for it in accountsservice, and there is none here either.
// No user name: this is the hero card's own avatar, and an account you are
// not signed in to has no avatar surface to remove it from.
function removeIcon(): void {
root.settingIcon = true;
root.run(["set-icon", root.currentUser, ""]);
}
// Called when the gallery is about to be shown, not at startup: most
// sessions never open it, and it is a directory listing either way.
function loadStockAvatars(): void {
if (root.stockAvatarsLoaded || stock.running)
return;
stock.running = true;
}
// Any account, including one that is not signed in. The helper keeps the
// last-administrator refusal, so a page that forgets the guard still
// cannot leave the machine unadministrable.
function setAccountTypeFor(userName: string, kind: string): void {
root.run(["set-account-type", userName, kind]);
}
@@ -123,12 +149,28 @@ Singleton {
root.run(["set-automatic-login", userName, enabled ? "true" : "false"]);
}
// Locked accounts cannot sign in at all. Unlocking is the only half of this
// the page offers, because locking someone out is not a settings gesture.
function setLocked(userName: string, locked: bool): void {
root.run(["set-locked", userName, locked ? "true" : "false"]);
}
// No password of any kind is involved: accountsservice is told the account
// must choose one at the next sign-in, and the login screen collects it
// from the person who will use it.
function resetPassword(userName: string): void {
root.run(["reset-password", userName]);
}
function createUser(userName: string, realName: string, kind: string): void {
root.run(["create-user", userName, realName, kind]);
}
function deleteUser(userName: string, removeFiles: bool): void {
root.run(["delete-user", userName, removeFiles ? "remove-files" : "keep-files"]);
// keepFiles, not removeFiles: the page asks "Keep the files" or "Remove
// everything", and a service that inverted the sentence on its way to the
// helper is how the destructive answer gets chosen by accident.
function deleteUser(userName: string, keepFiles: bool): void {
root.run(["delete-user", userName, keepFiles ? "keep" : "remove"]);
}
// The password goes to the helper's stdin and nowhere else: never an
@@ -177,6 +219,25 @@ Singleton {
onExited: root.pendingPassword = ""
}
Process {
id: stock
command: [root.helperPath, "stock-avatars"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.stockAvatars = Array.isArray(parsed.avatars) ? parsed.avatars : [];
} catch (error) {
// A machine with no gallery is normal; an empty one reads
// the same to the page, and nothing else here depends on it.
root.stockAvatars = [];
console.warn("Accounts: could not read the stock avatars:", error);
}
root.stockAvatarsLoaded = true;
}
}
}
Process {
id: mutation
// The helper answers with the fresh state, so the page updates from the