From cc7ec1cf7fdf86aca3c01e8335008c29121da1a7 Mon Sep 17 00:00:00 2001 From: Jon Koops Date: Wed, 18 May 2022 11:16:20 +0200 Subject: [PATCH] Add 'Session' tabs to user and client details (#2655) --- public/resources/en/clients.json | 3 +- public/resources/en/sessions.json | 10 +- public/resources/en/users.json | 1 + src/clients/ClientDetails.tsx | 9 ++ src/clients/ClientSessions.tsx | 31 +++++ src/clients/routes/Client.ts | 3 +- .../table-toolbar/KeycloakDataTable.tsx | 4 +- src/sessions/SessionsSection.tsx | 73 ++--------- src/sessions/SessionsTable.tsx | 124 ++++++++++++++++++ src/user/UserSessions.tsx | 28 ++++ src/user/UsersTabs.tsx | 8 ++ src/user/routes/User.ts | 7 +- src/util.ts | 10 ++ 13 files changed, 238 insertions(+), 73 deletions(-) create mode 100644 src/clients/ClientSessions.tsx create mode 100644 src/sessions/SessionsTable.tsx create mode 100644 src/user/UserSessions.tsx diff --git a/public/resources/en/clients.json b/public/resources/en/clients.json index 7c2c06ae7f..808ce56b7f 100644 --- a/public/resources/en/clients.json +++ b/public/resources/en/clients.json @@ -493,5 +493,6 @@ "expires": "Expires in", "never": "Never expires" }, - "mappers": "Mappers" + "mappers": "Mappers", + "sessions": "Sessions" } diff --git a/public/resources/en/sessions.json b/public/resources/en/sessions.json index 5d549b2416..2ee794f2d1 100644 --- a/public/resources/en/sessions.json +++ b/public/resources/en/sessions.json @@ -2,10 +2,10 @@ "title": "Sessions", "sessionExplain": "Sessions are sessions of users in this realm and the clients that they access within the session.", "searchForSession": "Search session", - "subject": "Subject", + "user": "User", "lastAccess": "Last access", - "startDate": "Start date", - "accessedClients": "Accessed clients", + "started": "Started", + "clients": "Clients", "sessionsType": { "allSessions": "All session types", "regularSSO": "Regular SSO", @@ -29,5 +29,7 @@ "push": "Push", "none": "None", "noSessions": "No sessions", - "noSessionsDescription": "There are currently no active sessions in this realm." + "noSessionsDescription": "There are currently no active sessions in this realm.", + "noSessionsForUser": "There are currently no active sessions for this user.", + "noSessionsForClient": "There are currently no active sessions for this client." } diff --git a/public/resources/en/users.json b/public/resources/en/users.json index b5f13f5e72..2ed26f2846 100644 --- a/public/resources/en/users.json +++ b/public/resources/en/users.json @@ -78,6 +78,7 @@ "verifyEmail": "Verify Email", "updateUserLocale": "Update User Locale", "consents": "Consents", + "sessions": "Sessions", "noConsents": "No consents", "noConsentsText": "The consents will only be recorded when users try to access a client that is configured to require consent. In that case, users will get a consent page which asks them to grant access to the client.", "identityProvider": "Identity provider", diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx index 1741addb6e..10c648433b 100644 --- a/src/clients/ClientDetails.tsx +++ b/src/clients/ClientDetails.tsx @@ -43,6 +43,7 @@ import { import useToggle from "../utils/useToggle"; import { AdvancedTab } from "./AdvancedTab"; import { ClientSettings } from "./ClientSettings"; +import { ClientSessions } from "./ClientSessions"; import { Credentials } from "./credentials/Credentials"; import { Keys } from "./keys/Keys"; import { ClientParams, ClientTab, toClient } from "./routes/Client"; @@ -595,6 +596,14 @@ export default function ClientDetails() { > + {t("sessions")}} + {...route("sessions")} + > + + diff --git a/src/clients/ClientSessions.tsx b/src/clients/ClientSessions.tsx new file mode 100644 index 0000000000..3c1d3b379e --- /dev/null +++ b/src/clients/ClientSessions.tsx @@ -0,0 +1,31 @@ +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 React from "react"; +import { useTranslation } from "react-i18next"; + +import type { LoaderFunction } from "../components/table-toolbar/KeycloakDataTable"; +import { useAdminClient } from "../context/auth/AdminClient"; +import SessionsTable from "../sessions/SessionsTable"; + +type ClientSessionsProps = { + client: ClientRepresentation; +}; + +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 }); + + return ( + + + + ); +}; diff --git a/src/clients/routes/Client.ts b/src/clients/routes/Client.ts index b877fe7c69..581cbe6f91 100644 --- a/src/clients/routes/Client.ts +++ b/src/clients/routes/Client.ts @@ -13,7 +13,8 @@ export type ClientTab = | "mappers" | "authorization" | "serviceAccount" - | "permissions"; + | "permissions" + | "sessions"; export type ClientParams = { realm: string; diff --git a/src/components/table-toolbar/KeycloakDataTable.tsx b/src/components/table-toolbar/KeycloakDataTable.tsx index 21f3efa79e..2b0422ae21 100644 --- a/src/components/table-toolbar/KeycloakDataTable.tsx +++ b/src/components/table-toolbar/KeycloakDataTable.tsx @@ -118,10 +118,10 @@ export type DetailField = { }; export type Action = IAction & { - onRowClick?: (row: T) => Promise | void; + onRowClick?: (row: T) => Promise | void; }; -type LoaderFunction = ( +export type LoaderFunction = ( first?: number, max?: number, search?: string diff --git a/src/sessions/SessionsSection.tsx b/src/sessions/SessionsSection.tsx index 77f2eed34d..b4a6c6cbdc 100644 --- a/src/sessions/SessionsSection.tsx +++ b/src/sessions/SessionsSection.tsx @@ -1,28 +1,20 @@ -import React, { useState } from "react"; -import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import moment from "moment"; -import { DropdownItem, PageSection } from "@patternfly/react-core"; -import { CubesIcon } from "@patternfly/react-icons"; - -import type UserSessionRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userSessionRepresentation"; import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; -import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; +import { DropdownItem, PageSection } from "@patternfly/react-core"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; + import { ViewHeader } from "../components/view-header/ViewHeader"; -import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { useAdminClient } from "../context/auth/AdminClient"; -import { RevocationModal } from "./RevocationModal"; -import { LogoutAllSessionsModal } from "./LogoutAllSessionsModal"; import helpUrls from "../help-urls"; -import { toClient } from "../clients/routes/Client"; -import { useRealm } from "../context/realm-context/RealmContext"; +import { LogoutAllSessionsModal } from "./LogoutAllSessionsModal"; +import { RevocationModal } from "./RevocationModal"; +import SessionsTable from "./SessionsTable"; import "./SessionsSection.css"; export default function SessionsSection() { - const { t } = useTranslation("sessions"); const adminClient = useAdminClient(); - const { realm } = useRealm(); + const { t } = useTranslation("sessions"); const [revocationModalOpen, setRevocationModalOpen] = useState(false); const [logoutAllSessionsModalOpen, setLogoutAllSessionsModalOpen] = useState(false); @@ -71,20 +63,6 @@ export default function SessionsSection() { return userSessions; }; - const Clients = (row: UserSessionRepresentation) => ( - <> - {Object.entries(row.clients!).map(([clientId, client]) => ( - - {client} - - ))} - - ); - const dropdownItems = [ )} - moment(row.lastAccess).fromNow(), - }, - { - name: "start", - displayKey: "sessions:startDate", - cellRenderer: (row) => moment(row.lastAccess).format("LLL"), - }, - { - name: "clients", - displayKey: "sessions:accessedClients", - cellRenderer: Clients, - }, - ]} - emptyState={ - - } - /> + ); diff --git a/src/sessions/SessionsTable.tsx b/src/sessions/SessionsTable.tsx new file mode 100644 index 0000000000..0050aee0eb --- /dev/null +++ b/src/sessions/SessionsTable.tsx @@ -0,0 +1,124 @@ +import type UserSessionRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userSessionRepresentation"; +import { List, ListItem, ListVariant } from "@patternfly/react-core"; +import { CubesIcon } from "@patternfly/react-icons"; +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; + +import { toClient } from "../clients/routes/Client"; +import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; +import { + Field, + KeycloakDataTable, + LoaderFunction, +} from "../components/table-toolbar/KeycloakDataTable"; +import { useAdminClient } from "../context/auth/AdminClient"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { useWhoAmI } from "../context/whoami/WhoAmI"; +import { toUser } from "../user/routes/User"; +import { dateFormatter } from "../util"; + +export type ColumnName = "username" | "start" | "lastAccess" | "clients"; + +export type SessionsTableProps = { + loader: LoaderFunction; + hiddenColumns?: ColumnName[]; + emptyInstructions?: string; +}; + +export default function SessionsTable({ + loader, + hiddenColumns = [], + emptyInstructions, +}: SessionsTableProps) { + const { realm } = useRealm(); + const { whoAmI } = useWhoAmI(); + const { t } = useTranslation("sessions"); + const adminClient = useAdminClient(); + const [key, setKey] = useState(0); + const locale = whoAmI.getLocale(); + const refresh = () => setKey((value) => value + 1); + + const columns = useMemo(() => { + const UsernameCell = (row: UserSessionRepresentation) => ( + + {row.username} + + ); + + const ClientsCell = (row: UserSessionRepresentation) => ( + + {Object.entries(row.clients!).map(([clientId, client]) => ( + + + {client} + + + ))} + + ); + + const defaultColumns: Field[] = [ + { + name: "username", + displayKey: "sessions:user", + cellRenderer: UsernameCell, + }, + { + name: "start", + displayKey: "sessions:started", + cellFormatters: [dateFormatter(locale)], + }, + { + name: "lastAccess", + displayKey: "sessions:lastAccess", + cellFormatters: [dateFormatter(locale)], + }, + { + name: "clients", + displayKey: "sessions:clients", + cellRenderer: ClientsCell, + }, + ]; + + return defaultColumns.filter( + ({ name }) => !hiddenColumns.includes(name as ColumnName) + ); + }, [realm, locale, hiddenColumns]); + + async function onClickSignOut(session: UserSessionRepresentation) { + await adminClient.realms.deleteSession({ realm, session: session.id! }); + + if (session.userId === whoAmI.getUserId()) { + await adminClient.keycloak?.logout({ redirectUri: "" }); + } else { + refresh(); + } + } + + return ( + + } + /> + ); +} diff --git a/src/user/UserSessions.tsx b/src/user/UserSessions.tsx new file mode 100644 index 0000000000..d976405e0a --- /dev/null +++ b/src/user/UserSessions.tsx @@ -0,0 +1,28 @@ +import { PageSection } from "@patternfly/react-core"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; + +import { useAdminClient } from "../context/auth/AdminClient"; +import { useRealm } from "../context/realm-context/RealmContext"; +import SessionsTable from "../sessions/SessionsTable"; +import type { UserParams } from "./routes/User"; + +export const UserSessions = () => { + const adminClient = useAdminClient(); + const { id } = useParams(); + const { realm } = useRealm(); + const { t } = useTranslation("sessions"); + + const loader = () => adminClient.users.listSessions({ id, realm }); + + return ( + + + + ); +}; diff --git a/src/user/UsersTabs.tsx b/src/user/UsersTabs.tsx index 2cbe1c6a69..745c15617e 100644 --- a/src/user/UsersTabs.tsx +++ b/src/user/UsersTabs.tsx @@ -28,6 +28,7 @@ import { toUsers } from "./routes/Users"; import { UserRoleMapping } from "./UserRoleMapping"; import { UserAttributes } from "./UserAttributes"; import { UserCredentials } from "./UserCredentials"; +import { UserSessions } from "./UserSessions"; import { useAccess } from "../context/access/Access"; import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; @@ -239,6 +240,13 @@ const UsersTabs = () => { )} + {t("sessions")}} + > + + )} {!id && ( diff --git a/src/user/routes/User.ts b/src/user/routes/User.ts index f9baf858a7..1afeb1da58 100644 --- a/src/user/routes/User.ts +++ b/src/user/routes/User.ts @@ -3,7 +3,12 @@ import { lazy } from "react"; import { generatePath } from "react-router-dom"; import type { RouteDef } from "../../route-config"; -export type UserTab = "settings" | "groups" | "consents" | "attributes"; +export type UserTab = + | "settings" + | "groups" + | "consents" + | "attributes" + | "sessions"; export type UserParams = { realm: string; diff --git a/src/util.ts b/src/util.ts index b2ced43dab..e6e9a2afff 100644 --- a/src/util.ts +++ b/src/util.ts @@ -138,6 +138,16 @@ export const getBaseUrl = (adminClient: KeycloakAdminClient) => { ); }; +export const dateFormatter = + (locale: string, options?: Intl.DateTimeFormatOptions): IFormatter => + (value) => { + if (typeof value !== "number" && typeof value !== "string") { + throw new Error("Date value should be a number or string."); + } + + return new Date(value).toLocaleString(locale, options); + }; + export const alphaRegexPattern = /[^A-Za-z]/g; export const emailRegexPattern =