* Copyright (C) 2013 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import '../lib/patternfly/patternfly-6-cockpit.scss';
import 'polyfills';
import 'cockpit-dark-theme';
import React, { useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { debounce } from 'throttle-debounce';
import cockpit from 'cockpit';
import { superuser } from "superuser";
import { getLastlog2 } from "logins";
import { usePageLocation, useLoggedInUser, useFile, useInit } from "hooks.js";
import { etc_passwd_syntax, etc_group_syntax, etc_shells_syntax } from "pam_user_parser.js";
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
import { AccountsMain } from "./accounts-list.js";
import { AccountDetails, AccountNotFound } from "./account-details.js";
import "./users.scss";
import { fsinfo } from 'cockpit/fsinfo';
superuser.reload_page_on_change();
export const admins = ['sudo', 'root', 'wheel'];
const sortGroups = groups => {
return groups.sort((a, b) => {
if (a.isAdmin && !b.isAdmin)
return -1;
if (!a.isAdmin && b.isAdmin)
return 1;
return a.name.localeCompare(b.name);
});
};
function AccountsPage() {
const [isGroupsExpanded, setIsGroupsExpanded] = useState(false);
const { path } = usePageLocation();
const accounts = useFile("/etc/passwd", { syntax: etc_passwd_syntax });
const groups = useFile("/etc/group", { syntax: etc_group_syntax });
const shells = useFile("/etc/shells", { syntax: etc_shells_syntax });
const current_user_info = useLoggedInUser();
const [min_gid, setMinGid] = useState(500);
const [max_gid, setMaxGid] = useState(60000);
const [min_uid, setMinUid] = useState(500);
const [max_uid, setMaxUid] = useState(60000);
const [details, setDetails] = useState(null);
useInit(async () => {
const logind_client = cockpit.dbus("org.freedesktop.login1");
const debouncedGetLoginDetails = debounce(100, () => {
getLoginDetails(logind_client).then(setDetails);
});
* while UserRemoved lags behind due to the "State: closing" period when the user's systemd instance
* etc. are being cleaned up. Also, there's not that many signals and this is debounced, so just react to all
* of them. See https://www.freedesktop.org/wiki/Software/systemd/logind/ */
logind_client.subscribe({
interface: "org.freedesktop.login1.Manager",
path: "/org/freedesktop/login1",
}, debouncedGetLoginDetails);
let handleUtmp;
const handleShadow = cockpit.file("/etc/shadow", { superuser: "try" });
handleShadow.watch(() => debouncedGetLoginDetails(), { read: false });
let handleLogindef;
try {
await fsinfo("/etc/login.defs", []);
handleLogindef = cockpit.file("/etc/login.defs");
} catch (ex) {
handleLogindef = cockpit.file("/usr/etc/login.defs");
}
handleLogindef.watch((logindef) => {
if (logindef === null)
return;
const minGid = parseInt(logindef.match(/^GID_MIN\s+(\d+)/m)[1]);
const maxGid = parseInt(logindef.match(/^GID_MAX\s+(\d+)/m)[1]);
const minUid = parseInt(logindef.match(/^UID_MIN\s+(\d+)/m)[1]);
const maxUid = parseInt(logindef.match(/^UID_MAX\s+(\d+)/m)[1]);
if (minGid)
setMinGid(minGid);
if (maxGid)
setMaxGid(maxGid);
if (minUid)
setMinUid(minUid);
if (maxUid)
setMaxUid(maxUid);
});
return [logind_client, handleUtmp, handleShadow, handleLogindef];
}, [], null, handles => handles.forEach(handle => handle.close()));
const accountsInfo = useMemo(() => {
if (accounts && details)
return accounts.map(account => {
return Object.assign({}, account, details[account.name]);
});
else
return [];
}, [accounts, details]);
const groupsExtraInfo = useMemo(() => sortGroups(
(groups || []).map(group => {
const userlistPrimary = accountsInfo.filter(account => account.gid === group.gid).map(account => account.name);
const userlist = group.userlist.filter(el => el !== "");
return ({
...group,
userlistPrimary,
userlist,
members: userlist.length + userlistPrimary.length,
isAdmin: admins.includes(group.name),
isUserCreatedGroup: group.gid >= min_gid && group.gid <= max_gid
});
})
), [groups, accountsInfo, min_gid, max_gid]);
if (groupsExtraInfo.length === 0 || accountsInfo.length === 0) {
return <EmptyStatePanel loading />;
} else if (path.length === 0) {
return (
<AccountsMain
accountsInfo={accountsInfo}
current_user={current_user_info?.name}
groups={groupsExtraInfo || []}
isGroupsExpanded={isGroupsExpanded}
setIsGroupsExpanded={setIsGroupsExpanded}
min_gid={min_gid}
max_gid={max_gid}
min_uid={min_uid}
max_uid={max_uid}
shells={shells}
/>
);
} else if (path.length === 1) {
const account = accountsInfo?.find(account => account.name === path[0]);
if (!account)
return <AccountNotFound />;
return (
<AccountDetails account={account} isLoading={accountsInfo.length === 0} groups={groupsExtraInfo}
current_user={current_user_info?.name} shells={shells} />
);
} else return null;
}
async function getLoginDetails(logind_client) {
const details = {};
try {
const [users] = await logind_client.call(
"/org/freedesktop/login1", "org.freedesktop.login1.Manager", "ListUsers",
null, { type: "", flags: "", timeout: 5000 });
await Promise.all(users.map(async ([_, name, objpath]) => {
const [active] = await logind_client.call(
objpath, "org.freedesktop.DBus.Properties", "Get",
["org.freedesktop.login1.User", "State"],
{ type: "ss", flags: "", timeout: 5000 });
if (active.v !== "closing")
details[name] = { ...details[name], loggedIn: true };
}));
} catch (err) {
console.warn("Unexpected error when getting logged in accounts", err);
}
try {
const locked_statuses = await cockpit.spawn(["passwd", "-S", "--all"], { superuser: "require", err: "message", environ: ["LC_ALL=C"] });
for (const line of locked_statuses.trim().split('\n')) {
const name = line.split(" ")[0];
const status = line.split(" ")[1];
details[name] = { ...details[name], isLocked: status === "L" };
}
} catch (err) {
if (err.message?.includes("bad argument --all")) {
try {
const shadow = await cockpit.file("/etc/shadow", { superuser: "require", err: "message" }).read();
for (const line of shadow.split('\n')) {
const [name, hash] = line.split(":");
if (name && hash)
details[name] = { ...details[name], isLocked: hash.startsWith("!") };
}
} catch (err) {
console.warn("Unexpected error when getting locked accounts from /etc/shadow:", err.toString());
}
} else {
console.warn("Unexpected error when getting locked account information:", err.toString());
}
}
try {
for (const [name, last] of Object.entries(await getLastlog2()))
details[name] = { ...details[name], lastLogin: last.time * 1000 };
} catch (err) {
try {
const out = await cockpit.spawn(["lastlog"], { environ: ["LC_ALL=C"] });
await Promise.all(out.split('\n').slice(1, -1).map(async line => {
if (line.includes('**Never logged in**'))
return;
const splitLine = line.trim().split(/[ \t]+/);
const name = splitLine[0];
const date_fields = splitLine.slice(-5);
try {
const out = await cockpit.spawn(["date", "+%s", "-d", date_fields.join(' ')],
{ environ: ["LC_ALL=C"], err: "out" });
details[name] = { ...details[name], lastLogin: parseInt(out) * 1000 };
} catch (e) {
console.warn(`Failed to parse date from lastlog line '${line}': ${e.toString()}`);
}
}));
} catch (ex) {
console.warn(`Failed to run lastlog: ${ex.toString()}`);
}
}
return details;
}
function init() {
const root = createRoot(document.getElementById("page"));
root.render(<AccountsPage />);
document.body.removeAttribute("hidden");
}
document.addEventListener("DOMContentLoaded", init);