added saml keys tab (#1308)
Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
parent
6f09d581a5
commit
60e4676ac8
10 changed files with 758 additions and 68 deletions
|
@ -294,7 +294,6 @@ describe("Clients test", () => {
|
|||
cy.findByTestId("jump-link-capability-config").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SAML test", () => {
|
||||
const samlClientName = "saml";
|
||||
|
||||
|
@ -339,4 +338,60 @@ describe("Clients test", () => {
|
|||
masthead.checkNotificationMessage("Client successfully updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SAML keys tab", () => {
|
||||
const clientId = "saml-keys";
|
||||
|
||||
before(() => {
|
||||
new AdminClient().createClient({
|
||||
clientId,
|
||||
protocol: "saml",
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
new AdminClient().deleteClient(clientId);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
keycloakBefore();
|
||||
loginPage.logIn();
|
||||
sidebarPage.goToClients();
|
||||
listingPage.searchItem(clientId).goToItemDetails(clientId);
|
||||
cy.get("#pf-tab-keys-keys").click();
|
||||
});
|
||||
|
||||
it("doesn't disable when no", () => {
|
||||
cy.findByTestId("clientSignature").click({ force: true });
|
||||
|
||||
modalUtils
|
||||
.checkModalTitle('Disable "Client signature required"')
|
||||
.cancelModal();
|
||||
|
||||
cy.findAllByTestId("certificate").should("have.length", 1);
|
||||
});
|
||||
|
||||
it("disable client signature", () => {
|
||||
cy.findByTestId("clientSignature").click({ force: true });
|
||||
|
||||
modalUtils
|
||||
.checkModalTitle('Disable "Client signature required"')
|
||||
.confirmModal();
|
||||
|
||||
masthead.checkNotificationMessage("Client successfully updated");
|
||||
cy.findAllByTestId("certificate").should("have.length", 0);
|
||||
});
|
||||
|
||||
it("should enable Encryption keys config", () => {
|
||||
cy.findByTestId("encryptAssertions").click({ force: true });
|
||||
|
||||
cy.findByTestId("generate").click();
|
||||
masthead.checkNotificationMessage(
|
||||
"New key pair and certificate generated successfully"
|
||||
);
|
||||
|
||||
modalUtils.confirmModal();
|
||||
cy.findAllByTestId("certificate").should("have.length", 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -53,6 +53,7 @@ import { ClientScopes } from "./scopes/ClientScopes";
|
|||
import { EvaluateScopes } from "./scopes/EvaluateScopes";
|
||||
import { ServiceAccount } from "./service-account/ServiceAccount";
|
||||
import { isRealmClient, getProtocolName } from "./utils";
|
||||
import { SamlKeys } from "./keys/SamlKeys";
|
||||
|
||||
type ClientDetailHeaderProps = {
|
||||
onChange: (value: boolean) => void;
|
||||
|
@ -349,7 +350,12 @@ export const ClientDetails = () => {
|
|||
eventKey="keys"
|
||||
title={<TabTitleText>{t("keys")}</TabTitleText>}
|
||||
>
|
||||
<Keys clientId={clientId} save={() => save()} />
|
||||
{client.protocol === "openid-connect" && (
|
||||
<Keys clientId={clientId} save={save} />
|
||||
)}
|
||||
{client.protocol === "saml" && (
|
||||
<SamlKeys clientId={clientId} save={save} />
|
||||
)}
|
||||
</Tab>
|
||||
)}
|
||||
{!client.publicClient && !isRealmClient(client) && (
|
||||
|
|
|
@ -248,7 +248,7 @@ export const CapabilityConfig = ({
|
|||
hasNoPaddingTop
|
||||
>
|
||||
<Controller
|
||||
name="attributes.saml_encrypt"
|
||||
name="attributes.saml-encrypt"
|
||||
control={control}
|
||||
defaultValue="false"
|
||||
render={({ onChange, value }) => (
|
||||
|
@ -276,7 +276,7 @@ export const CapabilityConfig = ({
|
|||
hasNoPaddingTop
|
||||
>
|
||||
<Controller
|
||||
name="attributes.saml_client_signature"
|
||||
name="attributes.saml-client-signature"
|
||||
control={control}
|
||||
defaultValue="false"
|
||||
render={({ onChange, value }) => (
|
||||
|
|
52
src/clients/keys/Certificate.tsx
Normal file
52
src/clients/keys/Certificate.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormGroup, GenerateId, TextArea } from "@patternfly/react-core";
|
||||
|
||||
import type CertificateRepresentation from "@keycloak/keycloak-admin-client/lib/defs/certificateRepresentation";
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
|
||||
type CertificateProps = Omit<CertificateDisplayProps, "id"> & {
|
||||
plain?: boolean;
|
||||
};
|
||||
|
||||
type CertificateDisplayProps = {
|
||||
id: string;
|
||||
keyInfo?: CertificateRepresentation;
|
||||
};
|
||||
|
||||
const CertificateDisplay = ({ id, keyInfo }: CertificateDisplayProps) => (
|
||||
<TextArea
|
||||
readOnly
|
||||
rows={5}
|
||||
id={id}
|
||||
data-testid="certificate"
|
||||
value={keyInfo?.certificate}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Certificate = ({ keyInfo, plain = false }: CertificateProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
return (
|
||||
<GenerateId prefix="certificate">
|
||||
{(id) =>
|
||||
plain ? (
|
||||
<CertificateDisplay id={id} keyInfo={keyInfo} />
|
||||
) : (
|
||||
<FormGroup
|
||||
label={t("certificate")}
|
||||
fieldId={id}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:certificate"
|
||||
forLabel={t("certificate")}
|
||||
forID={id}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CertificateDisplay id={id} keyInfo={keyInfo} />
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
</GenerateId>
|
||||
);
|
||||
};
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
Button,
|
||||
ButtonVariant,
|
||||
FileUpload,
|
||||
Form,
|
||||
FormGroup,
|
||||
Modal,
|
||||
|
@ -24,6 +25,99 @@ type GenerateKeyDialogProps = {
|
|||
save: (keyStoreConfig: KeyStoreConfig) => void;
|
||||
};
|
||||
|
||||
type KeyFormProps = {
|
||||
register: () => void;
|
||||
control: Control<KeyStoreConfig>;
|
||||
useFile?: boolean;
|
||||
};
|
||||
|
||||
export const KeyForm = ({
|
||||
register,
|
||||
control,
|
||||
useFile = false,
|
||||
}: KeyFormProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
|
||||
const [filename, setFilename] = useState<string>();
|
||||
const [openArchiveFormat, setOpenArchiveFormat] = useState(false);
|
||||
|
||||
return (
|
||||
<Form className="pf-u-pt-lg">
|
||||
<FormGroup
|
||||
label={t("archiveFormat")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:archiveFormat"
|
||||
forLabel={t("archiveFormat")}
|
||||
forID="archiveFormat"
|
||||
/>
|
||||
}
|
||||
fieldId="archiveFormat"
|
||||
>
|
||||
<Controller
|
||||
name="format"
|
||||
defaultValue="JKS"
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Select
|
||||
toggleId="archiveFormat"
|
||||
onToggle={(isExpanded) => setOpenArchiveFormat(isExpanded)}
|
||||
onSelect={(_, value) => {
|
||||
onChange(value.toString());
|
||||
setOpenArchiveFormat(false);
|
||||
}}
|
||||
selections={value}
|
||||
variant={SelectVariant.single}
|
||||
aria-label={t("archiveFormat")}
|
||||
isOpen={openArchiveFormat}
|
||||
>
|
||||
{["JKS", "PKCS12"].map((option) => (
|
||||
<SelectOption
|
||||
selected={option === value}
|
||||
key={option}
|
||||
value={option}
|
||||
/>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
{useFile && (
|
||||
<FormGroup
|
||||
label={t("importFile")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:importFile"
|
||||
forLabel={t("importFile")}
|
||||
forID="importFile"
|
||||
/>
|
||||
}
|
||||
fieldId="importFile"
|
||||
>
|
||||
<Controller
|
||||
name="file"
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<FileUpload
|
||||
id="importFile"
|
||||
value={value}
|
||||
filename={filename}
|
||||
browseButtonText={t("browse")}
|
||||
onChange={(value, filename) => {
|
||||
setFilename(filename);
|
||||
onChange(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
<StoreSettings register={register} hidePassword={useFile} />
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const GenerateKeyDialog = ({
|
||||
save,
|
||||
toggleDialog,
|
||||
|
@ -31,8 +125,6 @@ export const GenerateKeyDialog = ({
|
|||
const { t } = useTranslation("clients");
|
||||
const { register, control, handleSubmit } = useForm<KeyStoreConfig>();
|
||||
|
||||
const [openArchiveFormat, setOpenArchiveFormat] = useState(false);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
variant={ModalVariant.medium}
|
||||
|
@ -69,48 +161,7 @@ export const GenerateKeyDialog = ({
|
|||
<TextContent>
|
||||
<Text>{t("clients-help:generateKeysDescription")}</Text>
|
||||
</TextContent>
|
||||
<Form className="pf-u-pt-lg">
|
||||
<FormGroup
|
||||
label={t("archiveFormat")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:archiveFormat"
|
||||
forLabel={t("archiveFormat")}
|
||||
forID="archiveFormat"
|
||||
/>
|
||||
}
|
||||
fieldId="archiveFormat"
|
||||
>
|
||||
<Controller
|
||||
name="format"
|
||||
defaultValue="JKS"
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Select
|
||||
toggleId="archiveFormat"
|
||||
onToggle={() => setOpenArchiveFormat(!openArchiveFormat)}
|
||||
onSelect={(_, value) => {
|
||||
onChange(value as string);
|
||||
setOpenArchiveFormat(false);
|
||||
}}
|
||||
selections={value}
|
||||
variant={SelectVariant.single}
|
||||
aria-label={t("archiveFormat")}
|
||||
isOpen={openArchiveFormat}
|
||||
>
|
||||
{["JKS", "PKCS12"].map((option) => (
|
||||
<SelectOption
|
||||
selected={option === value}
|
||||
key={option}
|
||||
value={option}
|
||||
/>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<StoreSettings register={register} />
|
||||
</Form>
|
||||
<KeyForm register={register} control={control} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,7 +13,6 @@ import {
|
|||
PageSection,
|
||||
Switch,
|
||||
Text,
|
||||
TextArea,
|
||||
TextContent,
|
||||
TextInput,
|
||||
} from "@patternfly/react-core";
|
||||
|
@ -28,6 +27,7 @@ import { GenerateKeyDialog } from "./GenerateKeyDialog";
|
|||
import { useFetch, useAdminClient } from "../../context/auth/AdminClient";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { ImportKeyDialog, ImportFile } from "./ImportKeyDialog";
|
||||
import { Certificate } from "./Certificate";
|
||||
|
||||
type KeysProps = {
|
||||
save: () => void;
|
||||
|
@ -155,24 +155,7 @@ export const Keys = ({ clientId, save }: KeysProps) => {
|
|||
</FormGroup>
|
||||
{useJwksUrl !== "true" &&
|
||||
(keyInfo ? (
|
||||
<FormGroup
|
||||
label={t("certificate")}
|
||||
fieldId="certificate"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:certificate"
|
||||
forLabel={t("certificate")}
|
||||
forID="certificate"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TextArea
|
||||
readOnly
|
||||
rows={5}
|
||||
id="certificate"
|
||||
value={keyInfo.certificate}
|
||||
/>
|
||||
</FormGroup>
|
||||
<Certificate plain keyInfo={keyInfo} />
|
||||
) : (
|
||||
"No client certificate configured"
|
||||
))}
|
||||
|
|
54
src/clients/keys/SamlImportKeyDialog.tsx
Normal file
54
src/clients/keys/SamlImportKeyDialog.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { AlertVariant } from "@patternfly/react-core";
|
||||
|
||||
import type { KeyTypes } from "./SamlKeys";
|
||||
import { KeyForm } from "./GenerateKeyDialog";
|
||||
import { useAdminClient } from "../../context/auth/AdminClient";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { SamlKeysDialogForm, submitForm } from "./SamlKeysDialog";
|
||||
import { ConfirmDialogModal } from "../../components/confirm-dialog/ConfirmDialog";
|
||||
|
||||
type SamlImportKeyDialogProps = {
|
||||
id: string;
|
||||
attr: KeyTypes;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const SamlImportKeyDialog = ({
|
||||
id,
|
||||
attr,
|
||||
onClose,
|
||||
}: SamlImportKeyDialogProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const { register, control, handleSubmit } = useFormContext();
|
||||
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
const submit = (form: SamlKeysDialogForm) => {
|
||||
submitForm(form, id, attr, adminClient, (error) => {
|
||||
if (error) {
|
||||
addError("clients:importError", error);
|
||||
} else {
|
||||
addAlert(t("importSuccess"), AlertVariant.success);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmDialogModal
|
||||
open={true}
|
||||
toggleDialog={onClose}
|
||||
continueButtonLabel="clients:import"
|
||||
titleKey="clients:importKey"
|
||||
onConfirm={() => {
|
||||
handleSubmit(submit)();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<KeyForm register={register} control={control} useFile />
|
||||
</ConfirmDialogModal>
|
||||
);
|
||||
};
|
260
src/clients/keys/SamlKeys.tsx
Normal file
260
src/clients/keys/SamlKeys.tsx
Normal file
|
@ -0,0 +1,260 @@
|
|||
import React, { useState } from "react";
|
||||
import FileSaver from "file-saver";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
CardBody,
|
||||
PageSection,
|
||||
TextContent,
|
||||
Text,
|
||||
FormGroup,
|
||||
Switch,
|
||||
Card,
|
||||
Form,
|
||||
ActionGroup,
|
||||
Button,
|
||||
AlertVariant,
|
||||
} from "@patternfly/react-core";
|
||||
|
||||
import type CertificateRepresentation from "@keycloak/keycloak-admin-client/lib/defs/certificateRepresentation";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
import type { ClientForm } from "../ClientDetails";
|
||||
import { SamlKeysDialog } from "./SamlKeysDialog";
|
||||
import { FormPanel } from "../../components/scroll-form/FormPanel";
|
||||
import { Certificate } from "./Certificate";
|
||||
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { SamlImportKeyDialog } from "./SamlImportKeyDialog";
|
||||
|
||||
type SamlKeysProps = {
|
||||
clientId: string;
|
||||
save: () => void;
|
||||
};
|
||||
|
||||
const KEYS = ["saml.signing", "saml.encryption"] as const;
|
||||
export type KeyTypes = typeof KEYS[number];
|
||||
|
||||
const KEYS_MAPPING: { [key in KeyTypes]: { [index: string]: string } } = {
|
||||
"saml.signing": {
|
||||
name: "attributes.saml-client-signature",
|
||||
title: "signingKeysConfig",
|
||||
key: "clientSignature",
|
||||
},
|
||||
"saml.encryption": {
|
||||
name: "attributes.saml-encrypt",
|
||||
title: "encryptionKeysConfig",
|
||||
key: "encryptAssertions",
|
||||
},
|
||||
};
|
||||
|
||||
type KeySectionProps = {
|
||||
keyInfo?: CertificateRepresentation;
|
||||
attr: KeyTypes;
|
||||
onChanged: (key: KeyTypes) => void;
|
||||
onGenerate: (key: KeyTypes, regenerate: boolean) => void;
|
||||
onImport: (key: KeyTypes) => void;
|
||||
};
|
||||
|
||||
const KeySection = ({
|
||||
keyInfo,
|
||||
attr,
|
||||
onChanged,
|
||||
onGenerate,
|
||||
onImport,
|
||||
}: KeySectionProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const { control, watch } = useFormContext<ClientForm>();
|
||||
const title = KEYS_MAPPING[attr].title;
|
||||
const key = KEYS_MAPPING[attr].key;
|
||||
const name = KEYS_MAPPING[attr].name;
|
||||
|
||||
const section = watch(name);
|
||||
return (
|
||||
<>
|
||||
<FormPanel title={t(title)} className="kc-form-panel__panel">
|
||||
<TextContent className="pf-u-pb-lg">
|
||||
<Text>{t(`${title}Explain`)}</Text>
|
||||
</TextContent>
|
||||
<FormAccess role="manage-clients" isHorizontal>
|
||||
<FormGroup
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={`clients-help:${key}`}
|
||||
forLabel={t(key)}
|
||||
forID={t(`common:helpLabel`, { label: t(key) })}
|
||||
/>
|
||||
}
|
||||
label={t(key)}
|
||||
fieldId={key}
|
||||
hasNoPaddingTop
|
||||
>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue="false"
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
data-testid={key}
|
||||
id={key}
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
isChecked={value === "true"}
|
||||
onChange={(value) => {
|
||||
const v = value.toString();
|
||||
if (v === "true") {
|
||||
onChanged(attr);
|
||||
onChange(v);
|
||||
} else {
|
||||
onGenerate(attr, false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormAccess>
|
||||
</FormPanel>
|
||||
{keyInfo?.certificate && section === "true" && (
|
||||
<Card isFlat>
|
||||
<CardBody className="kc-form-panel__body">
|
||||
<Form isHorizontal>
|
||||
<Certificate keyInfo={keyInfo} />
|
||||
<ActionGroup>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onGenerate(attr, true)}
|
||||
>
|
||||
{t("regenerate")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onImport(attr)}>
|
||||
{t("importKey")}
|
||||
</Button>
|
||||
<Button variant="tertiary">{t("common:export")}</Button>
|
||||
</ActionGroup>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SamlKeys = ({ clientId, save }: SamlKeysProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const [isChanged, setIsChanged] = useState<KeyTypes>();
|
||||
const [keyInfo, setKeyInfo] = useState<CertificateRepresentation[]>();
|
||||
const [selectedType, setSelectedType] = useState<KeyTypes>();
|
||||
const [openImport, setImportOpen] = useState(false);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
|
||||
const { setValue } = useFormContext();
|
||||
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
useFetch(
|
||||
() =>
|
||||
Promise.all(
|
||||
KEYS.map((attr) =>
|
||||
adminClient.clients.getKeyInfo({ id: clientId, attr })
|
||||
)
|
||||
),
|
||||
(info) => setKeyInfo(info),
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const generate = async (attr: KeyTypes) => {
|
||||
const index = KEYS.indexOf(attr);
|
||||
try {
|
||||
const info = [...(keyInfo || [])];
|
||||
info[index] = await adminClient.clients.generateKey({
|
||||
id: clientId,
|
||||
attr,
|
||||
});
|
||||
|
||||
setKeyInfo(info);
|
||||
FileSaver.saveAs(
|
||||
new Blob([info[index].privateKey!], {
|
||||
type: "application/octet-stream",
|
||||
}),
|
||||
"private.key"
|
||||
);
|
||||
|
||||
addAlert(t("generateSuccess"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addError("clients:generateError", error);
|
||||
}
|
||||
};
|
||||
|
||||
const key = selectedType ? KEYS_MAPPING[selectedType].key : "";
|
||||
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
|
||||
titleKey: t("disableSigning", {
|
||||
key: t(key),
|
||||
}),
|
||||
messageKey: t("disableSigningExplain", {
|
||||
key: t(key),
|
||||
}),
|
||||
continueButtonLabel: "common:yes",
|
||||
cancelButtonLabel: "common:no",
|
||||
onConfirm: () => {
|
||||
setValue(KEYS_MAPPING[selectedType!].name, "false");
|
||||
save();
|
||||
},
|
||||
});
|
||||
|
||||
const [toggleReGenerateDialog, ReGenerateConfirm] = useConfirmDialog({
|
||||
titleKey: "clients:reGenerateSigning",
|
||||
messageKey: "clients:reGenerateSigningExplain",
|
||||
continueButtonLabel: "common:yes",
|
||||
cancelButtonLabel: "common:no",
|
||||
onConfirm: () => {
|
||||
generate(selectedType!);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PageSection variant="light" className="keycloak__form">
|
||||
{isChanged && (
|
||||
<SamlKeysDialog
|
||||
id={clientId}
|
||||
attr={isChanged}
|
||||
onClose={() => {
|
||||
setIsChanged(undefined);
|
||||
save();
|
||||
setRefresh(refresh + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DisableConfirm />
|
||||
<ReGenerateConfirm />
|
||||
{KEYS.map((attr, index) => (
|
||||
<>
|
||||
{openImport && (
|
||||
<SamlImportKeyDialog
|
||||
id={clientId}
|
||||
attr={attr}
|
||||
onClose={() => setImportOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<KeySection
|
||||
key={attr}
|
||||
keyInfo={keyInfo?.[index]}
|
||||
attr={attr}
|
||||
onChanged={setIsChanged}
|
||||
onGenerate={(type, isNew) => {
|
||||
setSelectedType(type);
|
||||
if (!isNew) {
|
||||
toggleDisableDialog();
|
||||
} else {
|
||||
toggleReGenerateDialog();
|
||||
}
|
||||
}}
|
||||
onImport={() => setImportOpen(true)}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</PageSection>
|
||||
);
|
||||
};
|
206
src/clients/keys/SamlKeysDialog.tsx
Normal file
206
src/clients/keys/SamlKeysDialog.tsx
Normal file
|
@ -0,0 +1,206 @@
|
|||
import React, { useState } from "react";
|
||||
import FileSaver from "file-saver";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
AlertVariant,
|
||||
Button,
|
||||
ButtonVariant,
|
||||
Flex,
|
||||
FlexItem,
|
||||
Form,
|
||||
FormGroup,
|
||||
Modal,
|
||||
ModalVariant,
|
||||
Radio,
|
||||
Split,
|
||||
SplitItem,
|
||||
Text,
|
||||
TextContent,
|
||||
Title,
|
||||
} from "@patternfly/react-core";
|
||||
|
||||
import type CertificateRepresentation from "@keycloak/keycloak-admin-client/lib/defs/certificateRepresentation";
|
||||
import type KeyStoreConfig from "@keycloak/keycloak-admin-client/lib/defs/keystoreConfig";
|
||||
import type { KeyTypes } from "./SamlKeys";
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
import { useAdminClient } from "../../context/auth/AdminClient";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { KeyForm } from "./GenerateKeyDialog";
|
||||
import { Certificate } from "./Certificate";
|
||||
import type KeycloakAdminClient from "@keycloak/keycloak-admin-client";
|
||||
|
||||
type SamlKeysDialogProps = {
|
||||
id: string;
|
||||
attr: KeyTypes;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export type SamlKeysDialogForm = KeyStoreConfig & {
|
||||
file: File;
|
||||
};
|
||||
|
||||
export const submitForm = async (
|
||||
form: SamlKeysDialogForm,
|
||||
id: string,
|
||||
attr: KeyTypes,
|
||||
adminClient: KeycloakAdminClient,
|
||||
callback: (error?: unknown) => void
|
||||
) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const { file, ...rest } = form;
|
||||
Object.entries(rest).map(([key, value]) =>
|
||||
formData.append(
|
||||
key === "format" ? "keystoreFormat" : key,
|
||||
value.toString()
|
||||
)
|
||||
);
|
||||
formData.append("file", file);
|
||||
|
||||
await adminClient.clients.uploadKey({ id, attr }, formData);
|
||||
callback();
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const SamlKeysDialog = ({ id, attr, onClose }: SamlKeysDialogProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const [type, setType] = useState(false);
|
||||
const [keys, setKeys] = useState<CertificateRepresentation>();
|
||||
const { register, control, handleSubmit } = useForm<SamlKeysDialogForm>();
|
||||
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
const submit = (form: SamlKeysDialogForm) => {
|
||||
submitForm(form, id, attr, adminClient, (error) => {
|
||||
if (error) {
|
||||
addError("clients:importError", error);
|
||||
} else {
|
||||
addAlert(t("importSuccess"), AlertVariant.success);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
try {
|
||||
const key = await adminClient.clients.generateKey({
|
||||
id,
|
||||
attr,
|
||||
});
|
||||
setKeys(key);
|
||||
FileSaver.saveAs(
|
||||
new Blob([key.privateKey!], {
|
||||
type: "application/octet-stream",
|
||||
}),
|
||||
"private.key"
|
||||
);
|
||||
|
||||
addAlert(t("generateSuccess"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addError("clients:generateError", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
variant={ModalVariant.medium}
|
||||
aria-labelledby={t("enableClientSignatureRequired")}
|
||||
header={
|
||||
<TextContent>
|
||||
<Title headingLevel="h1">{t("enableClientSignatureRequired")}</Title>
|
||||
<Text>{t("enableClientSignatureRequiredExplain")}</Text>
|
||||
</TextContent>
|
||||
}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
actions={[
|
||||
<Button
|
||||
id="modal-confirm"
|
||||
key="confirm"
|
||||
data-testid="confirm"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (type) {
|
||||
handleSubmit(submit)();
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>,
|
||||
<Button
|
||||
id="modal-cancel"
|
||||
key="cancel"
|
||||
data-testid="cancel"
|
||||
variant={ButtonVariant.link}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form isHorizontal>
|
||||
<FormGroup
|
||||
label={t("selectMethod")}
|
||||
fieldId="selectMethod"
|
||||
hasNoPaddingTop
|
||||
>
|
||||
<Flex>
|
||||
<FlexItem>
|
||||
<Radio
|
||||
isChecked={!type}
|
||||
name="selectMethodType"
|
||||
onChange={() => setType(false)}
|
||||
label={t("selectMethodType.generate")}
|
||||
id="selectMethodType-generate"
|
||||
/>
|
||||
</FlexItem>
|
||||
<FlexItem>
|
||||
<Radio
|
||||
isChecked={type}
|
||||
name="selectMethodType"
|
||||
onChange={() => setType(true)}
|
||||
label={t("selectMethodType.import")}
|
||||
id="selectMethodType-import"
|
||||
/>
|
||||
</FlexItem>
|
||||
</Flex>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
{!type && (
|
||||
<Form>
|
||||
<FormGroup
|
||||
label={t("certificate")}
|
||||
fieldId="certificate"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:certificate"
|
||||
forLabel={t("certificate")}
|
||||
forID="certificate"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Split hasGutter>
|
||||
<SplitItem isFilled>
|
||||
<Certificate plain keyInfo={keys} />
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="generate"
|
||||
onClick={generate}
|
||||
>
|
||||
{t("generate")}
|
||||
</Button>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
)}
|
||||
{type && <KeyForm register={register} control={control} useFile />}
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -169,6 +169,29 @@ export default {
|
|||
"If you regenerate registration access token, the access data regarding the client registration service will be updated.",
|
||||
clientSecretSuccess: "Client secret regenerated",
|
||||
clientSecretError: "Could not regenerate client secret due to: {{error}}",
|
||||
signingKeysConfig: "Signing keys config",
|
||||
signingKeysConfigExplain:
|
||||
'If you enable the "Client signature required" below, you must configure the signing keys by generating or importing keys, and the client will sign their saml requests and responses. The signature will be validated.',
|
||||
encryptionKeysConfig: "Encryption keys config",
|
||||
encryptionKeysConfigExplain:
|
||||
'If you enable the "Encryption assertions" below, you must configure the encryption keys by generating or importing keys, and the SAML assertions will be encrypted with the client\'s public key using AES.',
|
||||
enableClientSignatureRequired: 'Enable "Client signature required"?',
|
||||
enableClientSignatureRequiredExplain:
|
||||
'If you enable "Client signature required", the adapter of this client will be updated. You may need to download a new adapter for this client. You need to generate or import keys for this client otherwise the authentication will not work.',
|
||||
selectMethod: "Select method",
|
||||
selectMethodType: {
|
||||
generate: "Generate",
|
||||
import: "Import",
|
||||
},
|
||||
confirm: "Confirm",
|
||||
browse: "Browse",
|
||||
importKey: "Import key",
|
||||
disableSigning: 'Disable "{{key}}"',
|
||||
disableSigningExplain:
|
||||
'If you disable "{{key}}", the Keycloak database will be updated and you may need to download a new adapter for this client.',
|
||||
reGenerateSigning: "Regenerate signing key for this client",
|
||||
reGenerateSigningExplain:
|
||||
"If you regenerate signing key for client, the Keycloak database will be updated and you may need to download a new adapter for this client.",
|
||||
registrationAccessToken: "Registration access token",
|
||||
accessTokenSuccess: "Access token regenerated",
|
||||
accessTokenError: "Could not regenerate access token due to: {{error}}",
|
||||
|
|
Loading…
Reference in a new issue