Added offline sessions to client and sessions (#4374)

Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Erik Jan de Wit 2023-02-10 17:28:22 +01:00 committed by GitHub
parent 2add99f48d
commit fe2ed2c680
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 146 additions and 73 deletions

View file

@ -1,7 +1,6 @@
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
import type UserSessionRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userSessionRepresentation";
import { PageSection } from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import type { LoaderFunction } from "../components/table-toolbar/KeycloakDataTable";
@ -16,8 +15,32 @@ export const ClientSessions = ({ client }: ClientSessionsProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation("sessions");
const loader: LoaderFunction<UserSessionRepresentation> = (first, max) =>
adminClient.clients.listSessions({ id: client.id!, first, max });
const loader: LoaderFunction<UserSessionRepresentation> = async (
first,
max
) => {
const mapSessionsToType =
(type: string) => (sessions: UserSessionRepresentation[]) =>
sessions.map((session) => ({
type,
...session,
}));
const allSessions = await Promise.all([
adminClient.clients
.listSessions({ id: client.id!, first, max })
.then(mapSessionsToType(t("sessions:sessionsType.regularSSO"))),
adminClient.clients
.listOfflineSessions({
id: client.id!,
first,
max,
})
.then(mapSessionsToType(t("sessions:sessionsType.offline"))),
]);
return allSessions.flat();
};
return (
<PageSection variant="light" className="pf-u-p-0">

View file

@ -1,18 +1,27 @@
import { DropdownItem, PageSection } from "@patternfly/react-core";
import { ClientSessionStat } from "@keycloak/keycloak-admin-client/lib/defs/clientSessionStat";
import {
DropdownItem,
PageSection,
Select,
SelectOption,
} from "@patternfly/react-core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FilterIcon } from "@patternfly/react-icons";
import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAdminClient } from "../context/auth/AdminClient";
import { useRealm } from "../context/realm-context/RealmContext";
import helpUrls from "../help-urls";
import { RevocationModal } from "./RevocationModal";
import SessionsTable from "./SessionsTable";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { useAlerts } from "../components/alert/Alerts";
import { useRealm } from "../context/realm-context/RealmContext";
import "./SessionsSection.css";
type FilterType = "all" | "regular" | "offline";
export default function SessionsSection() {
const { t } = useTranslation("sessions");
@ -23,34 +32,56 @@ export default function SessionsSection() {
const { realm } = useRealm();
const [revocationModalOpen, setRevocationModalOpen] = useState(false);
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [filterType, setFilterType] = useState<FilterType>("all");
const [noSessions, setNoSessions] = useState(false);
const handleRevocationModalToggle = () => {
setRevocationModalOpen(!revocationModalOpen);
};
const loader = async () => {
const activeClients = await adminClient.sessions.find();
const clientSessions = (
await Promise.all(
activeClients.map((client) =>
adminClient.clients.listSessions({ id: client.id })
)
async function getClientSessions(clientSessionStats: ClientSessionStat[]) {
const sessions = await Promise.all(
clientSessionStats.map((client) =>
adminClient.clients.listSessions({ id: client.id })
)
).flat();
setNoSessions(clientSessions.length === 0);
const userIds = Array.from(
new Set(clientSessions.map((session) => session.userId))
);
const userSessions = (
await Promise.all(
userIds.map((userId) => adminClient.users.listSessions({ id: userId! }))
)
).flat();
return userSessions;
return sessions.flat();
}
async function getOfflineSessions(clientSessionStats: ClientSessionStat[]) {
const sessions = await Promise.all(
clientSessionStats.map((client) =>
adminClient.clients.listOfflineSessions({ id: client.id })
)
);
return sessions.flat();
}
const loader = async () => {
const clientSessionStats = await adminClient.realms.getClientSessionStats({
realm,
});
const [clientSessions, offlineSessions] = await Promise.all([
filterType !== "offline" ? getClientSessions(clientSessionStats) : [],
filterType !== "regular" ? getOfflineSessions(clientSessionStats) : [],
]);
setNoSessions(clientSessions.length === 0 && offlineSessions.length === 0);
return [
...clientSessions.map((s) => ({
type: t("sessionsType.regularSSO"),
...s,
})),
...offlineSessions.map((s) => ({
type: t("sessionsType.offline"),
...s,
})),
];
};
const [toggleLogoutDialog, LogoutConfirm] = useConfirmDialog({
@ -105,7 +136,34 @@ export default function SessionsSection() {
}}
/>
)}
<SessionsTable key={key} loader={loader} />
<SessionsTable
key={key}
loader={loader}
filter={
<Select
data-testid="filter-session-type-select"
isOpen={filterDropdownOpen}
onToggle={(value) => setFilterDropdownOpen(value)}
toggleIcon={<FilterIcon />}
onSelect={(_, value) => {
setFilterType(value as FilterType);
refresh();
setFilterDropdownOpen(false);
}}
selections={filterType}
>
<SelectOption data-testid="all-sessions-option" value="all">
{t("sessionsType.allSessions")}
</SelectOption>
<SelectOption data-testid="regular-sso-option" value="regular">
{t("sessionsType.regularSSO")}
</SelectOption>
<SelectOption data-testid="offline-option" value="offline">
{t("sessionsType.offline")}
</SelectOption>
</Select>
}
/>
</PageSection>
</>
);

View file

@ -7,7 +7,7 @@ import {
ToolbarItem,
} from "@patternfly/react-core";
import { CubesIcon } from "@patternfly/react-icons";
import { useMemo, useState } from "react";
import { ReactNode, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@ -26,13 +26,19 @@ import { useWhoAmI } from "../context/whoami/WhoAmI";
import { toUser } from "../user/routes/User";
import useFormatDate from "../utils/useFormatDate";
export type ColumnName = "username" | "start" | "lastAccess" | "clients";
export type ColumnName =
| "username"
| "start"
| "lastAccess"
| "clients"
| "type";
export type SessionsTableProps = {
loader: LoaderFunction<UserSessionRepresentation>;
hiddenColumns?: ColumnName[];
emptyInstructions?: string;
logoutUser?: string;
filter?: ReactNode;
};
const UsernameCell = (row: UserSessionRepresentation) => {
@ -64,6 +70,7 @@ export default function SessionsTable({
hiddenColumns = [],
emptyInstructions,
logoutUser,
filter,
}: SessionsTableProps) {
const { realm } = useRealm();
const { whoAmI } = useWhoAmI();
@ -81,6 +88,10 @@ export default function SessionsTable({
displayKey: "sessions:user",
cellRenderer: UsernameCell,
},
{
name: "type",
displayKey: "common:type",
},
{
name: "start",
displayKey: "sessions:started",
@ -139,6 +150,7 @@ export default function SessionsTable({
loader={loader}
ariaLabelKey="sessions:title"
searchPlaceholderKey="sessions:searchForSession"
searchTypeComponent={filter}
toolbarItem={
logoutUser && (
<ToolbarItem>

View file

@ -19,7 +19,7 @@ export const UserSessions = () => {
<PageSection variant="light" className="pf-u-p-0">
<SessionsTable
loader={loader}
hiddenColumns={["username"]}
hiddenColumns={["username", "type"]}
emptyInstructions={t("noSessionsForUser")}
logoutUser={id}
/>

View file

@ -11,7 +11,6 @@ import { IdentityProviders } from "./resources/identityProviders.js";
import { Realms } from "./resources/realms.js";
import { Roles } from "./resources/roles.js";
import { ServerInfo } from "./resources/serverInfo.js";
import { Sessions } from "./resources/sessions.js";
import { Users } from "./resources/users.js";
import { UserStorageProvider } from "./resources/userStorageProvider.js";
import { WhoAmI } from "./resources/whoAmI.js";
@ -44,7 +43,6 @@ export class KeycloakAdminClient {
public serverInfo: ServerInfo;
public whoAmI: WhoAmI;
public attackDetection: AttackDetection;
public sessions: Sessions;
public authenticationManagement: AuthenticationManagement;
public cache: Cache;
@ -78,7 +76,6 @@ export class KeycloakAdminClient {
this.authenticationManagement = new AuthenticationManagement(this);
this.serverInfo = new ServerInfo(this);
this.whoAmI = new WhoAmI(this);
this.sessions = new Sessions(this);
this.attackDetection = new AttackDetection(this);
this.cache = new Cache(this);
}

View file

@ -0,0 +1,6 @@
export interface ClientSessionStat {
id: string;
clientId: string;
active: string;
offline: string;
}

View file

@ -17,6 +17,7 @@ import type GlobalRequestResult from "../defs/globalRequestResult.js";
import type GroupRepresentation from "../defs/groupRepresentation.js";
import type { ManagementPermissionReference } from "../defs/managementPermissionReference.js";
import type ComponentTypeRepresentation from "../defs/componentTypeRepresentation.js";
import type { ClientSessionStat } from "../defs/clientSessionStat.js";
export class Realms extends Resource {
/**
@ -294,6 +295,15 @@ export class Realms extends Resource {
/**
* Sessions
*/
public getClientSessionStats = this.makeRequest<
{ realm: string },
ClientSessionStat[]
>({
method: "GET",
path: "/{realm}/client-session-stats",
urlParamKeys: ["realm"],
});
public logoutAll = this.makeRequest<{ realm: string }, void>({
method: "POST",
path: "/{realm}/logout-all",

View file

@ -1,18 +0,0 @@
import Resource from "./resource.js";
import type KeycloakAdminClient from "../index.js";
export class Sessions extends Resource<{ realm?: string }> {
public find = this.makeRequest<{}, Record<string, any>[]>({
method: "GET",
});
constructor(client: KeycloakAdminClient) {
super(client, {
path: "/admin/realms/{realm}/client-session-stats",
getUrlParams: () => ({
realm: client.realmName,
}),
getBaseUrl: () => client.baseUrl,
});
}
}

View file

@ -374,6 +374,13 @@ describe("Realms", () => {
currentRealmName = created.realmName;
});
it("gets client session stats", async () => {
const sessionStats = await kcAdminClient.realms.getClientSessionStats({
realm: currentRealmName,
});
expect(sessionStats).to.be.ok;
});
it("push revocation", async () => {
const push = await kcAdminClient.realms.pushRevocation({
realm: currentRealmName,

View file

@ -1,22 +0,0 @@
// tslint:disable:no-unused-expression
import * as chai from "chai";
import { KeycloakAdminClient } from "../src/client.js";
import { credentials } from "./constants.js";
const expect = chai.expect;
describe("Sessions", () => {
let client: KeycloakAdminClient;
before(async () => {
client = new KeycloakAdminClient();
await client.auth(credentials);
});
it("list sessions", async () => {
const sessions = await client.sessions.find();
expect(sessions).to.be.ok;
expect(sessions.length).to.be.eq(1);
expect(sessions[0].clientId).to.be.eq("admin-cli");
});
});