initial version of authentication section (#309)

* initial version of authentication section

* remove dialog for now should go to detail page

* added link and buildin label to first column

* added key attributes

* removed setTimeout workaround

* only delete when not in use

* Update src/authentication/messages.json

Co-authored-by: Stan Silvert <ssilvert@redhat.com>

* Update src/authentication/messages.json

Co-authored-by: Stan Silvert <ssilvert@redhat.com>

* refresh table on duplicate

Co-authored-by: Stan Silvert <ssilvert@redhat.com>
This commit is contained in:
Erik Jan de Wit 2021-01-26 20:39:01 +01:00 committed by GitHub
parent 5633101f42
commit b9de247569
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 419 additions and 10 deletions

View file

@ -24,7 +24,7 @@
"@patternfly/react-table": "4.19.24",
"file-saver": "^2.0.2",
"i18next": "^19.6.2",
"keycloak-admin": "1.14.4",
"keycloak-admin": "1.14.6",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"react": "^16.8.5",

View file

@ -1,6 +1,254 @@
import React from "react";
import { WorkInProgress } from "../components/work-in-progress/WorkInProgress";
import React, { useState } from "react";
import { Link, useRouteMatch } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import {
AlertVariant,
Button,
ButtonVariant,
Label,
PageSection,
Popover,
Tab,
TabTitleText,
} from "@patternfly/react-core";
import { CheckCircleIcon } from "@patternfly/react-icons";
export const AuthenticationSection = () => (
<WorkInProgress marvelLink="https://marvelapp.com/prototype/bh91013/screen/75647039" />
);
import AuthenticationFlowRepresentation from "keycloak-admin/lib/defs/authenticationFlowRepresentation";
import { useAdminClient } from "../context/auth/AdminClient";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useRealm } from "../context/realm-context/RealmContext";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { useAlerts } from "../components/alert/Alerts";
import { toUpperCase } from "../util";
import { DuplicateFlowModal } from "./DuplicateFlowModal";
import "./authentication-section.css";
type UsedBy = "client" | "default" | "idp";
type AuthenticationType = AuthenticationFlowRepresentation & {
usedBy: { type?: UsedBy; values: string[] };
};
const realmFlows = [
"browserFlow",
"registrationFlow",
"directGrantFlow",
"resetCredentialsFlow",
"clientAuthenticationFlow",
"dockerAuthenticationFlow",
];
export const AuthenticationSection = () => {
const { t } = useTranslation("authentication");
const adminClient = useAdminClient();
const { realm } = useRealm();
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
const { addAlert } = useAlerts();
const { url } = useRouteMatch();
const [selectedFlow, setSelectedFlow] = useState<AuthenticationType>();
const [open, setOpen] = useState(false);
const loader = async () => {
const clients = await adminClient.clients.find();
const idps = await adminClient.identityProviders.find();
const realmRep = await adminClient.realms.findOne({ realm });
const defaultFlows = Object.entries(realmRep)
.filter((entry) => realmFlows.includes(entry[0]))
.map((entry) => entry[1]);
const flows = (await adminClient.authenticationManagement.getFlows()) as AuthenticationType[];
for (const flow of flows) {
flow.usedBy = { values: [] };
const client = clients.find(
(client) =>
client.authenticationFlowBindingOverrides &&
(client.authenticationFlowBindingOverrides["direct_grant"] ===
flow.id ||
client.authenticationFlowBindingOverrides["browser"] === flow.id)
);
if (client) {
flow.usedBy.type = "client";
flow.usedBy.values.push(client.clientId!);
}
const idp = idps.find(
(idp) =>
idp.firstBrokerLoginFlowAlias === flow.alias ||
idp.postBrokerLoginFlowAlias === flow.alias
);
if (idp) {
flow.usedBy.type = "idp";
flow.usedBy.values.push(idp.alias!);
}
const isDefault = defaultFlows.includes(flow.alias);
if (isDefault) {
flow.usedBy.type = "default";
}
}
return flows;
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "authentication:deleteConfirmFlow",
children: (
<Trans i18nKey="authentication:deleteConfirmFlowMessage">
{" "}
<strong>{{ flow: selectedFlow ? selectedFlow.alias : "" }}</strong>.
</Trans>
),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.authenticationManagement.deleteFlow({
flowId: selectedFlow!.id!,
});
refresh();
addAlert(t("deleteFlowSuccess"), AlertVariant.success);
} catch (error) {
addAlert(t("deleteFlowError", { error }), AlertVariant.danger);
}
},
});
const UsedBy = ({ id, usedBy: { type, values } }: AuthenticationType) => (
<>
{(type === "idp" || type === "client") && (
<Popover
key={id}
aria-label="Basic popover"
bodyContent={
<div key={`usedBy-${id}-${values}`}>
{t("appliedBy" + (type === "client" ? "Clients" : "Providers"))}{" "}
{values.map((used, index) => (
<>
<strong>{used}</strong>
{index < values.length - 1 ? ", " : ""}
</>
))}
</div>
}
>
<Button variant={ButtonVariant.link} key={`button-${id}`}>
<CheckCircleIcon
className="keycloak_authentication-section__usedby"
key={`icon-${id}`}
/>{" "}
{t("specific" + (type === "client" ? "Clients" : "Providers"))}
</Button>
</Popover>
)}
{type === "default" && (
<Button key={id} variant={ButtonVariant.link} isDisabled>
<CheckCircleIcon
className="keycloak_authentication-section__usedby"
key={`icon-${id}`}
/>{" "}
{t("default")}
</Button>
)}
{!type && (
<Button key={id} variant={ButtonVariant.link} isDisabled>
{t("notInUse")}
</Button>
)}
</>
);
const AliasRenderer = ({ id, alias, builtIn }: AuthenticationType) => (
<>
<Link to={`${url}/${id}`} key={`link-{id}`}>
{toUpperCase(alias!)}
</Link>{" "}
{builtIn && <Label key={`label-${id}`}>{t("buildIn")}</Label>}
</>
);
return (
<>
<DeleteConfirm />
{open && (
<DuplicateFlowModal
name={selectedFlow ? selectedFlow.alias! : ""}
description={selectedFlow?.description!}
toggleDialog={() => setOpen(!open)}
onComplete={() => {
refresh();
setOpen(false);
}}
/>
)}
<ViewHeader titleKey="authentication:title" subKey="" />
<PageSection variant="light">
<KeycloakTabs isBox>
<Tab
eventKey="flows"
title={<TabTitleText>{t("flows")}</TabTitleText>}
>
<KeycloakDataTable
key={key}
loader={loader}
ariaLabelKey="authentication:title"
searchPlaceholderKey="authentication:searchForEvent"
actionResolver={({ data }) => {
const defaultActions = [
{
title: t("duplicate"),
onClick: () => {
setOpen(true);
setSelectedFlow(data);
},
},
];
// remove delete when it's in use or default flow
if (data.builtIn || data.usedBy.values.length > 0) {
return defaultActions;
} else {
return [
{
title: t("common:delete"),
onClick: () => {
setSelectedFlow(data);
toggleDeleteDialog();
},
},
...defaultActions,
];
}
}}
columns={[
{
name: "alias",
displayKey: "authentication:flowName",
cellRenderer: AliasRenderer,
},
{
name: "usedBy",
displayKey: "authentication:usedBy",
cellRenderer: UsedBy,
},
{
name: "description",
displayKey: "common:description",
},
]}
emptyState={
<ListEmptyState
message={t("emptyEvents")}
instructions={t("emptyEventsInstructions")}
/>
}
/>
</Tab>
</KeycloakTabs>
</PageSection>
</>
);
};

View file

@ -0,0 +1,123 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import {
AlertVariant,
Button,
ButtonVariant,
Form,
FormGroup,
Modal,
ModalVariant,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
type DuplicateFlowModalProps = {
name: string;
description: string;
toggleDialog: () => void;
onComplete: () => void;
};
export const DuplicateFlowModal = ({
name,
description,
toggleDialog,
onComplete,
}: DuplicateFlowModalProps) => {
const { t } = useTranslation("authentication");
const { register, errors, setValue, trigger, getValues } = useForm({
shouldUnregister: false,
});
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
useEffect(() => {
setValue("description", description);
setValue("name", t("copyOf", { name }));
}, [name, description, setValue]);
const save = async () => {
await trigger();
const form = getValues();
try {
await adminClient.authenticationManagement.copyFlow({
flow: name,
newName: form.name,
});
if (form.description !== description) {
const newFlow = (
await adminClient.authenticationManagement.getFlows()
).find((flow) => flow.alias === form.name)!;
newFlow.description = form.description;
await adminClient.authenticationManagement.updateFlow(
{ flowId: newFlow.id! },
newFlow
);
}
addAlert(t("copyFlowSuccess"), AlertVariant.success);
} catch (error) {
addAlert(t("copyFlowError", { error }), AlertVariant.danger);
}
onComplete();
};
return (
<Modal
title={t("duplicateFlow")}
isOpen={true}
onClose={toggleDialog}
variant={ModalVariant.small}
actions={[
<Button id="modal-confirm" key="confirm" onClick={save}>
{t("common:save")}
</Button>,
<Button
id="modal-cancel"
key="cancel"
variant={ButtonVariant.secondary}
onClick={() => {
toggleDialog();
}}
>
{t("common:cancel")}
</Button>,
]}
>
<Form isHorizontal>
<FormGroup
label={t("common:name")}
fieldId="kc-name"
helperTextInvalid={t("common:required")}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
isRequired
>
<TextInput
type="text"
id="kc-name"
name="name"
ref={register({ required: true })}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
<FormGroup label={t("common:description")} fieldId="kc-description">
<TextInput
type="text"
id="kc-description"
name="description"
ref={register()}
/>
</FormGroup>
</Form>
</Modal>
);
};

View file

@ -0,0 +1,3 @@
.keycloak_authentication-section__usedby {
color: var(--pf-global--success-color--100);
}

View file

@ -0,0 +1,24 @@
{
"authentication": {
"title": "Authentication",
"flows": "Flows",
"flowName": "Flow name",
"usedBy": "Used by",
"buildIn": "Built-in",
"appliedByProviders": "Applied by the following providers",
"appliedByClients": "Applied by the following clients",
"specificProviders": "Specific providers",
"specificClients": "Specific clients",
"default": "Default",
"notInUse": "Not in use",
"duplicate": "Duplicate",
"deleteConfirmFlow": "Delete flow?",
"deleteConfirmFlowMessage": "Are you sure you want to permanently delete the flow \"<1>{{flow}}</1>\".",
"deleteFlowSuccess": "Flow successfully deleted",
"deleteFlowError": "Could not delete flow: {{error}}",
"duplicateFlow": "Duplicate flow",
"copyOf": "Copy of {{name}}",
"copyFlowSuccess": "Flow successfully duplicated",
"copyFlowError": "Could not duplicate flow: {{error}}"
}
}

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import {
IAction,
IActions,
IActionsResolver,
IFormatter,
Table,
TableBody,
@ -27,6 +28,7 @@ type DataTableProps<T> = {
columns: Field<T>[];
rows: Row<T>[];
actions?: IActions;
actionResolver?: IActionsResolver;
onSelect?: (isSelected: boolean, rowIndex: number) => void;
canSelectAll: boolean;
};
@ -35,6 +37,7 @@ function DataTable<T>({
columns,
rows,
actions,
actionResolver,
ariaLabelKey,
onSelect,
canSelectAll,
@ -54,6 +57,7 @@ function DataTable<T>({
})}
rows={rows}
actions={actions}
actionResolver={actionResolver}
aria-label={t(ariaLabelKey)}
>
<TableHeader />
@ -82,6 +86,7 @@ export type DataListProps<T> = {
searchPlaceholderKey: string;
columns: Field<T>[];
actions?: Action<T>[];
actionResolver?: IActionsResolver;
toolbarItem?: ReactNode;
emptyState?: ReactNode;
};
@ -104,6 +109,7 @@ export type DataListProps<T> = {
* @param {(first?: number, max?: number, search?: string) => Promise<T[]>} props.loader - loader function that will fetch the data to display first, max and search are only applicable when isPaginated = true
* @param {Field<T>} props.columns - definition of the columns
* @param {Action[]} props.actions - the actions that appear on the row
* @param {IActionsResolver} props.actionResolver Resolver for the given action
* @param {ReactNode} props.toolbarItem - Toolbar items that appear on the top of the table {@link ToolbarItem}
* @param {ReactNode} props.emptyState - ReactNode show when the list is empty could be any component but best to use {@link ListEmptyState}
*/
@ -116,6 +122,7 @@ export function KeycloakDataTable<T>({
loader,
columns,
actions,
actionResolver,
toolbarItem,
emptyState,
}: DataListProps<T>) {
@ -250,6 +257,7 @@ export function KeycloakDataTable<T>({
canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined}
actions={convertAction()}
actionResolver={actionResolver}
rows={rows}
columns={columns}
ariaLabelKey={ariaLabelKey}
@ -272,6 +280,7 @@ export function KeycloakDataTable<T>({
canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined}
actions={convertAction()}
actionResolver={actionResolver}
rows={filteredData || rows}
columns={columns}
ariaLabelKey={ariaLabelKey}

View file

@ -17,6 +17,7 @@ import sessions from "./sessions/messages.json";
import events from "./events/messages.json";
import realmSettings from "./realm-settings/messages.json";
import realmSettingsHelp from "./realm-settings/help.json";
import authentication from "./authentication/messages.json";
import storybook from "./stories/messages.json";
import userFederation from "./user-federation/messages.json";
import userFederationHelp from "./user-federation/help.json";
@ -42,6 +43,7 @@ const initOptions = {
...events,
...realmSettings,
...realmSettingsHelp,
...authentication,
...storybook,
...userFederation,
...userFederationHelp,

View file

@ -13202,10 +13202,10 @@ junk@^3.1.0:
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
keycloak-admin@1.14.4:
version "1.14.4"
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.4.tgz#67f9a0991e88003f23a3c6a9d87c7a11294afd12"
integrity sha512-16/ctPH/Pz+XLL62tQp1d2XziMaPtxjthrfXcr+wfeJYMGH09QQTd1EDJjdFskuhZOhmYlifaAYPanQLwbUFEg==
keycloak-admin@1.14.6:
version "1.14.6"
resolved "https://registry.yarnpkg.com/keycloak-admin/-/keycloak-admin-1.14.6.tgz#22ae06bc0db7b041914bae6cf8fd5fd20d3efa9b"
integrity sha512-bjYwXzq5VRJh/uM/gfogf0SsPn53xb7cMd6R3qp5jY4VrdjnCYPvD1db8F+Cr6lxOxYsA3jVMziMLgGKKuGIdw==
dependencies:
axios "^0.21.0"
camelize "^1.0.0"