diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 35301001..9b65cd2f 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -555,5 +555,20 @@ FocusScope { Qt.callLater(() => item.forceActiveFocus()); } } + + Loader { + id: usersLoader + anchors.fill: parent + active: root.currentIndex === 35 + visible: active + focus: active + + sourceComponent: UsersTab {} + + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } } } diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index ee8c0d6b..21b5ecad 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -293,6 +293,12 @@ Rectangle { "tabIndex": 20, "updaterOnly": true }, + { + "id": "users", + "text": I18n.tr("Users"), + "icon": "manage_accounts", + "tabIndex": 35 + }, { "id": "window_rules", "text": I18n.tr("Window Rules"), diff --git a/quickshell/Modules/Settings/UsersTab.qml b/quickshell/Modules/Settings/UsersTab.qml new file mode 100644 index 00000000..5dfe7bd3 --- /dev/null +++ b/quickshell/Modules/Settings/UsersTab.qml @@ -0,0 +1,414 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modals.Common +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets + +Item { + id: root + + property string statusText: "" + property bool statusIsError: false + property bool operationPending: false + property string pendingUsername: "" + property string pendingPassword: "" + property string pendingConfirm: "" + property bool pendingAdmin: false + + function _resetForm() { + pendingUsername = ""; + pendingPassword = ""; + pendingConfirm = ""; + pendingAdmin = false; + usernameField.text = ""; + passwordField.text = ""; + confirmField.text = ""; + } + + function _passwordsMatch() { + return pendingPassword.length > 0 && pendingPassword === pendingConfirm; + } + + function _createCanProceed() { + return !operationPending && UsersService.isValidUsername(pendingUsername) && !UsersService.userExists(pendingUsername) && _passwordsMatch(); + } + + Connections { + target: UsersService + function onOperationCompleted(op, username, success, message) { + root.operationPending = false; + root.statusIsError = !success; + if (success) { + root.statusText = message + (username ? (" — " + username) : ""); + if (op === "create") + root._resetForm(); + } else { + root.statusText = (username ? (username + ": ") : "") + message; + } + } + } + + ConfirmModal { + id: deleteUserConfirm + } + + ConfirmModal { + id: adminToggleConfirm + } + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + topPadding: 4 + width: Math.min(600, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + + StyledText { + width: parent.width + visible: !PolkitService.polkitAvailable + text: I18n.tr("Polkit integration is disabled. User management requires Polkit to elevate privileges.") + font.pixelSize: Theme.fontSizeMedium + color: Theme.error + wrapMode: Text.WordWrap + } + + SettingsCard { + width: parent.width + iconName: "group" + title: I18n.tr("Existing Users") + settingKey: "usersList" + visible: PolkitService.polkitAvailable + + Row { + width: parent.width + spacing: Theme.spacingS + + StyledText { + text: I18n.tr("Administrator group:") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: UsersService.adminGroup + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Item { + width: Theme.spacingM + height: 1 + } + + StyledText { + text: UsersService.refreshing ? I18n.tr("Refreshing…") : "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + } + + Repeater { + model: UsersService.users + + Rectangle { + id: userRow + required property var modelData + width: parent.width + height: Math.max(48, rowContent.implicitHeight + Theme.spacingS * 2) + radius: Theme.cornerRadius + color: Theme.surfaceContainerHighest + + readonly property bool isLastAdmin: modelData.isAdmin && UsersService.adminMembers.length <= 1 + + Row { + id: rowContent + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingM + + DankIcon { + name: "account_circle" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - actionButtons.width - Theme.spacingM * 3 + spacing: 2 + anchors.verticalCenter: parent.verticalCenter + + Row { + spacing: Theme.spacingS + + StyledText { + text: userRow.modelData.username + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Rectangle { + visible: userRow.modelData.isAdmin + width: adminChipText.implicitWidth + Theme.spacingS * 2 + height: adminChipText.implicitHeight + Theme.spacingXS * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.primary, 0.15) + anchors.verticalCenter: parent.verticalCenter + + StyledText { + id: adminChipText + anchors.centerIn: parent + text: I18n.tr("admin") + font.pixelSize: Theme.fontSizeSmall + color: Theme.primary + font.weight: Font.Medium + } + } + } + + StyledText { + text: userRow.modelData.gecos && userRow.modelData.gecos.length > 0 ? userRow.modelData.gecos + " · UID " + userRow.modelData.uid : "UID " + userRow.modelData.uid + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + } + } + + Row { + id: actionButtons + spacing: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + + DankActionButton { + id: adminToggleBtn + readonly property bool actionBlocked: root.operationPending || (userRow.isLastAdmin && userRow.modelData.isAdmin) + buttonSize: 36 + iconSize: 20 + iconName: userRow.modelData.isAdmin ? "shield_person" : "shield" + iconColor: userRow.modelData.isAdmin ? Theme.primary : Theme.surfaceVariantText + opacity: actionBlocked ? 0.4 : 1.0 + tooltipText: (userRow.isLastAdmin && userRow.modelData.isAdmin) ? I18n.tr("Cannot remove the only administrator") : (userRow.modelData.isAdmin ? I18n.tr("Remove admin") : I18n.tr("Make admin")) + tooltipSide: "left" + onClicked: { + if (actionBlocked) + return; + const makeAdmin = !userRow.modelData.isAdmin; + adminToggleConfirm.showWithOptions({ + title: makeAdmin ? I18n.tr("Grant admin?") : I18n.tr("Remove admin?"), + message: makeAdmin ? I18n.tr("Add \"%1\" to the %2 group?").arg(userRow.modelData.username).arg(UsersService.adminGroup) : I18n.tr("Remove \"%1\" from the %2 group?").arg(userRow.modelData.username).arg(UsersService.adminGroup), + confirmText: makeAdmin ? I18n.tr("Grant") : I18n.tr("Remove"), + confirmColor: Theme.primary, + onConfirm: () => { + root.operationPending = true; + root.statusText = ""; + UsersService.setAdmin(userRow.modelData.username, makeAdmin, null); + } + }); + } + } + + DankActionButton { + id: deleteBtn + readonly property bool actionBlocked: root.operationPending || !UsersService.canDelete(userRow.modelData.username) + buttonSize: 36 + iconSize: 20 + iconName: "delete" + iconColor: Theme.error + opacity: actionBlocked ? 0.4 : 1.0 + tooltipText: userRow.isLastAdmin ? I18n.tr("Cannot delete the only administrator") : I18n.tr("Delete user") + tooltipSide: "left" + onClicked: { + if (actionBlocked) + return; + deleteUserConfirm.showWithOptions({ + title: I18n.tr("Delete user?"), + message: I18n.tr("Delete \"%1\" and remove the home directory? This cannot be undone.").arg(userRow.modelData.username), + confirmText: I18n.tr("Delete"), + confirmColor: Theme.primary, + onConfirm: () => { + root.operationPending = true; + root.statusText = ""; + UsersService.deleteUser(userRow.modelData.username, null); + } + }); + } + } + } + } + } + } + + StyledText { + width: parent.width + visible: UsersService.users.length === 0 && !UsersService.refreshing + text: I18n.tr("No human user accounts found.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + + SettingsCard { + width: parent.width + iconName: "person_add" + title: I18n.tr("Create User") + settingKey: "createUser" + visible: PolkitService.polkitAvailable + + Column { + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Username") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + id: usernameField + width: parent.width + placeholderText: I18n.tr("e.g. alice") + backgroundColor: Theme.surfaceContainerHighest + normalBorderColor: usernameInvalid ? Theme.error : Theme.outlineMedium + focusedBorderColor: usernameInvalid ? Theme.error : Theme.primary + + readonly property bool usernameInvalid: text.length > 0 && (!UsersService.isValidUsername(text) || UsersService.userExists(text)) + + onTextEdited: { + root.pendingUsername = text.trim(); + } + } + + StyledText { + width: parent.width + visible: usernameField.text.length > 0 && !UsersService.isValidUsername(usernameField.text) + text: I18n.tr("Username must start with a lowercase letter or underscore and contain only lowercase letters, digits, hyphens, or underscores.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + wrapMode: Text.WordWrap + } + + StyledText { + width: parent.width + visible: usernameField.text.length > 0 && UsersService.isValidUsername(usernameField.text) && UsersService.userExists(usernameField.text) + text: I18n.tr("A user with that name already exists.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + wrapMode: Text.WordWrap + } + } + + Column { + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Password") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + id: passwordField + width: parent.width + placeholderText: I18n.tr("Set initial password") + echoMode: TextInput.Password + showPasswordToggle: true + backgroundColor: Theme.surfaceContainerHighest + normalBorderColor: Theme.outlineMedium + focusedBorderColor: Theme.primary + onTextEdited: root.pendingPassword = text + } + } + + Column { + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Confirm password") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + id: confirmField + width: parent.width + placeholderText: I18n.tr("Re-enter password") + echoMode: TextInput.Password + showPasswordToggle: true + backgroundColor: Theme.surfaceContainerHighest + normalBorderColor: confirmMismatch ? Theme.error : Theme.outlineMedium + focusedBorderColor: confirmMismatch ? Theme.error : Theme.primary + + readonly property bool confirmMismatch: text.length > 0 && text !== passwordField.text + + onTextEdited: root.pendingConfirm = text + } + + StyledText { + width: parent.width + visible: confirmField.text.length > 0 && confirmField.text !== passwordField.text + text: I18n.tr("Passwords do not match.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + } + } + + SettingsToggleRow { + settingKey: "createUserAdmin" + tags: ["user", "admin", "sudo", "wheel"] + text: I18n.tr("Grant administrator privileges") + description: I18n.tr("Add the new user to the %1 group so they can use sudo.").arg(UsersService.adminGroup) + checked: root.pendingAdmin + onToggled: checked => root.pendingAdmin = checked + } + + Row { + width: parent.width + spacing: Theme.spacingM + + DankButton { + text: root.operationPending ? I18n.tr("Working…") : I18n.tr("Create User") + iconName: "person_add" + backgroundColor: Theme.primary + textColor: Theme.primaryText + enabled: root._createCanProceed() + onClicked: { + if (!root._createCanProceed()) + return; + root.operationPending = true; + root.statusText = ""; + UsersService.createUser(root.pendingUsername, root.pendingPassword, root.pendingAdmin, null); + } + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: root.statusText + color: root.statusIsError ? Theme.error : Theme.primary + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + width: parent.width - parent.children[0].width - Theme.spacingM + } + } + } + } + } +} diff --git a/quickshell/Services/UsersService.qml b/quickshell/Services/UsersService.qml new file mode 100644 index 00000000..8735c8e9 --- /dev/null +++ b/quickshell/Services/UsersService.qml @@ -0,0 +1,365 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common + +Singleton { + id: root + + readonly property var log: Log.scoped("UsersService") + + property var users: [] + property string adminGroup: "wheel" + property var adminMembers: [] + property bool refreshing: false + + signal operationCompleted(string op, string username, bool success, string message) + + readonly property var _usernameRegex: /^[a-z_][a-z0-9_-]{0,30}\$?$/ + + function isValidUsername(name) { + if (typeof name !== "string") + return false; + return _usernameRegex.test(name); + } + + function userExists(name) { + for (let i = 0; i < users.length; i++) { + if (users[i].username === name) + return true; + } + return false; + } + + function _findUser(name) { + for (let i = 0; i < users.length; i++) { + if (users[i].username === name) + return users[i]; + } + return null; + } + + function canDelete(name) { + const u = _findUser(name); + if (!u) + return false; + if (u.isAdmin && adminMembers.length <= 1) + return false; + return true; + } + + function refresh() { + if (refreshing) + return; + refreshing = true; + _detectAdminGroup(); + } + + function _detectAdminGroup() { + Proc.runCommand("usersService-detectGroup", ["sh", "-c", "getent group wheel >/dev/null && echo wheel || (getent group sudo >/dev/null && echo sudo || echo wheel)"], (output, exitCode) => { + const detected = (output || "").trim() || "wheel"; + root.adminGroup = detected; + _loadAdminMembers(); + }, 0); + } + + function _loadAdminMembers() { + Proc.runCommand("usersService-adminMembers", ["sh", "-c", "getent group " + root.adminGroup + " | awk -F: '{print $4}'"], (output, exitCode) => { + const members = (output || "").trim().split(",").map(s => s.trim()).filter(s => s.length > 0); + root.adminMembers = members; + _loadUsers(); + }, 0); + } + + function _loadUsers() { + Proc.runCommand("usersService-loadUsers", ["sh", "-c", "getent passwd | awk -F: '$3>=1000 && $3<60000 && $1!=\"nobody\" {print $1\":\"$3\":\"$5\":\"$6\":\"$7}'"], (output, exitCode) => { + const lines = (output || "").trim().split("\n").filter(l => l.length > 0); + const list = []; + const adminSet = {}; + for (let i = 0; i < root.adminMembers.length; i++) + adminSet[root.adminMembers[i]] = true; + + for (let i = 0; i < lines.length; i++) { + const parts = lines[i].split(":"); + if (parts.length < 5) + continue; + const username = parts[0]; + list.push({ + username, + uid: parseInt(parts[1], 10), + gecos: (parts[2] || "").split(",")[0], + home: parts[3] || "", + shell: parts[4] || "", + isAdmin: adminSet[username] === true + }); + } + list.sort((a, b) => a.username.localeCompare(b.username)); + root.users = list; + root.refreshing = false; + }, 0); + } + + function createUser(username, password, addToAdmin, callback) { + if (!isValidUsername(username)) { + _emit("create", username, false, I18n.tr("Invalid username"), callback); + return; + } + if (!password || password.length < 1) { + _emit("create", username, false, I18n.tr("Password cannot be empty"), callback); + return; + } + if (userExists(username)) { + _emit("create", username, false, I18n.tr("User already exists"), callback); + return; + } + _runUseradd(username, password, addToAdmin === true, callback); + } + + function setPassword(username, newPassword, callback) { + if (!isValidUsername(username) || !userExists(username)) { + _emit("passwd", username, false, I18n.tr("User not found"), callback); + return; + } + if (!newPassword || newPassword.length < 1) { + _emit("passwd", username, false, I18n.tr("Password cannot be empty"), callback); + return; + } + _runChpasswd(username, newPassword, "passwd", callback); + } + + function deleteUser(username, callback) { + if (!userExists(username)) { + _emit("delete", username, false, I18n.tr("User not found"), callback); + return; + } + if (!canDelete(username)) { + _emit("delete", username, false, I18n.tr("Cannot delete the only administrator"), callback); + return; + } + _runUserdel(username, callback); + } + + function setAdmin(username, makeAdmin, callback) { + if (!userExists(username)) { + _emit("admin", username, false, I18n.tr("User not found"), callback); + return; + } + if (!makeAdmin) { + const u = _findUser(username); + if (u && u.isAdmin && root.adminMembers.length <= 1) { + _emit("admin", username, false, I18n.tr("Cannot remove the only administrator"), callback); + return; + } + } + _runAdminToggle(username, makeAdmin === true, callback); + } + + function _emit(op, username, success, message, callback) { + root.operationCompleted(op, username, success, message); + if (typeof callback === "function") { + try { + callback(success, message); + } catch (e) { + log.warn("UsersService callback error:", e); + } + } + } + + Component { + id: useraddComp + Process { + id: useraddProc + property string targetUser: "" + property string targetPassword: "" + property bool addAdmin: false + property var cb: null + property string capturedErr: "" + running: false + stdout: StdioCollector {} + stderr: StdioCollector { + onStreamFinished: useraddProc.capturedErr = text || "" + } + onExited: exitCode => { + const svc = root; + if (exitCode !== 0) { + svc._emit("create", useraddProc.targetUser, false, (useraddProc.capturedErr || "").trim() || I18n.tr("useradd failed (exit %1)").arg(exitCode), useraddProc.cb); + Qt.callLater(() => useraddProc.destroy()); + return; + } + const targetUser = useraddProc.targetUser; + const targetPassword = useraddProc.targetPassword; + const addAdmin = useraddProc.addAdmin; + const outerCb = useraddProc.cb; + Qt.callLater(() => useraddProc.destroy()); + + svc._runChpasswd(targetUser, targetPassword, "create", (pwOk, pwMsg) => { + if (!pwOk) { + svc._emit("create", targetUser, false, pwMsg, outerCb); + return; + } + if (addAdmin) { + svc._runAdminToggle(targetUser, true, (adminOk, adminMsg) => { + if (adminOk) { + svc._emit("create", targetUser, true, I18n.tr("User created with administrator privileges"), outerCb); + } else { + svc._emit("create", targetUser, false, adminMsg, outerCb); + } + }); + } else { + svc._emit("create", targetUser, true, I18n.tr("User created"), outerCb); + } + }); + } + } + } + + Component { + id: chpasswdComp + Process { + id: chpasswdProc + property string targetUser: "" + property string targetPassword: "" + property string op: "passwd" + property var cb: null + property string capturedErr: "" + command: ["pkexec", "sh", "-c", "head -n1 | chpasswd"] + stdinEnabled: true + running: false + stdout: StdioCollector {} + stderr: StdioCollector { + onStreamFinished: chpasswdProc.capturedErr = text || "" + } + onStarted: { + chpasswdProc.write(chpasswdProc.targetUser + ":" + chpasswdProc.targetPassword + "\n"); + } + onExited: exitCode => { + const op = chpasswdProc.op; + const targetUser = chpasswdProc.targetUser; + const cb = chpasswdProc.cb; + const err = (chpasswdProc.capturedErr || "").trim(); + Qt.callLater(() => chpasswdProc.destroy()); + + if (exitCode !== 0) { + const msg = err || I18n.tr("Password change failed (exit %1)").arg(exitCode); + if (op === "create") { + if (typeof cb === "function") + cb(false, msg); + } else { + root._emit("passwd", targetUser, false, msg, cb); + } + } else { + root.refresh(); + if (op === "create") { + if (typeof cb === "function") + cb(true, I18n.tr("Password set")); + } else { + root._emit("passwd", targetUser, true, I18n.tr("Password updated"), cb); + } + } + } + } + } + + Component { + id: userdelComp + Process { + id: userdelProc + property string targetUser: "" + property var cb: null + property string capturedErr: "" + running: false + stdout: StdioCollector {} + stderr: StdioCollector { + onStreamFinished: userdelProc.capturedErr = text || "" + } + onExited: exitCode => { + const targetUser = userdelProc.targetUser; + const cb = userdelProc.cb; + const err = (userdelProc.capturedErr || "").trim(); + Qt.callLater(() => userdelProc.destroy()); + + if (exitCode !== 0) { + root._emit("delete", targetUser, false, err || I18n.tr("userdel failed (exit %1)").arg(exitCode), cb); + } else { + root.refresh(); + root._emit("delete", targetUser, true, I18n.tr("User deleted"), cb); + } + } + } + } + + Component { + id: adminToggleComp + Process { + id: adminToggleProc + property string targetUser: "" + property bool makeAdmin: false + property var cb: null + property string capturedErr: "" + running: false + stdout: StdioCollector {} + stderr: StdioCollector { + onStreamFinished: adminToggleProc.capturedErr = text || "" + } + onExited: exitCode => { + const targetUser = adminToggleProc.targetUser; + const makeAdmin = adminToggleProc.makeAdmin; + const cb = adminToggleProc.cb; + const err = (adminToggleProc.capturedErr || "").trim(); + Qt.callLater(() => adminToggleProc.destroy()); + + if (exitCode !== 0) { + root._emit("admin", targetUser, false, err || I18n.tr("usermod failed (exit %1)").arg(exitCode), cb); + } else { + root.refresh(); + root._emit("admin", targetUser, true, makeAdmin ? I18n.tr("Granted administrator privileges") : I18n.tr("Removed administrator privileges"), cb); + } + } + } + } + + function _runUseradd(username, password, addToAdmin, callback) { + const proc = useraddComp.createObject(root, { + command: ["pkexec", "useradd", "-m", "-s", "/bin/bash", username], + targetUser: username, + targetPassword: password, + addAdmin: addToAdmin, + cb: callback + }); + proc.running = true; + } + + function _runChpasswd(username, password, op, callback) { + const proc = chpasswdComp.createObject(root, { + targetUser: username, + targetPassword: password, + op: op, + cb: callback + }); + proc.running = true; + } + + function _runUserdel(username, callback) { + const proc = userdelComp.createObject(root, { + command: ["pkexec", "userdel", "-r", username], + targetUser: username, + cb: callback + }); + proc.running = true; + } + + function _runAdminToggle(username, makeAdmin, callback) { + const cmd = makeAdmin ? ["pkexec", "usermod", "-aG", root.adminGroup, username] : ["pkexec", "gpasswd", "-d", username, root.adminGroup]; + const proc = adminToggleComp.createObject(root, { + command: cmd, + targetUser: username, + makeAdmin: makeAdmin, + cb: callback + }); + proc.running = true; + } + + Component.onCompleted: refresh() +}