Users(Consents): add empty state and list consents (#516)
* user consents * remove form prop * address PR feedback from Stan and list consents in data table * update test * revert css updates
This commit is contained in:
parent
6f4ea86ecb
commit
2bcdf51075
9 changed files with 134 additions and 6 deletions
|
@ -126,8 +126,19 @@ describe("Users test", () => {
|
||||||
cy.getId("modalConfirm").click();
|
cy.getId("modalConfirm").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Delete user", function () {
|
it("Go to user consents test", function () {
|
||||||
|
cy.wait(1000);
|
||||||
listingPage.searchItem(itemId).itemExist(itemId);
|
listingPage.searchItem(itemId).itemExist(itemId);
|
||||||
|
|
||||||
|
cy.wait(1000);
|
||||||
|
listingPage.goToItemDetails(itemId);
|
||||||
|
|
||||||
|
cy.getId("user-consents-tab").click();
|
||||||
|
|
||||||
|
cy.getId("empty-state").contains("No consents");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Delete user test", function () {
|
||||||
// Delete
|
// Delete
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
listingPage.deleteItem(itemId);
|
listingPage.deleteItem(itemId);
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
"client-scopes": {
|
"client-scopes": {
|
||||||
"createClientScope": "Create client scope",
|
"createClientScope": "Create client scope",
|
||||||
"clientScopeList": "Client scopes",
|
"clientScopeList": "Client scopes",
|
||||||
|
"grantedClientScopes": "Granted client scopes",
|
||||||
"clientScopeDetails": "Client scope details",
|
"clientScopeDetails": "Client scope details",
|
||||||
"clientScopeExplain": "Client scopes allow you to define a common set of protocol mappers and roles, which are shared between multiple clients",
|
"clientScopeExplain": "Client scopes allow you to define a common set of protocol mappers and roles, which are shared between multiple clients",
|
||||||
"searchFor": "Search for client scope",
|
"searchFor": "Search for client scope",
|
||||||
|
|
|
@ -99,6 +99,7 @@ export const ClientsSection = () => {
|
||||||
<DeleteConfirm />
|
<DeleteConfirm />
|
||||||
<KeycloakDataTable
|
<KeycloakDataTable
|
||||||
key={key}
|
key={key}
|
||||||
|
emptyState={<> </>}
|
||||||
loader={loader}
|
loader={loader}
|
||||||
isPaginated
|
isPaginated
|
||||||
ariaLabelKey="clients:clientList"
|
ariaLabelKey="clients:clientList"
|
||||||
|
|
|
@ -83,6 +83,8 @@
|
||||||
"tokenDeleteSuccess": "initial access token created successfully",
|
"tokenDeleteSuccess": "initial access token created successfully",
|
||||||
"tokenDeleteError": "Could not delete initial access token: '{{error}}'",
|
"tokenDeleteError": "Could not delete initial access token: '{{error}}'",
|
||||||
"timestamp": "Created date",
|
"timestamp": "Created date",
|
||||||
|
"created": "Created",
|
||||||
|
"lastUpdated": "Last updated",
|
||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
"count": "Count",
|
"count": "Count",
|
||||||
"remainingCount": "Remaining count",
|
"remainingCount": "Remaining count",
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
EmptyStateSecondaryActions,
|
EmptyStateSecondaryActions,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
|
import { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon";
|
||||||
import { PlusCircleIcon } from "@patternfly/react-icons";
|
import { PlusCircleIcon } from "@patternfly/react-icons";
|
||||||
import { SearchIcon } from "@patternfly/react-icons";
|
import { SearchIcon } from "@patternfly/react-icons";
|
||||||
|
|
||||||
|
@ -23,6 +24,7 @@ export type ListEmptyStateProps = {
|
||||||
primaryActionText?: string;
|
primaryActionText?: string;
|
||||||
onPrimaryAction?: MouseEventHandler<HTMLButtonElement>;
|
onPrimaryAction?: MouseEventHandler<HTMLButtonElement>;
|
||||||
hasIcon?: boolean;
|
hasIcon?: boolean;
|
||||||
|
icon?: React.ComponentClass<SVGIconProps>;
|
||||||
isSearchVariant?: boolean;
|
isSearchVariant?: boolean;
|
||||||
secondaryActions?: Action[];
|
secondaryActions?: Action[];
|
||||||
};
|
};
|
||||||
|
@ -35,6 +37,7 @@ export const ListEmptyState = ({
|
||||||
isSearchVariant,
|
isSearchVariant,
|
||||||
primaryActionText,
|
primaryActionText,
|
||||||
secondaryActions,
|
secondaryActions,
|
||||||
|
icon,
|
||||||
}: ListEmptyStateProps) => {
|
}: ListEmptyStateProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -42,7 +45,7 @@ export const ListEmptyState = ({
|
||||||
{hasIcon && isSearchVariant ? (
|
{hasIcon && isSearchVariant ? (
|
||||||
<EmptyStateIcon icon={SearchIcon} />
|
<EmptyStateIcon icon={SearchIcon} />
|
||||||
) : (
|
) : (
|
||||||
hasIcon && <EmptyStateIcon icon={PlusCircleIcon} />
|
hasIcon && <EmptyStateIcon icon={icon ? icon : PlusCircleIcon} />
|
||||||
)}
|
)}
|
||||||
<Title headingLevel="h4" size="lg">
|
<Title headingLevel="h4" size="lg">
|
||||||
{message}
|
{message}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import _ from "lodash";
|
||||||
import { PaginatingTableToolbar } from "./PaginatingTableToolbar";
|
import { PaginatingTableToolbar } from "./PaginatingTableToolbar";
|
||||||
import { asyncStateFetch } from "../../context/auth/AdminClient";
|
import { asyncStateFetch } from "../../context/auth/AdminClient";
|
||||||
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
|
import { ListEmptyState } from "../list-empty-state/ListEmptyState";
|
||||||
|
import { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon";
|
||||||
|
|
||||||
type Row<T> = {
|
type Row<T> = {
|
||||||
data: T;
|
data: T;
|
||||||
|
@ -98,6 +99,7 @@ export type DataListProps<T> = {
|
||||||
searchTypeComponent?: ReactNode;
|
searchTypeComponent?: ReactNode;
|
||||||
toolbarItem?: ReactNode;
|
toolbarItem?: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
|
icon?: React.ComponentClass<SVGIconProps>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -136,6 +138,7 @@ export function KeycloakDataTable<T>({
|
||||||
searchTypeComponent,
|
searchTypeComponent,
|
||||||
toolbarItem,
|
toolbarItem,
|
||||||
emptyState,
|
emptyState,
|
||||||
|
icon,
|
||||||
...props
|
...props
|
||||||
}: DataListProps<T>) {
|
}: DataListProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -330,6 +333,7 @@ export function KeycloakDataTable<T>({
|
||||||
searchPlaceholderKey && (
|
searchPlaceholderKey && (
|
||||||
<ListEmptyState
|
<ListEmptyState
|
||||||
hasIcon={true}
|
hasIcon={true}
|
||||||
|
icon={icon}
|
||||||
isSearchVariant={true}
|
isSearchVariant={true}
|
||||||
message={t("noSearchResults")}
|
message={t("noSearchResults")}
|
||||||
instructions={t("noSearchResultsInstructions")}
|
instructions={t("noSearchResultsInstructions")}
|
||||||
|
|
94
src/user/UserConsents.tsx
Normal file
94
src/user/UserConsents.tsx
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PageSection } from "@patternfly/react-core";
|
||||||
|
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||||
|
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
||||||
|
import { emptyFormatter } from "../util";
|
||||||
|
import { useAdminClient } from "../context/auth/AdminClient";
|
||||||
|
import { cellWidth } from "@patternfly/react-table";
|
||||||
|
import _ from "lodash";
|
||||||
|
import UserConsentRepresentation from "keycloak-admin/lib/defs/userConsentRepresentation";
|
||||||
|
import { CubesIcon } from "@patternfly/react-icons";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
|
export const UserConsents = () => {
|
||||||
|
const { t } = useTranslation("roles");
|
||||||
|
|
||||||
|
const adminClient = useAdminClient();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const alphabetize = (consentsList: UserConsentRepresentation[]) => {
|
||||||
|
return _.sortBy(consentsList, (client) => client.clientId?.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const loader = async () => {
|
||||||
|
const consents = await adminClient.users.listConsents({ id });
|
||||||
|
|
||||||
|
return alphabetize(consents);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientScopesRenderer = ({
|
||||||
|
grantedClientScopes,
|
||||||
|
}: UserConsentRepresentation) => {
|
||||||
|
return <>{grantedClientScopes!.join(", ")}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdRenderer = ({ createDate }: UserConsentRepresentation) => {
|
||||||
|
return <>{moment(createDate).format("MM/DD/YY hh:MM A")}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastUpdatedRenderer = ({
|
||||||
|
lastUpdatedDate,
|
||||||
|
}: UserConsentRepresentation) => {
|
||||||
|
return <>{moment(lastUpdatedDate).format("MM/DD/YY hh:MM A")}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageSection variant="light">
|
||||||
|
<KeycloakDataTable
|
||||||
|
loader={loader}
|
||||||
|
ariaLabelKey="roles:roleList"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: "clientId",
|
||||||
|
displayKey: "clients:Client",
|
||||||
|
cellFormatters: [emptyFormatter()],
|
||||||
|
transforms: [cellWidth(20)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "grantedClientScopes",
|
||||||
|
displayKey: "client-scopes:grantedClientScopes",
|
||||||
|
cellFormatters: [emptyFormatter()],
|
||||||
|
cellRenderer: clientScopesRenderer,
|
||||||
|
transforms: [cellWidth(30)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "createdDate",
|
||||||
|
displayKey: "clients:created",
|
||||||
|
cellFormatters: [emptyFormatter()],
|
||||||
|
cellRenderer: createdRenderer,
|
||||||
|
transforms: [cellWidth(20)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lastUpdatedDate",
|
||||||
|
displayKey: "clients:lastUpdated",
|
||||||
|
cellFormatters: [emptyFormatter()],
|
||||||
|
cellRenderer: lastUpdatedRenderer,
|
||||||
|
transforms: [cellWidth(20)],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyState={
|
||||||
|
<ListEmptyState
|
||||||
|
hasIcon={true}
|
||||||
|
icon={CubesIcon}
|
||||||
|
message={t("users:noConsents")}
|
||||||
|
instructions={t("users:noConsentsText")}
|
||||||
|
onPrimaryAction={() => {}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -16,6 +16,7 @@ import { useAdminClient } from "../context/auth/AdminClient";
|
||||||
import { useHistory, useParams, useRouteMatch } from "react-router-dom";
|
import { useHistory, useParams, useRouteMatch } from "react-router-dom";
|
||||||
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
|
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
|
||||||
import { UserGroups } from "./UserGroups";
|
import { UserGroups } from "./UserGroups";
|
||||||
|
import { UserConsents } from "./UserConsents";
|
||||||
|
|
||||||
export const UsersTabs = () => {
|
export const UsersTabs = () => {
|
||||||
const { t } = useTranslation("roles");
|
const { t } = useTranslation("roles");
|
||||||
|
@ -24,7 +25,7 @@ export const UsersTabs = () => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const form = useForm<UserRepresentation>({ mode: "onChange" });
|
const userForm = useForm<UserRepresentation>({ mode: "onChange" });
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [user, setUser] = useState("");
|
const [user, setUser] = useState("");
|
||||||
|
|
||||||
|
@ -69,7 +70,7 @@ export const UsersTabs = () => {
|
||||||
data-testid="user-details-tab"
|
data-testid="user-details-tab"
|
||||||
title={<TabTitleText>{t("details")}</TabTitleText>}
|
title={<TabTitleText>{t("details")}</TabTitleText>}
|
||||||
>
|
>
|
||||||
<UserForm form={form} save={save} editMode={true} />
|
<UserForm form={userForm} save={save} editMode={true} />
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="groups"
|
eventKey="groups"
|
||||||
|
@ -78,9 +79,16 @@ export const UsersTabs = () => {
|
||||||
>
|
>
|
||||||
<UserGroups />
|
<UserGroups />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
eventKey="consents"
|
||||||
|
data-testid="user-consents-tab"
|
||||||
|
title={<TabTitleText>{t("users:consents")}</TabTitleText>}
|
||||||
|
>
|
||||||
|
<UserConsents />
|
||||||
|
</Tab>
|
||||||
</KeycloakTabs>
|
</KeycloakTabs>
|
||||||
)}
|
)}
|
||||||
{!id && <UserForm form={form} save={save} editMode={false} />}
|
{!id && <UserForm form={userForm} save={save} editMode={false} />}
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -52,7 +52,11 @@
|
||||||
"updatePassword": "Update Password",
|
"updatePassword": "Update Password",
|
||||||
"updateProfile": "Update Profile",
|
"updateProfile": "Update Profile",
|
||||||
"verifyEmail": "Verify Email",
|
"verifyEmail": "Verify Email",
|
||||||
"updateUserLocale": "Update User Locale"
|
"updateUserLocale": "Update User Locale",
|
||||||
|
"consents": "Consents",
|
||||||
|
"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."
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue