Sessions(actions dropdown): Adds actions dropdown - "revocation" and "log out all sessions" (#896)

* rebase and resolve conflicts/comments

* remove badgeid
This commit is contained in:
Jenny 2021-08-16 15:25:36 -04:00 committed by GitHub
parent b5e2fb802c
commit 412e4c1f6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 470 additions and 29 deletions

View file

@ -42,5 +42,27 @@ describe("Sessions test", function () {
it("Select 'Service account' dropdown option", () => {
sessionsPage.selectServiceAccount();
});
it("Set revocation notBefore", () => {
sessionsPage.setToNow();
});
it("Check if notBefore saved", () => {
sessionsPage.checkNotBeforeValueExists();
});
it("Clear revocation notBefore", () => {
sessionsPage.clearNotBefore();
});
it("Check if notBefore cleared", () => {
sessionsPage.checkNotBeforeCleared();
});
it("logout all sessions", () => {
sessionsPage.logoutAllSessions();
cy.get("#kc-page-title").contains("Sign in to your account");
});
});
});

View file

@ -1,23 +1,19 @@
export default class SessionsPage {
sessionTypeDrpDwn: string;
sessionTypeList: string;
allSessionTypesOption: string;
regularSSOOption: string;
offlineOption: string;
directGrantOption: string;
serviceAccountOption: string;
selectedType: string;
constructor() {
this.sessionTypeDrpDwn = ".pf-c-select__toggle";
this.sessionTypeList = ".pf-c-select__toggle + ul";
this.allSessionTypesOption = "all-sessions-option";
this.regularSSOOption = "regular-sso-option";
this.offlineOption = "offline-option";
this.directGrantOption = "direct-grant-option";
this.serviceAccountOption = "service-account-option";
this.selectedType = ".pf-c-select__toggle-text";
}
sessionTypeDrpDwn = ".pf-c-select__toggle";
sessionTypeList = ".pf-c-select__toggle + ul";
allSessionTypesOption = "all-sessions-option";
regularSSOOption = "regular-sso-option";
offlineOption = "offline-option";
directGrantOption = "direct-grant-option";
serviceAccountOption = "service-account-option";
selectedType = ".pf-c-select__toggle-text";
revocationActionItem = "revocation";
setToNowButton = "set-to-now-button";
actionDropdown = "action-dropdown";
clearNotBeforeButton = "clear-not-before-button";
notBeforeInput = "not-before-input";
logoutAll = "logout-all";
logoutAllConfirm = "logout-all-confirm-button";
shouldDisplay() {
cy.get(this.sessionTypeDrpDwn).should("exist");
@ -33,30 +29,60 @@ export default class SessionsPage {
selectAllSessionsType() {
cy.get(this.sessionTypeDrpDwn).click();
cy.getId(this.allSessionTypesOption).click();
cy.get(this.selectedType).should('have.text', 'All session types');
cy.get(this.selectedType).should("have.text", "All session types");
}
selectRegularSSO() {
cy.get(this.sessionTypeDrpDwn).click();
cy.getId(this.regularSSOOption).click();
cy.get(this.selectedType).should('have.text', 'Regular SSO');
cy.get(this.selectedType).should("have.text", "Regular SSO");
}
selectOffline() {
cy.get(this.sessionTypeDrpDwn).click();
cy.getId(this.offlineOption).click();
cy.get(this.selectedType).should('have.text', 'Offline');
cy.get(this.selectedType).should("have.text", "Offline");
}
selectDirectGrant() {
cy.get(this.sessionTypeDrpDwn).click();
cy.getId(this.directGrantOption).click();
cy.get(this.selectedType).should('have.text', 'Direct grant');
cy.get(this.selectedType).should("have.text", "Direct grant");
}
selectServiceAccount() {
cy.get(this.sessionTypeDrpDwn).click();
cy.getId(this.serviceAccountOption).click();
cy.get(this.selectedType).should('have.text', 'Service account');
cy.get(this.selectedType).should("have.text", "Service account");
}
setToNow() {
cy.getId(this.actionDropdown).click();
cy.getId(this.revocationActionItem).click();
cy.getId(this.setToNowButton).click();
}
checkNotBeforeValueExists() {
cy.getId(this.actionDropdown).click();
cy.getId(this.revocationActionItem).click();
cy.getId(this.notBeforeInput).should("not.have.value", "None");
}
clearNotBefore() {
cy.getId(this.actionDropdown).click();
cy.getId(this.revocationActionItem).click();
cy.getId(this.clearNotBeforeButton).click();
}
checkNotBeforeCleared() {
cy.getId(this.actionDropdown).click();
cy.getId(this.revocationActionItem).click();
cy.getId(this.notBeforeInput).should("have.value", "None");
}
logoutAllSessions() {
cy.getId(this.actionDropdown).click();
cy.getId(this.logoutAll).click();
cy.getId(this.logoutAllConfirm).click();
}
}

View file

@ -32,6 +32,7 @@ import { HelpItem } from "../help-enabler/HelpItem";
export type ViewHeaderProps = {
titleKey: string;
badges?: ViewHeaderBadge[];
isDropdownDisabled?: boolean;
subKey?: string | ReactNode;
actionsDropdownId?: string;
subKeyLinkProps?: FormattedLinkProps;
@ -54,6 +55,7 @@ export const ViewHeader = ({
actionsDropdownId,
titleKey,
badges,
isDropdownDisabled,
subKey,
subKeyLinkProps,
dropdownItems,
@ -138,6 +140,7 @@ export const ViewHeader = ({
position={DropdownPosition.right}
toggle={
<DropdownToggle
isDisabled={isDropdownDisabled}
id={actionsDropdownId}
onToggle={onDropdownToggle}
>

View file

@ -21,6 +21,10 @@ export class WhoAmI {
return this.me.displayName;
}
public getRealm() {
return this.me?.realm ?? "";
}
public getUserId(): string {
if (this.me === undefined) return "";

View file

@ -0,0 +1,73 @@
import React from "react";
import {
AlertVariant,
Button,
ButtonVariant,
Modal,
ModalVariant,
TextContent,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../context/auth/AdminClient";
import { useRealm } from "../context/realm-context/RealmContext";
import { useAlerts } from "../components/alert/Alerts";
type RevocationModalProps = {
handleModalToggle: () => void;
};
export const LogoutAllSessionsModal = ({
handleModalToggle,
}: RevocationModalProps) => {
const { t } = useTranslation("sessions");
const { addAlert } = useAlerts();
const { realm: realmName } = useRealm();
const adminClient = useAdminClient();
const logoutAllSessions = async () => {
try {
await adminClient.realms.logoutAll({ realm: realmName });
adminClient.keycloak.logout({ redirectUri: "" });
} catch (error) {
addAlert(t("logoutAllSessionsError", { error }), AlertVariant.danger);
}
};
return (
<Modal
variant={ModalVariant.small}
title={t("signOutAllActiveSessionsQuestion")}
isOpen={true}
onClose={handleModalToggle}
actions={[
<Button
data-testid="logout-all-confirm-button"
key="set-to-now"
variant="primary"
onClick={() => {
logoutAllSessions();
handleModalToggle();
}}
form="revocation-modal-form"
>
{t("realm-settings:confirm")}
</Button>,
<Button
id="modal-cancel"
key="cancel"
variant={ButtonVariant.link}
onClick={() => {
handleModalToggle();
}}
>
{t("common:cancel")}
</Button>,
]}
>
<TextContent className="kc-logout-all-description-text">
{t("logoutAllDescription")}
</TextContent>
</Modal>
);
};

View file

@ -0,0 +1,220 @@
import React, { useState } from "react";
import {
AlertVariant,
Button,
ButtonVariant,
Form,
FormGroup,
Modal,
ModalVariant,
TextContent,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { emailRegexPattern } from "../util";
import type RealmRepresentation from "keycloak-admin/lib/defs/realmRepresentation";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { useRealm } from "../context/realm-context/RealmContext";
import type ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { useAlerts } from "../components/alert/Alerts";
import type GlobalRequestResult from "keycloak-admin/lib/defs/globalRequestResult";
type RevocationModalProps = {
handleModalToggle: () => void;
activeClients: ClientRepresentation[];
save: () => void;
};
export const RevocationModal = ({
handleModalToggle,
save,
}: RevocationModalProps) => {
const { t } = useTranslation("sessions");
const { addAlert } = useAlerts();
const { realm: realmName } = useRealm();
const adminClient = useAdminClient();
const { register, errors, handleSubmit } = useForm();
const [realm, setRealm] = useState<RealmRepresentation>();
const [key, setKey] = useState(0);
const refresh = () => {
setKey(new Date().getTime());
};
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
(realm) => {
setRealm(realm);
},
[key]
);
const parseResult = (result: GlobalRequestResult, prefixKey: string) => {
const successCount = result.successRequests?.length || 0;
const failedCount = result.failedRequests?.length || 0;
if (successCount === 0 && failedCount === 0) {
addAlert(t("clients:noAdminUrlSet"), AlertVariant.warning);
} else if (failedCount > 0) {
addAlert(
t("clients:" + prefixKey + "Success", {
successNodes: result.successRequests,
}),
AlertVariant.success
);
addAlert(
t("clients:" + prefixKey + "Fail", {
failedNodes: result.failedRequests,
}),
AlertVariant.danger
);
} else {
addAlert(
t("clients:" + prefixKey + "Success", {
successNodes: result.successRequests,
}),
AlertVariant.success
);
}
};
const setToNow = async () => {
try {
await adminClient.realms.update(
{ realm: realmName },
{
realm: realmName,
notBefore: Date.now() / 1000,
}
);
addAlert(t("notBeforeSuccess"), AlertVariant.success);
} catch (error) {
addAlert(t("setToNowError", { error }), AlertVariant.danger);
}
};
const clearNotBefore = async () => {
try {
await adminClient.realms.update(
{ realm: realmName },
{
realm: realmName,
notBefore: 0,
}
);
addAlert(t("notBeforeClearedSuccess"), AlertVariant.success);
refresh();
} catch (error) {
addAlert(t("notBeforeError", { error }), AlertVariant.danger);
}
};
const push = async () => {
const result = adminClient.realms.pushRevocation({
realm: realmName,
}) as unknown as GlobalRequestResult;
parseResult(result, "notBeforePush");
refresh();
};
return (
<Modal
variant={ModalVariant.small}
title={t("revocation")}
isOpen={true}
onClose={handleModalToggle}
actions={[
<Button
data-testid="set-to-now-button"
key="set-to-now"
variant="tertiary"
onClick={() => {
setToNow();
handleModalToggle();
}}
form="revocation-modal-form"
>
{t("setToNow")}
</Button>,
<Button
data-testid="clear-not-before-button"
key="clear"
variant="tertiary"
onClick={() => {
clearNotBefore();
handleModalToggle();
}}
form="revocation-modal-form"
>
{t("clear")}
</Button>,
<Button
data-testid="modal-test-connection-button"
key="push"
variant="secondary"
onClick={() => {
push();
handleModalToggle();
}}
form="revocation-modal-form"
>
{t("push")}
</Button>,
<Button
id="modal-cancel"
key="cancel"
variant={ButtonVariant.link}
onClick={() => {
handleModalToggle();
}}
>
{t("common:cancel")}
</Button>,
]}
>
<TextContent className="kc-revocation-description-text">
{t("revocationDescription")}
</TextContent>
<Form
id="revocation-modal-form"
isHorizontal
onSubmit={handleSubmit(save)}
>
<FormGroup
className="kc-revocation-modal-form-group"
label={t("notBefore")}
name="notBefore"
fieldId="not-before"
validated={
errors.email ? ValidatedOptions.error : ValidatedOptions.default
}
>
<TextInput
data-testid="not-before-input"
ref={register({ required: true, pattern: emailRegexPattern })}
autoFocus
isReadOnly
value={
realm?.notBefore === 0
? (t("none") as string)
: new Date(realm?.notBefore! * 1000).toString()
}
type="text"
id="not-before"
name="notBefore"
validated={
errors.email ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
</Form>
</Modal>
);
};

View file

@ -2,3 +2,7 @@
margin-right: 10px;
width: 300px;
}
.pf-c-form__group.kc-revocation-modal-form-group {
display: inline-block !important;
}

View file

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import moment from "moment";
import {
DropdownItem,
PageSection,
Select,
SelectOption,
@ -16,7 +17,11 @@ import { ViewHeader } from "../components/view-header/ViewHeader";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useAdminClient } from "../context/auth/AdminClient";
import { CubesIcon } from "@patternfly/react-icons";
import "./SessionsSection.css";
import { RevocationModal } from "./RevocationModal";
import type ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { LogoutAllSessionsModal } from "./LogoutAllSessionsModal";
const Clients = (row: UserSessionRepresentation) => {
return (
@ -34,15 +39,30 @@ export const SessionsSection = () => {
const { t } = useTranslation("sessions");
const adminClient = useAdminClient();
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [revocationModalOpen, setRevocationModalOpen] = useState(false);
const [logoutAllSessionsModalOpen, setLogoutAllSessionsModalOpen] =
useState(false);
const [activeClientDetails, setActiveClientDetails] = useState<
ClientRepresentation[]
>([]);
const [filterType, setFilterType] = useState(
t("sessionsType.allSessions").toString()
);
const [key, setKey] = useState(0);
const [noSessions, setNoSessions] = useState(false);
const refresh = () => {
setKey(new Date().getTime());
};
const handleRevocationModalToggle = () => {
setRevocationModalOpen(!revocationModalOpen);
};
const handleLogoutAllSessionsModalToggle = () => {
setLogoutAllSessionsModalOpen(!logoutAllSessionsModalOpen);
};
const loader = async () => {
const activeClients = await adminClient.sessions.find();
const clientSessions = (
@ -53,6 +73,16 @@ export const SessionsSection = () => {
)
).flat();
setNoSessions(clientSessions.length === 0);
const allClients = await adminClient.clients.find();
const getActiveClientDetails = allClients.filter((x) =>
activeClients.map((y) => y.id).includes(x.id)
);
setActiveClientDetails(getActiveClientDetails);
const userIds = Array.from(
new Set(clientSessions.map((session) => session.userId))
);
@ -65,10 +95,48 @@ export const SessionsSection = () => {
return userSessions;
};
const dropdownItems = [
<DropdownItem
key="toggle-modal"
data-testid="revocation"
component="button"
onClick={() => handleRevocationModalToggle()}
>
{t("revocation")}
</DropdownItem>,
<DropdownItem
key="delete-role"
data-testid="logout-all"
component="button"
isDisabled={noSessions}
onClick={() => handleLogoutAllSessionsModalToggle()}
>
{t("signOutAllActiveSessions")}
</DropdownItem>,
];
return (
<>
<ViewHeader titleKey="sessions:title" subKey="sessions:sessionExplain" />
<ViewHeader
dropdownItems={dropdownItems}
titleKey="sessions:title"
subKey="sessions:sessionExplain"
/>
<PageSection variant="light" className="pf-u-p-0">
{revocationModalOpen && (
<RevocationModal
handleModalToggle={handleRevocationModalToggle}
activeClients={activeClientDetails}
save={() => {
handleRevocationModalToggle();
}}
/>
)}
{logoutAllSessionsModalOpen && (
<LogoutAllSessionsModal
handleModalToggle={handleLogoutAllSessionsModalToggle}
/>
)}
<KeycloakDataTable
key={key}
loader={loader}
@ -135,8 +203,10 @@ export const SessionsSection = () => {
]}
emptyState={
<ListEmptyState
message={t("emptyTitle")}
instructions={t("emptyInstructions")}
hasIcon
icon={CubesIcon}
message={t("noSessions")}
instructions={t("noSessionsDescription")}
/>
}
/>

View file

@ -14,7 +14,26 @@ export default {
directGrant: "Direct grant",
serviceAccount: "Service account",
},
emptyTitle: "No sessions",
emptyInstructions: "There are no active sessions on this realm",
revocation: "Revocation",
revocationDescription:
"This is a way to revoke all active sessions and access tokens. Not before means you can revoke any tokens issued before the date.",
notBefore: "Not before",
notBeforeSuccess: 'Success! "Not before" set for realm',
notBeforeError: 'Error clearing "Not Before" for realm: {{error}}',
notBeforeClearedSuccess: 'Success! "Not Before" cleared for realm.',
signOutAllActiveSessions: "Sign out all active sessions",
signOutAllActiveSessionsQuestion: "Sign out all active sessions?",
setToNow: "Set to now",
logoutAllDescription:
"If you sign out all active sessions, active subjects in this realm will be signed out.",
logoutAllSessionsError:
"Error! Failed to log out of all sessions: {{error}}.",
setToNowError: "Error! Failed to set notBefore to current date and time.",
clear: "Clear",
push: "Push",
none: "None",
noSessions: "No sessions",
noSessionsDescription:
"There are currently no active sessions in this realm.",
},
};