// Online Accounts. // // 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, removal and password-provider sign-in are all done // natively on this page. // // 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. // // 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 import qs.config 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.") : "What this desktop signs in to — Panama's own integrations first." // "" | "nextcloud" | "imap" property string openForm: "" property string expandedAccount: "" property string confirmingRemoval: "" property string ncServer: "" property string ncUser: "" property string ncPassword: "" property string imapEmail: "" property string imapHost: "" property string smtpHost: "" property string imapUser: "" property string imapPassword: "" 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"; } readonly property string phoneName: KdeConnect.preferredPhone ? String(KdeConnect.preferredPhone.name) : "Phone" 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; } // 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(/\/.*$/, ""); } // 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"); } 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(); } // 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(); } // 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 } TextRow { visible: OnlineAccounts.snapshotError !== "" label: "The account list could not be read" detail: OnlineAccounts.snapshotError value: "" divider: false } // ── 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 StatusBadge { anchors.verticalCenter: parent.verticalCenter visible: HomeAssistant.phase === "ready" text: "Connected" tone: Theme.ok } 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 StatusBadge { anchors.verticalCenter: parent.verticalCenter visible: KdeConnect.pairedCount > 0 text: "Paired" tone: Theme.ok } SettingsButton { text: KdeConnect.pairedCount > 0 ? "Open Phone" : "Set up" onClicked: ShellState.openSettings("phone") } } } } // ── Everything the applications use ────────────────────────────────────── SettingsCard { 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: "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: 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: "you@example.com" 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: "you@example.com" 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() } } }