From fe2ed2c680387772eab0c0ad43a856731794dafc Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Fri, 10 Feb 2023 17:28:22 +0100 Subject: [PATCH] Added offline sessions to client and sessions (#4374) Co-authored-by: Jon Koops --- apps/admin-ui/src/clients/ClientSessions.tsx | 29 ++++- .../admin-ui/src/sessions/SessionsSection.tsx | 106 ++++++++++++++---- apps/admin-ui/src/sessions/SessionsTable.tsx | 16 ++- apps/admin-ui/src/user/UserSessions.tsx | 2 +- libs/keycloak-admin-client/src/client.ts | 3 - .../src/defs/clientSessionStat.ts | 6 + .../src/resources/realms.ts | 10 ++ .../src/resources/sessions.ts | 18 --- .../keycloak-admin-client/test/realms.spec.ts | 7 ++ .../test/sessions.spec.ts | 22 ---- 10 files changed, 146 insertions(+), 73 deletions(-) create mode 100644 libs/keycloak-admin-client/src/defs/clientSessionStat.ts delete mode 100644 libs/keycloak-admin-client/src/resources/sessions.ts delete mode 100644 libs/keycloak-admin-client/test/sessions.spec.ts diff --git a/apps/admin-ui/src/clients/ClientSessions.tsx b/apps/admin-ui/src/clients/ClientSessions.tsx index 7259540332..52c35435c2 100644 --- a/apps/admin-ui/src/clients/ClientSessions.tsx +++ b/apps/admin-ui/src/clients/ClientSessions.tsx @@ -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 = (first, max) => - adminClient.clients.listSessions({ id: client.id!, first, max }); + const loader: LoaderFunction = 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 ( diff --git a/apps/admin-ui/src/sessions/SessionsSection.tsx b/apps/admin-ui/src/sessions/SessionsSection.tsx index 8923b67f0d..00f542272e 100644 --- a/apps/admin-ui/src/sessions/SessionsSection.tsx +++ b/apps/admin-ui/src/sessions/SessionsSection.tsx @@ -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("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() { }} /> )} - + setFilterDropdownOpen(value)} + toggleIcon={} + onSelect={(_, value) => { + setFilterType(value as FilterType); + refresh(); + setFilterDropdownOpen(false); + }} + selections={filterType} + > + + {t("sessionsType.allSessions")} + + + {t("sessionsType.regularSSO")} + + + {t("sessionsType.offline")} + + + } + /> ); diff --git a/apps/admin-ui/src/sessions/SessionsTable.tsx b/apps/admin-ui/src/sessions/SessionsTable.tsx index 5a9b6a916a..d75aa947da 100644 --- a/apps/admin-ui/src/sessions/SessionsTable.tsx +++ b/apps/admin-ui/src/sessions/SessionsTable.tsx @@ -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; 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 && ( diff --git a/apps/admin-ui/src/user/UserSessions.tsx b/apps/admin-ui/src/user/UserSessions.tsx index c9b6006737..9941e844df 100644 --- a/apps/admin-ui/src/user/UserSessions.tsx +++ b/apps/admin-ui/src/user/UserSessions.tsx @@ -19,7 +19,7 @@ export const UserSessions = () => { diff --git a/libs/keycloak-admin-client/src/client.ts b/libs/keycloak-admin-client/src/client.ts index 655bee8e74..3256f48d10 100644 --- a/libs/keycloak-admin-client/src/client.ts +++ b/libs/keycloak-admin-client/src/client.ts @@ -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); } diff --git a/libs/keycloak-admin-client/src/defs/clientSessionStat.ts b/libs/keycloak-admin-client/src/defs/clientSessionStat.ts new file mode 100644 index 0000000000..b693614500 --- /dev/null +++ b/libs/keycloak-admin-client/src/defs/clientSessionStat.ts @@ -0,0 +1,6 @@ +export interface ClientSessionStat { + id: string; + clientId: string; + active: string; + offline: string; +} diff --git a/libs/keycloak-admin-client/src/resources/realms.ts b/libs/keycloak-admin-client/src/resources/realms.ts index 20e8f4fea4..7f86458c3e 100644 --- a/libs/keycloak-admin-client/src/resources/realms.ts +++ b/libs/keycloak-admin-client/src/resources/realms.ts @@ -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", diff --git a/libs/keycloak-admin-client/src/resources/sessions.ts b/libs/keycloak-admin-client/src/resources/sessions.ts deleted file mode 100644 index b9105411b6..0000000000 --- a/libs/keycloak-admin-client/src/resources/sessions.ts +++ /dev/null @@ -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[]>({ - method: "GET", - }); - - constructor(client: KeycloakAdminClient) { - super(client, { - path: "/admin/realms/{realm}/client-session-stats", - getUrlParams: () => ({ - realm: client.realmName, - }), - getBaseUrl: () => client.baseUrl, - }); - } -} diff --git a/libs/keycloak-admin-client/test/realms.spec.ts b/libs/keycloak-admin-client/test/realms.spec.ts index 3a772d9b82..e333b89b87 100644 --- a/libs/keycloak-admin-client/test/realms.spec.ts +++ b/libs/keycloak-admin-client/test/realms.spec.ts @@ -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, diff --git a/libs/keycloak-admin-client/test/sessions.spec.ts b/libs/keycloak-admin-client/test/sessions.spec.ts deleted file mode 100644 index 762ca35e6a..0000000000 --- a/libs/keycloak-admin-client/test/sessions.spec.ts +++ /dev/null @@ -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"); - }); -});