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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user