Updated DataTable to be capable of select (#251)

* removed unused models

* added selectable to `DataList`

* changed groups to use new `DataList`

* fixed filter for JSX.Elements columns

* better refresh
This commit is contained in:
Erik Jan de Wit 2020-12-14 09:57:05 +01:00 committed by GitHub
parent af986e0f13
commit 6ae437c86a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 198 additions and 1172 deletions

View file

@ -1,222 +0,0 @@
import React, { useState } from "react";
import { useHistory } from "react-router-dom";
import {
ActionGroup,
AlertVariant,
Button,
Form,
FormGroup,
PageSection,
Select,
SelectOption,
SelectVariant,
Switch,
TextInput,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form";
import { ClientScopeRepresentation } from "../models/client-scope";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { useAlerts } from "../../components/alert/Alerts";
import { useAdminClient } from "../../context/auth/AdminClient";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useLoginProviders } from "../../context/server-info/ServerInfoProvider";
export const NewClientScopeForm = () => {
const { t } = useTranslation("client-scopes");
const helpText = useTranslation("client-scopes-help").t;
const { register, control, handleSubmit, errors } = useForm<
ClientScopeRepresentation
>();
const history = useHistory();
const adminClient = useAdminClient();
const providers = useLoginProviders();
const [open, isOpen] = useState(false);
const { addAlert } = useAlerts();
const save = async (clientScopes: ClientScopeRepresentation) => {
try {
const keyValues = Object.keys(clientScopes.attributes!).map((key) => {
const newKey = key.replace(/_/g, ".");
return { [newKey]: clientScopes.attributes![key] };
});
clientScopes.attributes = Object.assign({}, ...keyValues);
await adminClient.clientScopes.create({ ...clientScopes });
addAlert(t("createClientScopeSuccess"), AlertVariant.success);
} catch (error) {
addAlert(
`${t("createClientScopeError")} '${error}'`,
AlertVariant.danger
);
}
};
return (
<>
<ViewHeader
titleKey="client-scopes:createClientScope"
subKey="client-scopes:clientScopeExplain"
/>
<PageSection variant="light">
<Form isHorizontal onSubmit={handleSubmit(save)}>
<FormGroup
label={t("name")}
labelIcon={
<HelpItem
helpText={helpText("name")}
forLabel={t("name")}
forID="kc-name"
/>
}
fieldId="kc-name"
isRequired
validated={errors.name ? "error" : "default"}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={register({ required: true })}
type="text"
id="kc-name"
name="name"
/>
</FormGroup>
<FormGroup
label={t("description")}
labelIcon={
<HelpItem
helpText={helpText("description")}
forLabel={t("description")}
forID="kc-description"
/>
}
fieldId="kc-description"
>
<TextInput
ref={register}
type="text"
id="kc-description"
name="description"
/>
</FormGroup>
<FormGroup
label={t("protocol")}
labelIcon={
<HelpItem
helpText={helpText("protocol")}
forLabel="protocol"
forID="kc-protocol"
/>
}
fieldId="kc-protocol"
>
<Controller
name="protocol"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-protocol"
required
onToggle={() => isOpen(!open)}
onSelect={(_, value, isPlaceholder) => {
onChange(isPlaceholder ? "" : (value as string));
isOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("selectEncryptionType")}
placeholderText={t("common:selectOne")}
isOpen={open}
>
{providers.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
/>
))}
</Select>
)}
/>
</FormGroup>
<FormGroup
hasNoPaddingTop
label={t("displayOnConsentScreen")}
labelIcon={
<HelpItem
helpText={helpText("displayOnConsentScreen")}
forLabel={t("displayOnConsentScreen")}
forID="kc-display.on.consent.screen"
/>
}
fieldId="kc-display.on.consent.screen"
>
<Controller
name="attributes.display_on_consent_screen"
control={control}
defaultValue={false}
render={({ onChange, value }) => (
<Switch
id="kc-display.on.consent.screen"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("consentScreenText")}
labelIcon={
<HelpItem
helpText={helpText("consentScreenText")}
forLabel={t("consentScreenText")}
forID="kc-consent-screen-text"
/>
}
fieldId="kc-consent-screen-text"
>
<TextInput
ref={register}
type="text"
id="kc-consent-screen-text"
name="attributes.consent_screen_text"
/>
</FormGroup>
<FormGroup
label={t("guiOrder")}
labelIcon={
<HelpItem
helpText={helpText("guiOrder")}
forLabel={t("guiOrder")}
forID="kc-gui-order"
/>
}
fieldId="kc-gui-order"
>
<TextInput
ref={register}
type="number"
id="kc-gui-order"
name="attributes.gui_order"
/>
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button variant="link" onClick={() => history.push("..")}>
{t("common:cancel")}
</Button>
</ActionGroup>
</Form>
</PageSection>
</>
);
};

View file

@ -21,13 +21,13 @@ import {
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation"; import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import ProtocolMapperRepresentation from "keycloak-admin/lib/defs/protocolMapperRepresentation";
import { useAlerts } from "../../components/alert/Alerts"; import { useAlerts } from "../../components/alert/Alerts";
import { RealmContext } from "../../context/realm-context/RealmContext"; import { RealmContext } from "../../context/realm-context/RealmContext";
import { useAdminClient } from "../../context/auth/AdminClient"; import { useAdminClient } from "../../context/auth/AdminClient";
import { ViewHeader } from "../../components/view-header/ViewHeader"; import { ViewHeader } from "../../components/view-header/ViewHeader";
import { HelpItem } from "../../components/help-enabler/HelpItem"; import { HelpItem } from "../../components/help-enabler/HelpItem";
import { ProtocolMapperRepresentation } from "../models/client-scope";
export const RoleMappingForm = () => { export const RoleMappingForm = () => {
const { realm } = useContext(RealmContext); const { realm } = useContext(RealmContext);

View file

@ -21,10 +21,10 @@ import {
ValidatedOptions, ValidatedOptions,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { ConfigPropertyRepresentation } from "keycloak-admin/lib/defs/configPropertyRepresentation"; import { ConfigPropertyRepresentation } from "keycloak-admin/lib/defs/configPropertyRepresentation";
import ProtocolMapperRepresentation from "keycloak-admin/lib/defs/protocolMapperRepresentation";
import { ViewHeader } from "../../components/view-header/ViewHeader"; import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useAdminClient } from "../../context/auth/AdminClient"; import { useAdminClient } from "../../context/auth/AdminClient";
import { ProtocolMapperRepresentation } from "../models/client-scope";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { useAlerts } from "../../components/alert/Alerts"; import { useAlerts } from "../../components/alert/Alerts";

View file

@ -19,8 +19,8 @@ import {
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation";
import { ClientScopeRepresentation } from "../models/client-scope";
import { HelpItem } from "../../components/help-enabler/HelpItem"; import { HelpItem } from "../../components/help-enabler/HelpItem";
import { useAdminClient } from "../../context/auth/AdminClient"; import { useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts"; import { useAlerts } from "../../components/alert/Alerts";

View file

@ -1,24 +0,0 @@
/**
* https://www.keycloak.org/docs-api/4.1/rest-api/#_protocolmapperrepresentation
*/
export interface ProtocolMapperRepresentation {
config?: Record<string, any>;
id?: string;
name?: string;
protocol?: string;
protocolMapper?: string;
}
/**
* https://www.keycloak.org/docs-api/6.0/rest-api/index.html#_clientscoperepresentation
*/
export interface ClientScopeRepresentation {
attributes?: Record<string, any>;
description?: string;
id?: string;
name?: string;
protocol?: string;
protocolMappers?: ProtocolMapperRepresentation[];
}

View file

@ -1,119 +0,0 @@
export interface ClientRepresentation {
id?: string;
clientId?: string;
name?: string;
description?: string;
rootUrl?: string;
adminUrl?: string;
baseUrl?: string;
surrogateAuthRequired?: boolean;
enabled?: boolean;
alwaysDisplayInConsole?: boolean;
clientAuthenticatorType?: string;
secret?: string;
registrationAccessToken?: string;
defaultRoles?: string[];
redirectUris?: string[];
webOrigins?: string[];
notBefore?: number;
bearerOnly?: boolean;
consentRequired?: boolean;
standardFlowEnabled?: boolean;
implicitFlowEnabled?: boolean;
directAccessGrantsEnabled?: boolean;
serviceAccountsEnabled?: boolean;
authorizationServicesEnabled?: boolean;
directGrantsOnly?: boolean;
publicClient?: boolean;
frontchannelLogout?: boolean;
protocol?: string;
attributes?: { [index: string]: string };
authenticationFlowBindingOverrides?: { [index: string]: string };
fullScopeAllowed?: boolean;
nodeReRegistrationTimeout?: number;
registeredNodes?: { [index: string]: number };
protocolMappers?: ProtocolMapperRepresentation[];
clientTemplate?: string;
useTemplateConfig?: boolean;
useTemplateScope?: boolean;
useTemplateMappers?: boolean;
defaultClientScopes?: string[];
optionalClientScopes?: string[];
authorizationSettings?: ResourceServerRepresentation;
access?: { [index: string]: boolean };
origin?: string;
}
export interface ProtocolMapperRepresentation {
id: string;
name: string;
protocol: string;
protocolMapper: string;
consentRequired: boolean;
consentText: string;
config: { [index: string]: string };
}
export interface ResourceServerRepresentation {
id: string;
clientId: string;
name: string;
allowRemoteResourceManagement: boolean;
policyEnforcementMode: PolicyEnforcementMode;
resources: ResourceRepresentation[];
policies: PolicyRepresentation[];
scopes: ScopeRepresentation[];
decisionStrategy: DecisionStrategy;
}
export interface ResourceRepresentation {
name: string;
type: string;
owner: ResourceOwnerRepresentation;
ownerManagedAccess: boolean;
displayName: string;
attributes: { [index: string]: string[] };
_id: string;
uris: string[];
scopes: ScopeRepresentation[];
icon_uri: string;
}
export interface PolicyRepresentation extends AbstractPolicyRepresentation {
config: { [index: string]: string };
}
export interface ScopeRepresentation {
id: string;
name: string;
iconUri: string;
policies: PolicyRepresentation[];
resources: ResourceRepresentation[];
displayName: string;
}
export interface ResourceOwnerRepresentation {
id: string;
name: string;
}
export interface AbstractPolicyRepresentation {
id: string;
name: string;
description: string;
type: string;
policies: string[];
resources: string[];
scopes: string[];
logic: Logic;
decisionStrategy: DecisionStrategy;
owner: string;
resourcesData: ResourceRepresentation[];
scopesData: ScopeRepresentation[];
}
export type PolicyEnforcementMode = "ENFORCING" | "PERMISSIVE" | "DISABLED";
export type DecisionStrategy = "AFFIRMATIVE" | "UNANIMOUS" | "CONSENSUS";
export type Logic = "POSITIVE" | "NEGATIVE";

View file

@ -9,14 +9,16 @@ import {
TableHeader, TableHeader,
TableVariant, TableVariant,
} from "@patternfly/react-table"; } from "@patternfly/react-table";
import { PaginatingTableToolbar } from "./PaginatingTableToolbar";
import { Spinner } from "@patternfly/react-core"; import { Spinner } from "@patternfly/react-core";
import { TableToolbar } from "./TableToolbar";
import _ from "lodash"; import _ from "lodash";
import { PaginatingTableToolbar } from "./PaginatingTableToolbar";
import { TableToolbar } from "./TableToolbar";
type Row<T> = { type Row<T> = {
data: T; data: T;
cells: (keyof T)[]; selected: boolean;
cells: (keyof T | JSX.Element)[];
}; };
type DataTableProps<T> = { type DataTableProps<T> = {
@ -24,6 +26,8 @@ type DataTableProps<T> = {
columns: Field<T>[]; columns: Field<T>[];
rows: Row<T>[]; rows: Row<T>[];
actions?: IActions; actions?: IActions;
onSelect?: (isSelected: boolean, rowIndex: number) => void;
canSelectAll: boolean;
}; };
function DataTable<T>({ function DataTable<T>({
@ -31,11 +35,19 @@ function DataTable<T>({
rows, rows,
actions, actions,
ariaLabelKey, ariaLabelKey,
onSelect,
canSelectAll,
}: DataTableProps<T>) { }: DataTableProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Table <Table
variant={TableVariant.compact} variant={TableVariant.compact}
onSelect={
onSelect
? (_, isSelected, rowIndex) => onSelect(isSelected, rowIndex)
: undefined
}
canSelectAll={canSelectAll}
cells={columns.map((column) => { cells={columns.map((column) => {
return { ...column, title: t(column.displayKey || column.name) }; return { ...column, title: t(column.displayKey || column.name) };
})} })}
@ -62,6 +74,8 @@ export type Action<T> = IAction & {
export type DataListProps<T> = { export type DataListProps<T> = {
loader: (first?: number, max?: number, search?: string) => Promise<T[]>; loader: (first?: number, max?: number, search?: string) => Promise<T[]>;
onSelect?: (value: T[]) => void;
canSelectAll?: boolean;
isPaginated?: boolean; isPaginated?: boolean;
ariaLabelKey: string; ariaLabelKey: string;
searchPlaceholderKey: string; searchPlaceholderKey: string;
@ -96,6 +110,8 @@ export function KeycloakDataTable<T>({
ariaLabelKey, ariaLabelKey,
searchPlaceholderKey, searchPlaceholderKey,
isPaginated = false, isPaginated = false,
onSelect,
canSelectAll = false,
loader, loader,
columns, columns,
actions, actions,
@ -119,6 +135,7 @@ export function KeycloakDataTable<T>({
data!.map((value) => { data!.map((value) => {
return { return {
data: value, data: value,
selected: false,
cells: columns.map((col) => { cells: columns.map((col) => {
if (col.cellRenderer) { if (col.cellRenderer) {
return col.cellRenderer(value); return col.cellRenderer(value);
@ -135,12 +152,26 @@ export function KeycloakDataTable<T>({
load(); load();
}, [first, max]); }, [first, max]);
const getNodeText = (node: keyof T | JSX.Element): string => {
if (["string", "number"].includes(typeof node)) {
return node!.toString();
}
if (node instanceof Array) {
return node.map(getNodeText).join("");
}
if (typeof node === "object" && node) {
return getNodeText(node.props.children);
}
return "";
};
const filter = (search: string) => { const filter = (search: string) => {
setFilteredData( setFilteredData(
rows!.filter((row) => rows!.filter((row) =>
row.cells.some( row.cells.some(
(cell) => (cell) =>
cell && cell.toString().toLowerCase().includes(search.toLowerCase()) cell &&
getNodeText(cell).toLowerCase().includes(search.toLowerCase())
) )
) )
); );
@ -173,6 +204,21 @@ export function KeycloakDataTable<T>({
</div> </div>
); );
const _onSelect = (isSelected: boolean, rowIndex: number) => {
if (rowIndex === -1) {
setRows(
rows!.map((row) => {
row.selected = isSelected;
return row;
})
);
} else {
rows![rowIndex].selected = isSelected;
setRows([...rows!]);
}
onSelect!(rows!.filter((row) => row.selected).map((row) => row.data));
};
return ( return (
<> <>
{!rows && <Loading />} {!rows && <Loading />}
@ -195,6 +241,8 @@ export function KeycloakDataTable<T>({
> >
{!loading && (emptyState === undefined || rows.length !== 0) && ( {!loading && (emptyState === undefined || rows.length !== 0) && (
<DataTable <DataTable
canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined}
actions={convertAction()} actions={convertAction()}
rows={rows} rows={rows}
columns={columns} columns={columns}
@ -215,6 +263,8 @@ export function KeycloakDataTable<T>({
> >
{(emptyState === undefined || rows.length !== 0) && ( {(emptyState === undefined || rows.length !== 0) && (
<DataTable <DataTable
canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined}
actions={convertAction()} actions={convertAction()}
rows={filteredData || rows} rows={filteredData || rows}
columns={columns} columns={columns}

View file

@ -9,7 +9,7 @@ import {
import moment from "moment"; import moment from "moment";
import { useAdminClient } from "../context/auth/AdminClient"; import { useAdminClient } from "../context/auth/AdminClient";
import { DataList } from "../components/table-toolbar/DataList"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { RealmContext } from "../context/realm-context/RealmContext"; import { RealmContext } from "../context/realm-context/RealmContext";
import { import {
Table, Table,
@ -88,7 +88,7 @@ export const AdminEvents = () => {
some json from the changed values some json from the changed values
</DisplayDialog> </DisplayDialog>
)} )}
<DataList <KeycloakDataTable
key={key} key={key}
loader={loader} loader={loader}
isPaginated isPaginated

View file

@ -14,7 +14,7 @@ import EventRepresentation from "keycloak-admin/lib/defs/eventRepresentation";
import { useAdminClient } from "../context/auth/AdminClient"; import { useAdminClient } from "../context/auth/AdminClient";
import { ViewHeader } from "../components/view-header/ViewHeader"; import { ViewHeader } from "../components/view-header/ViewHeader";
import { DataList } from "../components/table-toolbar/DataList"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { RealmContext } from "../context/realm-context/RealmContext"; import { RealmContext } from "../context/realm-context/RealmContext";
import { InfoCircleIcon } from "@patternfly/react-icons"; import { InfoCircleIcon } from "@patternfly/react-icons";
import { AdminEvents } from "./AdminEvents"; import { AdminEvents } from "./AdminEvents";
@ -64,7 +64,7 @@ export const EventsSection = () => {
eventKey={0} eventKey={0}
title={<TabTitleText>{t("userEvents")}</TabTitleText>} title={<TabTitleText>{t("userEvents")}</TabTitleText>}
> >
<DataList <KeycloakDataTable
key={key} key={key}
loader={loader} loader={loader}
isPaginated isPaginated

View file

@ -1,125 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Table,
TableHeader,
TableBody,
TableVariant,
} from "@patternfly/react-table";
import { Button, AlertVariant } from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { GroupRepresentation } from "./models/groups";
import { UsersIcon } from "@patternfly/react-icons";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
export type GroupsListProps = {
list?: GroupRepresentation[];
refresh: () => void;
tableRowSelectedArray: number[];
setTableRowSelectedArray: (tableRowSelectedArray: number[]) => void;
};
type FormattedData = {
cells: JSX.Element[];
selected: boolean;
};
export const GroupsList = ({
list,
refresh,
tableRowSelectedArray,
setTableRowSelectedArray,
}: GroupsListProps) => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const columnGroupName: keyof GroupRepresentation = "name";
const columnGroupNumber: keyof GroupRepresentation = "membersLength";
const { addAlert } = useAlerts();
const [formattedData, setFormattedData] = useState<FormattedData[]>([]);
const formatData = (data: GroupRepresentation[]) =>
data.map((group: { [key: string]: any }, index) => {
const groupName = group[columnGroupName];
const groupNumber = group[columnGroupNumber];
return {
cells: [
<Button variant="link" key={index}>
{groupName}
</Button>,
<div className="keycloak-admin--groups__member-count" key={index}>
<UsersIcon key={`user-icon-${index}`} />
{groupNumber}
</div>,
],
selected: false,
};
});
useEffect(() => {
setFormattedData(formatData(list!));
}, [list]);
function onSelect(isSelected: boolean, rowId: number) {
const localRow = [...formattedData];
const localTableRow = [...tableRowSelectedArray];
if (localRow[rowId].selected !== isSelected) {
localRow[rowId].selected = isSelected;
}
if (localTableRow.includes(rowId)) {
const index = localTableRow.indexOf(rowId);
if (index === 0) {
localTableRow.shift();
} else {
localTableRow.splice(index, 1);
}
setTableRowSelectedArray(localTableRow);
} else {
setTableRowSelectedArray([rowId, ...tableRowSelectedArray]);
}
setFormattedData(localRow);
}
const tableHeader = [{ title: t("groupName") }, { title: t("members") }];
const actions = [
{
title: t("moveTo"),
onClick: () => console.log("TO DO: Add move to functionality"),
},
{
title: t("common:Delete"),
onClick: async (
_: React.MouseEvent<Element, MouseEvent>,
rowId: number
) => {
try {
await adminClient.groups.del({ id: list![rowId].id! });
refresh();
setTableRowSelectedArray([]);
addAlert(t("Group deleted"), AlertVariant.success);
} catch (error) {
addAlert(`${t("clientDeleteError")} ${error}`, AlertVariant.danger);
}
},
},
];
return (
<>
{formattedData && (
<Table
actions={actions}
variant={TableVariant.compact}
onSelect={(_, isSelected, rowId) => onSelect(isSelected, rowId)}
canSelectAll={false}
aria-label={t("tableOfGroups")}
cells={tableHeader}
rows={formattedData}
>
<TableHeader />
<TableBody />
</Table>
)}
</>
);
};

View file

@ -1,12 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { GroupsList } from "./GroupsList";
import { GroupsCreateModal } from "./GroupsCreateModal";
import { TableToolbar } from "../components/table-toolbar/TableToolbar";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
import { import {
Button, Button,
Dropdown, Dropdown,
@ -14,180 +7,180 @@ import {
KebabToggle, KebabToggle,
PageSection, PageSection,
PageSectionVariants, PageSectionVariants,
Spinner,
ToolbarItem, ToolbarItem,
AlertVariant, AlertVariant,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import "./GroupsSection.css"; import { UsersIcon } from "@patternfly/react-icons";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation"; import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { GroupsCreateModal } from "./GroupsCreateModal";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import "./GroupsSection.css";
import { Link } from "react-router-dom";
type GroupTableData = GroupRepresentation & {
membersLength?: number;
};
export const GroupsSection = () => { export const GroupsSection = () => {
const { t } = useTranslation("groups"); const { t } = useTranslation("groups");
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const [rawData, setRawData] = useState<{ [key: string]: any }[]>();
const [filteredData, setFilteredData] = useState<{ [key: string]: any }[]>();
const [isKebabOpen, setIsKebabOpen] = useState(false); const [isKebabOpen, setIsKebabOpen] = useState(false);
const [createGroupName, setCreateGroupName] = useState(""); const [createGroupName, setCreateGroupName] = useState("");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [tableRowSelectedArray, setTableRowSelectedArray] = useState< const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
Array<number>
>([]);
const columnID: keyof GroupRepresentation = "id";
const columnGroupName: keyof GroupRepresentation = "name";
const { addAlert } = useAlerts(); const { addAlert } = useAlerts();
const membersLength = "membersLength"; const [key, setKey] = useState("");
const refresh = () => setKey(`${new Date().getTime()}`);
const getMembers = async (id: string) => {
const response = await adminClient.groups.listMembers({ id });
return response ? response.length : 0;
};
const loader = async () => { const loader = async () => {
const groupsData = await adminClient.groups.find(); const groupsData = await adminClient.groups.find();
const getMembers = async (id: string) => { const memberPromises = groupsData.map((group) => getMembers(group.id!));
const response = await adminClient.groups.listMembers({ id });
return response.length;
};
const memberPromises = groupsData.map((group: { [key: string]: any }) =>
getMembers(group[columnID])
);
const memberData = await Promise.all(memberPromises); const memberData = await Promise.all(memberPromises);
const updatedObject = groupsData.map( const updatedObject = groupsData.map((group: GroupTableData, i) => {
(group: { [key: string]: any }, i: number) => { group.membersLength = memberData[i];
const object = Object.assign({}, group); return group;
object[membersLength] = memberData[i];
return object;
}
);
setFilteredData(updatedObject);
setRawData(updatedObject);
};
useEffect(() => {
loader();
}, []);
// Filter groups
const filterGroups = (newInput: string) => {
const localRowData = rawData!.filter((obj: { [key: string]: string }) => {
const groupName = obj[columnGroupName];
return groupName.toLowerCase().includes(newInput.toLowerCase());
}); });
setFilteredData(localRowData); return updatedObject;
};
// Kebab delete action
const onKebabToggle = (isOpen: boolean) => {
setIsKebabOpen(isOpen);
};
const onKebabSelect = () => {
setIsKebabOpen(!isKebabOpen);
}; };
const handleModalToggle = () => { const handleModalToggle = () => {
setIsCreateModalOpen(!isCreateModalOpen); setIsCreateModalOpen(!isCreateModalOpen);
}; };
const multiDelete = async () => { const deleteGroup = (group: GroupRepresentation) => {
if (tableRowSelectedArray.length !== 0) { try {
const deleteGroup = async (rowId: number) => { return adminClient.groups.del({
try { id: group.id!,
await adminClient.groups.del({
id: filteredData ? filteredData![rowId].id : rawData![rowId].id,
});
loader();
} catch (error) {
addAlert(`${t("groupDeleteError")} ${error}`, AlertVariant.danger);
}
};
const chainedPromises = tableRowSelectedArray.map((rowId: number) => {
deleteGroup(rowId);
}); });
} catch (error) {
await Promise.all(chainedPromises) addAlert(t("groupDeleteError", { error }), AlertVariant.danger);
.then(() => addAlert(t("groupsDeleted"), AlertVariant.success))
.then(() => setTableRowSelectedArray([]));
} }
}; };
const multiDelete = async () => {
if (selectedRows!.length !== 0) {
const chainedPromises = selectedRows!.map((group) => deleteGroup(group));
await Promise.all(chainedPromises);
addAlert(t("groupsDeleted"), AlertVariant.success);
setSelectedRows([]);
refresh();
}
};
const GroupNameCell = (group: GroupTableData) => (
<>
<Link key={group.id} to={`/groups/${group.id}`}>
{group.name}
</Link>
</>
);
const GroupMemberCell = (group: GroupTableData) => (
<div className="keycloak-admin--groups__member-count">
<UsersIcon key={`user-icon-${group.id}`} />
{group.membersLength}
</div>
);
return ( return (
<> <>
<ViewHeader titleKey="groups:groups" subKey="groups:groupsDescription" /> <ViewHeader titleKey="groups:groups" subKey="groups:groupsDescription" />
<PageSection variant={PageSectionVariants.light}> <PageSection variant={PageSectionVariants.light}>
{!rawData && ( <KeycloakDataTable
<div className="pf-u-text-align-center"> key={key}
<Spinner /> onSelect={(rows) => setSelectedRows([...rows])}
</div> canSelectAll={false}
)} loader={loader}
{rawData && rawData.length > 0 ? ( ariaLabelKey="client-scopes:clientScopeList"
<> searchPlaceholderKey="client-scopes:searchFor"
<TableToolbar toolbarItem={
inputGroupName="groupsToolbarTextInput" <>
inputGroupPlaceholder={t("searchGroups")} <ToolbarItem>
inputGroupOnChange={filterGroups} <Button variant="primary" onClick={handleModalToggle}>
toolbarItem={ {t("createGroup")}
<> </Button>
<ToolbarItem> </ToolbarItem>
<Button <ToolbarItem>
variant="primary" <Dropdown
onClick={() => handleModalToggle()} toggle={
> <KebabToggle
{t("createGroup")} onToggle={() => setIsKebabOpen(!isKebabOpen)}
</Button>
</ToolbarItem>
<ToolbarItem>
<Dropdown
onSelect={onKebabSelect}
toggle={<KebabToggle onToggle={onKebabToggle} />}
isOpen={isKebabOpen}
isPlain
dropdownItems={[
<DropdownItem
key="action"
component="button"
onClick={() => multiDelete()}
>
{t("common:Delete")}
</DropdownItem>,
]}
/> />
</ToolbarItem> }
</> isOpen={isKebabOpen}
} isPlain
> dropdownItems={[
{rawData && ( <DropdownItem
<GroupsList key="action"
list={filteredData ? filteredData : rawData} component="button"
refresh={loader} onClick={() => {
tableRowSelectedArray={tableRowSelectedArray} multiDelete();
setTableRowSelectedArray={setTableRowSelectedArray} setIsKebabOpen(false);
}}
>
{t("common:delete")}
</DropdownItem>,
]}
/> />
)} </ToolbarItem>
{filteredData && filteredData.length === 0 && ( </>
<ListEmptyState }
hasIcon={true} actions={[
isSearchVariant={true} {
message={t("noSearchResults")} title: t("moveTo"),
instructions={t("noSearchResultsInstructions")} onRowClick: () => console.log("TO DO: Add move to functionality"),
/> },
)} {
</TableToolbar> title: t("common:delete"),
</> onRowClick: async (group: GroupRepresentation) => {
) : ( await deleteGroup(group);
<ListEmptyState return true;
hasIcon={true} },
message={t("noGroupsInThisRealm")} },
instructions={t("noGroupsInThisRealmInstructions")} ]}
primaryActionText={t("createGroup")} columns={[
onPrimaryAction={() => handleModalToggle()} {
/> name: "name",
)} displayKey: "groups:groupName",
cellRenderer: GroupNameCell,
},
{
name: "members",
displayKey: "groups:members",
cellRenderer: GroupMemberCell,
},
]}
emptyState={
<ListEmptyState
hasIcon={true}
message={t("noGroupsInThisRealm")}
instructions={t("noGroupsInThisRealmInstructions")}
primaryActionText={t("createGroup")}
onPrimaryAction={() => handleModalToggle()}
/>
}
/>
<GroupsCreateModal <GroupsCreateModal
isCreateModalOpen={isCreateModalOpen} isCreateModalOpen={isCreateModalOpen}
handleModalToggle={handleModalToggle} handleModalToggle={handleModalToggle}
setIsCreateModalOpen={setIsCreateModalOpen} setIsCreateModalOpen={setIsCreateModalOpen}
createGroupName={createGroupName} createGroupName={createGroupName}
setCreateGroupName={setCreateGroupName} setCreateGroupName={setCreateGroupName}
refresh={loader} refresh={refresh}
/> />
</PageSection> </PageSection>
</> </>

View file

@ -19,7 +19,8 @@
"noSearchResultsInstructions": "Click on the search bar above to search for groups", "noSearchResultsInstructions": "Click on the search bar above to search for groups",
"noGroupsInThisRealm": "No groups in this realm", "noGroupsInThisRealm": "No groups in this realm",
"noGroupsInThisRealmInstructions": "You haven't created any groups in this realm. Create a group to get started.", "noGroupsInThisRealmInstructions": "You haven't created any groups in this realm. Create a group to get started.",
"groupDelete": "Group deleted",
"groupsDeleted": "Groups deleted", "groupsDeleted": "Groups deleted",
"groupDeleteError": "Error deleting group" "groupDeleteError": "Error deleting group {error}"
} }
} }

View file

@ -1,13 +0,0 @@
export interface GroupRepresentation {
id?: string;
name?: string;
path?: string;
attributes?: { [index: string]: string[] };
realmRoles?: string[];
clientRoles?: { [index: string]: string[] };
subGroups?: GroupRepresentation[];
access?: { [index: string]: boolean };
groupNumber?: number;
membersLength?: number;
list?: [];
}

View file

@ -1,15 +0,0 @@
export interface ServerGroupsRepresentation {
id?: number;
name?: string;
path?: string;
subGroups?: [];
}
// TO DO: Update this to represent the data that is returned
export interface ServerGroupMembersRepresentation {
data?: [];
}
export interface ServerGroupsArrayRepresentation {
groups: { [index: string]: ServerGroupsRepresentation[] };
}

View file

@ -1,500 +0,0 @@
import { ClientScopeRepresentation } from "../../client-scopes/models/client-scope";
export interface RealmRepresentation {
id: string;
realm: string;
displayName?: string;
displayNameHtml?: string;
notBefore?: number;
defaultSignatureAlgorithm?: string;
revokeRefreshToken?: boolean;
refreshTokenMaxReuse?: number;
accessTokenLifespan?: number;
accessTokenLifespanForImplicitFlow?: number;
ssoSessionIdleTimeout?: number;
ssoSessionMaxLifespan?: number;
ssoSessionIdleTimeoutRememberMe?: number;
ssoSessionMaxLifespanRememberMe?: number;
offlineSessionIdleTimeout?: number;
offlineSessionMaxLifespanEnabled?: boolean;
offlineSessionMaxLifespan?: number;
clientSessionIdleTimeout?: number;
clientSessionMaxLifespan?: number;
clientOfflineSessionIdleTimeout?: number;
clientOfflineSessionMaxLifespan?: number;
accessCodeLifespan?: number;
accessCodeLifespanUserAction?: number;
accessCodeLifespanLogin?: number;
actionTokenGeneratedByAdminLifespan?: number;
actionTokenGeneratedByUserLifespan?: number;
enabled?: boolean;
sslRequired?: string;
passwordCredentialGrantAllowed?: boolean;
registrationAllowed?: boolean;
registrationEmailAsUsername?: boolean;
rememberMe?: boolean;
verifyEmail?: boolean;
loginWithEmailAllowed?: boolean;
duplicateEmailsAllowed?: boolean;
resetPasswordAllowed?: boolean;
editUsernameAllowed?: boolean;
bruteForceProtected?: boolean;
permanentLockout?: boolean;
maxFailureWaitSeconds?: number;
minimumQuickLoginWaitSeconds?: number;
waitIncrementSeconds?: number;
quickLoginCheckMilliSeconds?: number;
maxDeltaTimeSeconds?: number;
failureFactor?: number;
privateKey?: string;
publicKey?: string;
certificate?: string;
codeSecret?: string;
roles?: RolesRepresentation;
groups?: GroupRepresentation[];
defaultRoles?: string[];
defaultGroups?: string[];
requiredCredentials?: string[];
passwordPolicy?: string;
otpPolicyType?: string;
otpPolicyAlgorithm?: string;
otpPolicyInitialCounter?: number;
otpPolicyDigits?: number;
otpPolicyLookAheadWindow?: number;
otpPolicyPeriod?: number;
otpSupportedApplications?: string[];
webAuthnPolicyRpEntityName?: string;
webAuthnPolicySignatureAlgorithms?: string[];
webAuthnPolicyRpId?: string;
webAuthnPolicyAttestationConveyancePreference?: string;
webAuthnPolicyAuthenticatorAttachment?: string;
webAuthnPolicyRequireResidentKey?: string;
webAuthnPolicyUserVerificationRequirement?: string;
webAuthnPolicyCreateTimeout?: number;
webAuthnPolicyAvoidSameAuthenticatorRegister?: boolean;
webAuthnPolicyAcceptableAaguids?: string[];
webAuthnPolicyPasswordlessRpEntityName?: string;
webAuthnPolicyPasswordlessSignatureAlgorithms?: string[];
webAuthnPolicyPasswordlessRpId?: string;
webAuthnPolicyPasswordlessAttestationConveyancePreference?: string;
webAuthnPolicyPasswordlessAuthenticatorAttachment?: string;
webAuthnPolicyPasswordlessRequireResidentKey?: string;
webAuthnPolicyPasswordlessUserVerificationRequirement?: string;
webAuthnPolicyPasswordlessCreateTimeout?: number;
webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister?: boolean;
webAuthnPolicyPasswordlessAcceptableAaguids?: string[];
users?: UserRepresentation[];
federatedUsers?: UserRepresentation[];
scopeMappings?: ScopeMappingRepresentation[];
clientScopeMappings?: { [index: string]: ScopeMappingRepresentation[] };
clients?: ClientRepresentation[];
clientScopes?: ClientScopeRepresentation[];
defaultDefaultClientScopes?: string[];
defaultOptionalClientScopes?: string[];
browserSecurityHeaders?: { [index: string]: string };
smtpServe?: { [index: string]: string };
userFederationProviders?: UserFederationProviderRepresentation[];
userFederationMappers?: UserFederationMapperRepresentation[];
loginTheme?: string;
accountTheme?: string;
adminTheme?: string;
emailTheme?: string;
eventsEnabled?: boolean;
eventsExpiration?: number;
eventsListeners?: string[];
enabledEventTypes?: string[];
adminEventsEnabled?: boolean;
adminEventsDetailsEnabled?: boolean;
identityProviders?: IdentityProviderRepresentation[];
identityProviderMappers?: IdentityProviderMapperRepresentation[];
protocolMappers?: ProtocolMapperRepresentation[];
components?: { [index: string]: ComponentExportRepresentation };
internationalizationEnabled?: boolean;
supportedLocales?: string[];
defaultLocale?: string;
authenticationFlows?: AuthenticationFlowRepresentation[];
authenticatorConfig?: AuthenticatorConfigRepresentation[];
requiredActions?: RequiredActionProviderRepresentation[];
browserFlow?: string;
registrationFlow?: string;
directGrantFlow?: string;
resetCredentialsFlow?: string;
clientAuthenticationFlow?: string;
dockerAuthenticationFlow?: string;
attributes?: { [index: string]: string };
keycloakVersion?: string;
userManagedAccessAllowed?: boolean;
social?: boolean;
updateProfileOnInitialSocialLogin?: boolean;
socialProviders?: { [index: string]: string };
applicationScopeMappings?: { [index: string]: ScopeMappingRepresentation[] };
applications?: ApplicationRepresentation[];
oauthClients?: OAuthClientRepresentation[];
clientTemplates?: ClientTemplateRepresentation[];
}
export interface RolesRepresentation {
realm: RoleRepresentation[];
client: { [index: string]: RoleRepresentation[] };
application: { [index: string]: RoleRepresentation[] };
}
export interface GroupRepresentation {
id: string;
name: string;
path: string;
attributes: { [index: string]: string[] };
realmRoles: string[];
clientRoles: { [index: string]: string[] };
subGroups: GroupRepresentation[];
access: { [index: string]: boolean };
}
export interface UserRepresentation {
self: string;
id: string;
origin: string;
createdTimestamp: number;
username: string;
enabled: boolean;
totp: boolean;
emailVerified: boolean;
firstName: string;
lastName: string;
email: string;
federationLink: string;
serviceAccountClientId: string;
attributes: { [index: string]: string[] };
credentials: CredentialRepresentation[];
disableableCredentialTypes: string[];
requiredActions: string[];
federatedIdentities: FederatedIdentityRepresentation[];
realmRoles: string[];
clientRoles: { [index: string]: string[] };
clientConsents: UserConsentRepresentation[];
notBefore: number;
applicationRoles: { [index: string]: string[] };
socialLinks: SocialLinkRepresentation[];
groups: string[];
access: { [index: string]: boolean };
}
export interface ScopeMappingRepresentation {
self: string;
client: string;
clientTemplate: string;
clientScope: string;
roles: string[];
}
export interface ClientRepresentation {
id: string;
clientId: string;
name: string;
description: string;
rootUrl: string;
adminUrl: string;
baseUrl: string;
surrogateAuthRequired: boolean;
enabled: boolean;
alwaysDisplayInConsole: boolean;
clientAuthenticatorType: string;
secret: string;
registrationAccessToken: string;
defaultRoles: string[];
redirectUris: string[];
webOrigins: string[];
notBefore: number;
bearerOnly: boolean;
consentRequired: boolean;
standardFlowEnabled: boolean;
implicitFlowEnabled: boolean;
directAccessGrantsEnabled: boolean;
serviceAccountsEnabled: boolean;
authorizationServicesEnabled: boolean;
directGrantsOnly: boolean;
publicClient: boolean;
frontchannelLogout: boolean;
protocol: string;
attributes: { [index: string]: string };
authenticationFlowBindingOverrides: { [index: string]: string };
fullScopeAllowed: boolean;
nodeReRegistrationTimeout: number;
registeredNodes: { [index: string]: number };
protocolMappers: ProtocolMapperRepresentation[];
clientTemplate: string;
useTemplateConfig: boolean;
useTemplateScope: boolean;
useTemplateMappers: boolean;
defaultClientScopes: string[];
optionalClientScopes: string[];
authorizationSettings: ResourceServerRepresentation;
access: { [index: string]: boolean };
origin: string;
}
export interface UserFederationProviderRepresentation {
id: string;
displayName: string;
providerName: string;
config: { [index: string]: string };
priority: number;
fullSyncPeriod: number;
changedSyncPeriod: number;
lastSync: number;
}
export interface UserFederationMapperRepresentation {
id: string;
name: string;
federationProviderDisplayName: string;
federationMapperType: string;
config: { [index: string]: string };
}
export interface IdentityProviderRepresentation {
alias: string;
displayName: string;
internalId: string;
providerId: string;
enabled: boolean;
updateProfileFirstLoginMode: string;
trustEmail: boolean;
storeToken: boolean;
addReadTokenRoleOnCreate: boolean;
authenticateByDefault: boolean;
linkOnly: boolean;
firstBrokerLoginFlowAlias: string;
postBrokerLoginFlowAlias: string;
config: { [index: string]: string };
}
export interface IdentityProviderMapperRepresentation {
id: string;
name: string;
identityProviderAlias: string;
identityProviderMapper: string;
config: { [index: string]: string };
}
export interface ProtocolMapperRepresentation {
id: string;
name: string;
protocol: string;
protocolMapper: string;
consentRequired: boolean;
consentText: string;
config: { [index: string]: string };
}
export interface ComponentExportRepresentation {
id: string;
name: string;
providerId: string;
subType: string;
subComponents: { [index: string]: ComponentExportRepresentation };
config: { [index: string]: string };
}
export interface AuthenticationFlowRepresentation extends Serializable {
id: string;
alias: string;
description: string;
providerId: string;
topLevel: boolean;
builtIn: boolean;
authenticationExecutions: AuthenticationExecutionExportRepresentation[];
}
export interface AuthenticatorConfigRepresentation extends Serializable {
id: string;
alias: string;
config: { [index: string]: string };
}
export interface RequiredActionProviderRepresentation {
alias: string;
name: string;
providerId: string;
enabled: boolean;
defaultAction: boolean;
priority: number;
config: { [index: string]: string };
}
export interface ApplicationRepresentation extends ClientRepresentation {
claims: ClaimRepresentation;
}
export interface OAuthClientRepresentation extends ApplicationRepresentation {}
export interface ClientTemplateRepresentation {
id: string;
name: string;
description: string;
protocol: string;
fullScopeAllowed: boolean;
bearerOnly: boolean;
consentRequired: boolean;
standardFlowEnabled: boolean;
implicitFlowEnabled: boolean;
directAccessGrantsEnabled: boolean;
serviceAccountsEnabled: boolean;
publicClient: boolean;
frontchannelLogout: boolean;
attributes: { [index: string]: string };
protocolMappers: ProtocolMapperRepresentation[];
}
export interface RoleRepresentation {
id: string;
name: string;
description: string;
scopeParamRequired: boolean;
composite: boolean;
composites: Composites;
clientRole: boolean;
containerId: string;
attributes: { [index: string]: string[] };
}
export interface CredentialRepresentation {
id: string;
type: string;
userLabel: string;
createdDate: number;
secretData: string;
credentialData: string;
priority: number;
value: string;
temporary: boolean;
device: string;
hashedSaltedValue: string;
salt: string;
hashIterations: number;
counter: number;
algorithm: string;
digits: number;
period: number;
config: { [index: string]: string };
}
export interface FederatedIdentityRepresentation {
identityProvider: string;
userId: string;
userName: string;
}
export interface UserConsentRepresentation {
clientId: string;
grantedClientScopes: string[];
createdDate: number;
lastUpdatedDate: number;
grantedRealmRoles: string[];
}
export interface SocialLinkRepresentation {
socialProvider: string;
socialUserId: string;
socialUsername: string;
}
export interface ResourceServerRepresentation {
id: string;
clientId: string;
name: string;
allowRemoteResourceManagement: boolean;
policyEnforcementMode: PolicyEnforcementMode;
resources: ResourceRepresentation[];
policies: PolicyRepresentation[];
scopes: ScopeRepresentation[];
decisionStrategy: DecisionStrategy;
}
export interface AuthenticationExecutionExportRepresentation
extends AbstractAuthenticationExecutionRepresentation {
flowAlias: string;
userSetupAllowed: boolean;
}
export interface Serializable {}
export interface ClaimRepresentation {
name: boolean;
username: boolean;
profile: boolean;
picture: boolean;
website: boolean;
email: boolean;
gender: boolean;
locale: boolean;
address: boolean;
phone: boolean;
}
export interface Composites {
realm: string[];
client: { [index: string]: string[] };
application: { [index: string]: string[] };
}
export interface ResourceRepresentation {
name: string;
type: string;
owner: ResourceOwnerRepresentation;
ownerManagedAccess: boolean;
displayName: string;
attributes: { [index: string]: string[] };
_id: string;
uris: string[];
scopes: ScopeRepresentation[];
icon_uri: string;
}
export interface PolicyRepresentation extends AbstractPolicyRepresentation {
config: { [index: string]: string };
}
export interface ScopeRepresentation {
id: string;
name: string;
iconUri: string;
policies: PolicyRepresentation[];
resources: ResourceRepresentation[];
displayName: string;
}
export interface AbstractAuthenticationExecutionRepresentation
extends Serializable {
authenticatorConfig: string;
authenticator: string;
requirement: string;
priority: number;
autheticatorFlow: boolean;
}
export interface ResourceOwnerRepresentation {
id: string;
name: string;
}
export interface AbstractPolicyRepresentation {
id: string;
name: string;
description: string;
type: string;
policies: string[];
resources: string[];
scopes: string[];
logic: Logic;
decisionStrategy: DecisionStrategy;
owner: string;
resourcesData: ResourceRepresentation[];
scopesData: ScopeRepresentation[];
}
export type PolicyEnforcementMode = "ENFORCING" | "PERMISSIVE" | "DISABLED";
export type DecisionStrategy = "AFFIRMATIVE" | "UNANIMOUS" | "CONSENSUS";
export type Logic = "POSITIVE" | "NEGATIVE";