Add 'Session' tabs to user and client details (#2655)
This commit is contained in:
parent
9573b23831
commit
cc7ec1cf7f
13 changed files with 238 additions and 73 deletions
|
@ -493,5 +493,6 @@
|
|||
"expires": "Expires in",
|
||||
"never": "Never expires"
|
||||
},
|
||||
"mappers": "Mappers"
|
||||
"mappers": "Mappers",
|
||||
"sessions": "Sessions"
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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() {
|
|||
>
|
||||
<AdvancedTab save={save} client={client} />
|
||||
</Tab>
|
||||
<Tab
|
||||
id="sessions"
|
||||
data-testid="sessionsTab"
|
||||
title={<TabTitleText>{t("sessions")}</TabTitleText>}
|
||||
{...route("sessions")}
|
||||
>
|
||||
<ClientSessions client={client} />
|
||||
</Tab>
|
||||
</RoutableTabs>
|
||||
</FormProvider>
|
||||
</PageSection>
|
||||
|
|
31
src/clients/ClientSessions.tsx
Normal file
31
src/clients/ClientSessions.tsx
Normal file
|
@ -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<UserSessionRepresentation> = (first, max) =>
|
||||
adminClient.clients.listSessions({ id: client.id!, first, max });
|
||||
|
||||
return (
|
||||
<PageSection variant="light" className="pf-u-p-0">
|
||||
<SessionsTable
|
||||
loader={loader}
|
||||
hiddenColumns={["clients"]}
|
||||
emptyInstructions={t("noSessionsForClient")}
|
||||
/>
|
||||
</PageSection>
|
||||
);
|
||||
};
|
|
@ -13,7 +13,8 @@ export type ClientTab =
|
|||
| "mappers"
|
||||
| "authorization"
|
||||
| "serviceAccount"
|
||||
| "permissions";
|
||||
| "permissions"
|
||||
| "sessions";
|
||||
|
||||
export type ClientParams = {
|
||||
realm: string;
|
||||
|
|
|
@ -118,10 +118,10 @@ export type DetailField<T> = {
|
|||
};
|
||||
|
||||
export type Action<T> = IAction & {
|
||||
onRowClick?: (row: T) => Promise<boolean> | void;
|
||||
onRowClick?: (row: T) => Promise<boolean | void> | void;
|
||||
};
|
||||
|
||||
type LoaderFunction<T> = (
|
||||
export type LoaderFunction<T> = (
|
||||
first?: number,
|
||||
max?: number,
|
||||
search?: string
|
||||
|
|
|
@ -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]) => (
|
||||
<Link
|
||||
key={client}
|
||||
to={toClient({ clientId, realm, tab: "settings" })}
|
||||
className="pf-u-mx-sm"
|
||||
>
|
||||
{client}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const dropdownItems = [
|
||||
<DropdownItem
|
||||
key="toggle-modal"
|
||||
|
@ -128,40 +106,7 @@ export default function SessionsSection() {
|
|||
handleModalToggle={handleLogoutAllSessionsModalToggle}
|
||||
/>
|
||||
)}
|
||||
<KeycloakDataTable
|
||||
loader={loader}
|
||||
ariaLabelKey="session:title"
|
||||
searchPlaceholderKey="sessions:searchForSession"
|
||||
columns={[
|
||||
{
|
||||
name: "username",
|
||||
displayKey: "sessions:subject",
|
||||
},
|
||||
{
|
||||
name: "lastAccess",
|
||||
displayKey: "sessions:lastAccess",
|
||||
cellRenderer: (row) => moment(row.lastAccess).fromNow(),
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
displayKey: "sessions:startDate",
|
||||
cellRenderer: (row) => moment(row.lastAccess).format("LLL"),
|
||||
},
|
||||
{
|
||||
name: "clients",
|
||||
displayKey: "sessions:accessedClients",
|
||||
cellRenderer: Clients,
|
||||
},
|
||||
]}
|
||||
emptyState={
|
||||
<ListEmptyState
|
||||
hasIcon
|
||||
icon={CubesIcon}
|
||||
message={t("noSessions")}
|
||||
instructions={t("noSessionsDescription")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SessionsTable loader={loader} />
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
|
|
124
src/sessions/SessionsTable.tsx
Normal file
124
src/sessions/SessionsTable.tsx
Normal file
|
@ -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<UserSessionRepresentation>;
|
||||
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) => (
|
||||
<Link to={toUser({ realm, id: row.userId!, tab: "sessions" })}>
|
||||
{row.username}
|
||||
</Link>
|
||||
);
|
||||
|
||||
const ClientsCell = (row: UserSessionRepresentation) => (
|
||||
<List variant={ListVariant.inline}>
|
||||
{Object.entries(row.clients!).map(([clientId, client]) => (
|
||||
<ListItem key={clientId}>
|
||||
<Link to={toClient({ realm, clientId, tab: "sessions" })}>
|
||||
{client}
|
||||
</Link>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
|
||||
const defaultColumns: Field<UserSessionRepresentation>[] = [
|
||||
{
|
||||
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 (
|
||||
<KeycloakDataTable
|
||||
key={key}
|
||||
loader={loader}
|
||||
ariaLabelKey="sessions:title"
|
||||
searchPlaceholderKey="sessions:searchForSession"
|
||||
columns={columns}
|
||||
actions={[
|
||||
{
|
||||
title: t("common:signOut"),
|
||||
onRowClick: onClickSignOut,
|
||||
},
|
||||
]}
|
||||
emptyState={
|
||||
<ListEmptyState
|
||||
hasIcon
|
||||
icon={CubesIcon}
|
||||
message={t("noSessions")}
|
||||
instructions={
|
||||
emptyInstructions ? emptyInstructions : t("noSessionsDescription")
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
28
src/user/UserSessions.tsx
Normal file
28
src/user/UserSessions.tsx
Normal file
|
@ -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<UserParams>();
|
||||
const { realm } = useRealm();
|
||||
const { t } = useTranslation("sessions");
|
||||
|
||||
const loader = () => adminClient.users.listSessions({ id, realm });
|
||||
|
||||
return (
|
||||
<PageSection variant="light" className="pf-u-p-0">
|
||||
<SessionsTable
|
||||
loader={loader}
|
||||
hiddenColumns={["username"]}
|
||||
emptyInstructions={t("noSessionsForUser")}
|
||||
/>
|
||||
</PageSection>
|
||||
);
|
||||
};
|
|
@ -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 = () => {
|
|||
<UserIdentityProviderLinks />
|
||||
</Tab>
|
||||
)}
|
||||
<Tab
|
||||
eventKey="sessions"
|
||||
data-testid="user-sessions-tab"
|
||||
title={<TabTitleText>{t("sessions")}</TabTitleText>}
|
||||
>
|
||||
<UserSessions />
|
||||
</Tab>
|
||||
</KeycloakTabs>
|
||||
)}
|
||||
{!id && (
|
||||
|
|
|
@ -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;
|
||||
|
|
10
src/util.ts
10
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 =
|
||||
|
|
Loading…
Reference in a new issue