Added the credentials tab (#196)

* initial version credentials tab

* added signed jwt

* submit token endpoint  signing alg

* added x509

* show tab when public client

* fixed test default confimnation dailog size is small

* pr review comments
This commit is contained in:
Erik Jan de Wit 2020-11-02 21:15:09 +01:00 committed by GitHub
parent 6b3990e1b9
commit 2393b40c9b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 711 additions and 247 deletions

View file

@ -0,0 +1,199 @@
import React, { useContext, useEffect, useState } from "react";
import {
AlertVariant,
ButtonVariant,
DropdownItem,
PageSection,
Tab,
Tabs,
TabTitleText,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { Controller, useForm, useWatch } from "react-hook-form";
import { useParams } from "react-router-dom";
import { ClientSettings } from "./ClientSettings";
import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { useDownloadDialog } from "../components/download-dialog/DownloadDialog";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { HttpClientContext } from "../context/http-service/HttpClientContext";
import { RealmContext } from "../context/realm-context/RealmContext";
import { Credentials } from "./credentials/Credentials";
import { ClientRepresentation } from "../realm/models/Realm";
import {
convertFormValuesToObject,
convertToFormValues,
exportClient,
} from "../util";
import {
convertToMultiline,
toValue,
} from "../components/multi-line-input/MultiLineInput";
export const ClientDetails = () => {
const { t } = useTranslation("clients");
const httpClient = useContext(HttpClientContext)!;
const { realm } = useContext(RealmContext);
const { addAlert } = useAlerts();
const form = useForm();
const publicClient = useWatch({
control: form.control,
name: "publicClient",
defaultValue: false,
});
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState(0);
const [name, setName] = useState("");
const url = `/admin/realms/${realm}/clients/${id}`;
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "clients:clientDeleteConfirmTitle",
messageKey: "clients:clientDeleteConfirm",
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: () => {
try {
httpClient.doDelete(`/admin/realms/${realm}/clients/${id}`);
addAlert(t("clientDeletedSuccess"), AlertVariant.success);
} catch (error) {
addAlert(`${t("clientDeleteError")} ${error}`, AlertVariant.danger);
}
},
});
const [toggleDownloadDialog, DownloadDialog] = useDownloadDialog({
id,
protocol: form.getValues("protocol"),
});
const setupForm = (client: ClientRepresentation) => {
form.reset(client);
Object.entries(client).map((entry) => {
if (entry[0] === "redirectUris") {
form.setValue(entry[0], convertToMultiline(entry[1]));
} else if (entry[0] === "attributes") {
convertToFormValues(entry[1], "attributes", form.setValue);
} else {
form.setValue(entry[0], entry[1]);
}
});
};
useEffect(() => {
(async () => {
const fetchedClient = await httpClient.doGet<ClientRepresentation>(url);
if (fetchedClient.data) {
setName(fetchedClient.data.clientId);
setupForm(fetchedClient.data);
}
})();
}, []);
const save = async () => {
if (await form.trigger()) {
const redirectUris = toValue(form.getValues()["redirectUris"]);
const attributes = form.getValues()["attributes"]
? convertFormValuesToObject(form.getValues()["attributes"])
: {};
try {
const client = {
...form.getValues(),
redirectUris,
attributes,
};
await httpClient.doPut(url, client);
setupForm(client as ClientRepresentation);
addAlert(t("clientSaveSuccess"), AlertVariant.success);
} catch (error) {
addAlert(`${t("clientSaveError")} '${error}'`, AlertVariant.danger);
}
}
};
return (
<>
<DeleteConfirm />
<DownloadDialog />
<Controller
name="enabled"
control={form.control}
defaultValue={true}
render={({ onChange, value }) => {
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "clients:disableConfirmTitle",
messageKey: "clients:disableConfirm",
continueButtonLabel: "common:disable",
onConfirm: () => {
onChange(!value);
save();
},
});
return (
<>
<DisableConfirm />
<ViewHeader
titleKey={name}
subKey="clients:clientsExplain"
dropdownItems={[
<DropdownItem
key="download"
onClick={() => toggleDownloadDialog()}
>
{t("downloadAdapterConfig")}
</DropdownItem>,
<DropdownItem
key="export"
onClick={() => exportClient(form.getValues())}
>
{t("common:export")}
</DropdownItem>,
<DropdownItem
key="delete"
onClick={() => toggleDeleteDialog()}
>
{t("common:delete")}
</DropdownItem>,
]}
isEnabled={value}
onToggle={(value) => {
if (!value) {
toggleDisableDialog();
} else {
onChange(value);
save();
}
}}
/>
</>
);
}}
/>
<PageSection variant="light">
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
isBox
>
<Tab
eventKey={0}
title={<TabTitleText>{t("settings")}</TabTitleText>}
>
<ClientSettings form={form} save={save} />
</Tab>
{publicClient && (
<Tab
eventKey={1}
title={<TabTitleText>{t("credentials")}</TabTitleText>}
>
<Credentials clientId={id} form={form} save={save} />
</Tab>
)}
</Tabs>
</PageSection>
</>
);
};

View file

@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
FormGroup, FormGroup,
@ -6,152 +6,27 @@ import {
Form, Form,
Switch, Switch,
TextArea, TextArea,
PageSection,
ActionGroup, ActionGroup,
Button, Button,
AlertVariant,
ButtonVariant,
DropdownItem,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { useParams } from "react-router-dom"; import { Controller, UseFormMethods } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { ScrollForm } from "../components/scroll-form/ScrollForm"; import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { ClientDescription } from "./ClientDescription"; import { ClientDescription } from "./ClientDescription";
import { CapabilityConfig } from "./add/CapabilityConfig"; import { CapabilityConfig } from "./add/CapabilityConfig";
import { RealmContext } from "../context/realm-context/RealmContext"; import { MultiLineInput } from "../components/multi-line-input/MultiLineInput";
import { HttpClientContext } from "../context/http-service/HttpClientContext";
import { ClientRepresentation } from "../realm/models/Realm";
import {
convertToMultiline,
MultiLineInput,
toValue,
} from "../components/multi-line-input/MultiLineInput";
import { useAlerts } from "../components/alert/Alerts";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { exportClient } from "../util";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { useDownloadDialog } from "../components/download-dialog/DownloadDialog";
import { FormAccess } from "../components/form-access/FormAccess"; import { FormAccess } from "../components/form-access/FormAccess";
export const ClientSettings = () => { type ClientSettingsProps = {
const { t } = useTranslation("clients"); form: UseFormMethods;
const httpClient = useContext(HttpClientContext)!; save: () => void;
const { realm } = useContext(RealmContext);
const { addAlert } = useAlerts();
const { id } = useParams<{ id: string }>();
const [name, setName] = useState("");
const form = useForm();
const url = `/admin/realms/${realm}/clients/${id}`;
useEffect(() => {
(async () => {
const fetchedClient = await httpClient.doGet<ClientRepresentation>(url);
if (fetchedClient.data) {
setName(fetchedClient.data.clientId);
Object.entries(fetchedClient.data).map((entry) => {
if (entry[0] !== "redirectUris") {
form.setValue(entry[0], entry[1]);
} else if (entry[1] && entry[1].length > 0) {
form.setValue(entry[0], convertToMultiline(entry[1]));
}
});
}
})();
}, []);
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "clients:clientDeleteConfirmTitle",
messageKey: "clients:clientDeleteConfirm",
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: () => {
try {
httpClient.doDelete(`/admin/realms/${realm}/clients/${id}`);
addAlert(t("clientDeletedSuccess"), AlertVariant.success);
} catch (error) {
addAlert(`${t("clientDeleteError")} ${error}`, AlertVariant.danger);
}
},
});
const [toggleDownloadDialog, DownloadDialog] = useDownloadDialog({
id,
protocol: form.getValues("protocol"),
});
const save = async () => {
if (await form.trigger()) {
const redirectUris = toValue(form.getValues()["redirectUris"]);
try {
httpClient.doPut(url, { ...form.getValues(), redirectUris });
addAlert(t("clientSaveSuccess"), AlertVariant.success);
} catch (error) {
addAlert(`${t("clientSaveError")} '${error}'`, AlertVariant.danger);
}
}
}; };
export const ClientSettings = ({ form, save }: ClientSettingsProps) => {
const { t } = useTranslation("clients");
return ( return (
<> <>
<DeleteConfirm />
<DownloadDialog />
<Controller
name="enabled"
control={form.control}
defaultValue={true}
render={({ onChange, value }) => {
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "clients:disableConfirmTitle",
messageKey: "clients:disableConfirm",
continueButtonLabel: "common:disable",
onConfirm: () => {
onChange(!value);
save();
},
});
return (
<>
<DisableConfirm />
<ViewHeader
titleKey={name}
subKey="clients:clientsExplain"
dropdownItems={[
<DropdownItem
key="download"
onClick={() => toggleDownloadDialog()}
>
{t("downloadAdapterConfig")}
</DropdownItem>,
<DropdownItem
key="export"
onClick={() => exportClient(form.getValues())}
>
{t("common:export")}
</DropdownItem>,
<DropdownItem
key="delete"
onClick={() => toggleDeleteDialog()}
>
{t("common:delete")}
</DropdownItem>,
]}
isEnabled={value}
onToggle={(value) => {
if (!value) {
toggleDisableDialog();
} else {
onChange(value);
save();
}
}}
/>
</>
);
}}
/>
<PageSection variant="light">
<ScrollForm <ScrollForm
sections={[ sections={[
t("capabilityConfig"), t("capabilityConfig"),
@ -244,7 +119,6 @@ export const ClientSettings = () => {
</ActionGroup> </ActionGroup>
</FormAccess> </FormAccess>
</ScrollForm> </ScrollForm>
</PageSection>
</> </>
); );
}; };

View file

@ -0,0 +1,34 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
Button,
ClipboardCopy,
FormGroup,
Split,
SplitItem,
} from "@patternfly/react-core";
export type ClientSecretProps = {
secret: string;
toggle: () => void;
};
export const ClientSecret = ({ secret, toggle }: ClientSecretProps) => {
const { t } = useTranslation("clients");
return (
<FormGroup label={t("clientSecret")} fieldId="kc-client-secret">
<Split hasGutter>
<SplitItem isFilled>
<ClipboardCopy id="kc-client-secret" isReadOnly>
{secret}
</ClipboardCopy>
</SplitItem>
<SplitItem>
<Button variant="secondary" onClick={toggle}>
{t("regenerate")}
</Button>
</SplitItem>
</Split>
</FormGroup>
);
};

View file

@ -0,0 +1,230 @@
import {
ActionGroup,
AlertVariant,
Button,
Card,
CardBody,
ClipboardCopy,
Divider,
Form,
FormGroup,
Select,
SelectOption,
SelectVariant,
Split,
SplitItem,
} from "@patternfly/react-core";
import React, { useContext, useEffect, useState } from "react";
import { Controller, UseFormMethods, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useAlerts } from "../../components/alert/Alerts";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { HttpClientContext } from "../../context/http-service/HttpClientContext";
import { RealmContext } from "../../context/realm-context/RealmContext";
import { ClientSecret } from "./ClientSecret";
import { SignedJWT } from "./SignedJWT";
import { X509 } from "./X509";
type ClientAuthenticatorProviders = {
id: string;
displayName: string;
};
type Secret = {
type: string;
value: string;
};
type AccessToken = {
registrationAccessToken: string;
};
export type CredentialsProps = {
clientId: string;
form: UseFormMethods;
save: () => void;
};
export const Credentials = ({ clientId, form, save }: CredentialsProps) => {
const { t } = useTranslation("clients");
const httpClient = useContext(HttpClientContext)!;
const { realm } = useContext(RealmContext);
const { addAlert } = useAlerts();
const clientAuthenticatorType = useWatch({
control: form.control,
name: "clientAuthenticatorType",
});
const [providers, setProviders] = useState<ClientAuthenticatorProviders[]>(
[]
);
const [secret, setSecret] = useState("");
const [accessToken, setAccessToken] = useState("");
const [open, isOpen] = useState(false);
useEffect(() => {
(async () => {
const response = await httpClient.doGet<ClientAuthenticatorProviders[]>(
`/admin/realms/${realm}/authentication/client-authenticator-providers`
);
setProviders(response.data!);
const secretResponse = await httpClient.doGet<Secret>(
`/admin/realms/${realm}/clients/${clientId}/client-secret`
);
setSecret(secretResponse.data!.value);
})();
}, []);
async function regenerate<T>(
endpoint: string,
message: string
): Promise<T | undefined> {
try {
const response = await httpClient.doPost<T>(
`/admin/realms/${realm}/clients/${clientId}/${endpoint}`,
{ client: clientId, realm }
);
addAlert(t(`${message}Success`), AlertVariant.success);
return response.data!;
} catch (error) {
addAlert(t(`${message}Error`, { error }), AlertVariant.danger);
}
}
const regenerateClientSecret = async () => {
const secret = await regenerate<Secret>("client-secret", "clientSecret");
setSecret(secret?.value || "");
};
const [toggleClientSecretConfirm, ClientSecretConfirm] = useConfirmDialog({
titleKey: "clients:confirmClientSecretTitle",
messageKey: "clients:confirmClientSecretBody",
continueButtonLabel: "common:yes",
cancelButtonLabel: "common:no",
onConfirm: regenerateClientSecret,
});
const regenerateAccessToken = async () => {
const accessToken = await regenerate<AccessToken>(
"registration-access-token",
"accessToken"
);
setAccessToken(accessToken?.registrationAccessToken || "");
};
const [toggleAccessTokenConfirm, AccessTokenConfirm] = useConfirmDialog({
titleKey: "clients:confirmAccessTokenTitle",
messageKey: "clients:confirmAccessTokenBody",
continueButtonLabel: "common:yes",
cancelButtonLabel: "common:no",
onConfirm: regenerateAccessToken,
});
return (
<Form isHorizontal className="pf-u-mt-md">
<ClientSecretConfirm />
<AccessTokenConfirm />
<Card isFlat>
<CardBody>
<FormGroup
label={t("clientAuthenticator")}
fieldId="kc-client-authenticator-type"
labelIcon={
<HelpItem
helpText="clients-help:client-authenticator-type"
forLabel={t("clientAuthenticator")}
forID="kc-client-authenticator-type"
/>
}
>
<Controller
name="clientAuthenticatorType"
control={form.control}
render={({ onChange, value }) => (
<Select
toggleId="kc-client-authenticator-type"
required
onToggle={() => isOpen(!open)}
onSelect={(_, value) => {
onChange(value as string);
isOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("clientAuthenticator")}
isOpen={open}
>
{providers.map((option) => (
<SelectOption
selected={option.id === value}
key={option.id}
value={option.id}
>
{option.displayName}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
{clientAuthenticatorType === "client-jwt" && (
<SignedJWT form={form} />
)}
{clientAuthenticatorType === "client-x509" && <X509 form={form} />}
<ActionGroup>
<Button
variant="primary"
onClick={() => save()}
isDisabled={!form.formState.isDirty}
>
{t("common:save")}
</Button>
</ActionGroup>
</CardBody>
<CardBody>
{(clientAuthenticatorType === "client-secret" ||
clientAuthenticatorType === "client-secret-jwt") && (
<>
<Divider className="pf-u-mb-md" />
<ClientSecret
secret={secret}
toggle={toggleClientSecretConfirm}
/>
</>
)}
</CardBody>
</Card>
<Card isFlat>
<CardBody>
<FormGroup
label={t("registrationAccessToken")}
fieldId="kc-access-token"
labelIcon={
<HelpItem
helpText="clients-help:registration-access-token"
forLabel={t("registrationAccessToken")}
forID="kc-access-token"
/>
}
>
<Split hasGutter>
<SplitItem isFilled>
<ClipboardCopy id="kc-access-token" isReadOnly>
{accessToken}
</ClipboardCopy>
</SplitItem>
<SplitItem>
<Button variant="secondary" onClick={toggleAccessTokenConfirm}>
{t("regenerate")}
</Button>
</SplitItem>
</Split>
</FormGroup>
</CardBody>
</Card>
</Form>
);
};

View file

@ -0,0 +1,69 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, UseFormMethods } from "react-hook-form";
import {
FormGroup,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { sortProviders } from "../../util";
export type SignedJWTProps = {
form: UseFormMethods;
};
export const SignedJWT = ({ form }: SignedJWTProps) => {
const providers = sortProviders(
useServerInfo().providers.clientSignature.providers
);
const { t } = useTranslation("clients");
const [open, isOpen] = useState(false);
return (
<>
<FormGroup
label={t("signatureAlgorithm")}
fieldId="kc-signature-algorithm"
labelIcon={
<HelpItem
helpText="clients-help:signature-algorithm"
forLabel={t("signatureAlgorithm")}
forID="kc-signature-algorithm"
/>
}
>
<Controller
name="attributes.token_endpoint_auth_signing_alg"
defaultValue={providers[0]}
control={form.control}
render={({ onChange, value }) => (
<Select
toggleId="kc-signature-algorithm"
onToggle={() => isOpen(!open)}
onSelect={(_, value) => {
onChange(value as string);
isOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("signatureAlgorithm")}
isOpen={open}
>
{providers.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
/>
))}
</Select>
)}
/>
</FormGroup>
</>
);
};

View file

@ -0,0 +1,33 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { UseFormMethods } from "react-hook-form";
import { FormGroup, TextInput } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
export type X509Props = {
form: UseFormMethods;
};
export const X509 = ({ form }: X509Props) => {
const { t } = useTranslation("clients");
return (
<FormGroup
label={t("subject")}
fieldId="kc-subject"
labelIcon={
<HelpItem
helpText="clients-help:subject"
forLabel={t("subject")}
forID="kc-subject"
/>
}
>
<TextInput
ref={form.register()}
type="text"
id="kc-subject"
name="attributes.x509_subjectdn"
/>
</FormGroup>
);
};

View file

@ -1,6 +1,10 @@
{ {
"clients-help": { "clients-help": {
"downloadType": "this is information about the download type", "downloadType": "this is information about the download type",
"details": "this is information about the details" "details": "this is information about the details",
"client-authenticator-type": "Client Authenticator used for authentication of this client against Keycloak server",
"registration-access-token": "The registration access token provides access for clients to the client registration service.",
"signature-algorithm": "JWA algorithm, which the client needs to use when signing a JWT for authentication. If left blank, the client is allowed to use any algorithm.",
"subject": "A regular expression for validating Subject DN in the Client Certificate. Use \"(.*?)(?:$)\" to match all kind of expressions."
} }
} }

View file

@ -11,6 +11,8 @@
"name": "Name", "name": "Name",
"formatOption": "Format option", "formatOption": "Format option",
"downloadAdaptorTitle": "Download adaptor configs", "downloadAdaptorTitle": "Download adaptor configs",
"settings": "Settings",
"credentials": "Credentials",
"details": "Details", "details": "Details",
"clientList": "Client list", "clientList": "Client list",
"clientSettings": "Client details", "clientSettings": "Client details",
@ -46,6 +48,20 @@
"validRedirectUri": "Valid redirect URIs", "validRedirectUri": "Valid redirect URIs",
"loginTheme": "Login theme", "loginTheme": "Login theme",
"consentRequired": "Consent required", "consentRequired": "Consent required",
"clientAuthenticator": "Client Authenticator",
"clientSecret": "Client secret",
"regenerate": "Regenerate",
"confirmClientSecretTitle": "Regenerate secret for this client?",
"confirmClientSecretBody": "If you regenerate secret, the Keycloak database will be updated and you will need to download a new adapter for this client.",
"confirmAccessTokenTitle": "Regenerate registration access token?",
"confirmAccessTokenBody": "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}}",
"registrationAccessToken": "Registration access token",
"accessTokenSuccess": "Access token regenerated",
"accessTokenError": "Could not regenerate access token due to: {{error}}",
"signatureAlgorithm": "Signature algorithm",
"subject": "Subject DN",
"searchForClient": "Search for client" "searchForClient": "Search for client"
} }
} }

View file

@ -4,6 +4,8 @@
"unknownUser": "Anonymous", "unknownUser": "Anonymous",
"add": "Add", "add": "Add",
"yes": "Yes",
"no": "No",
"create": "Create", "create": "Create",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",

View file

@ -54,7 +54,7 @@ export const ConfirmDialogModal = ({
onCancel, onCancel,
children, children,
open = true, open = true,
variant = ModalVariant.default, variant = ModalVariant.small,
toggleDialog, toggleDialog,
}: ConfirmDialogModalProps) => { }: ConfirmDialogModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();

View file

@ -48,7 +48,7 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
ouiaSafe={true} ouiaSafe={true}
showClose={true} showClose={true}
title="Delete app02?" title="Delete app02?"
variant="default" variant="small"
> >
<Portal <Portal
containerInfo={ containerInfo={
@ -63,8 +63,8 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
aria-describedby="pf-modal-part-3" aria-describedby="pf-modal-part-3"
aria-labelledby="pf-modal-part-2" aria-labelledby="pf-modal-part-2"
aria-modal="true" aria-modal="true"
class="pf-c-modal-box" class="pf-c-modal-box pf-m-sm"
data-ouia-component-id="OUIA-Generated-Modal-default-2" data-ouia-component-id="OUIA-Generated-Modal-small-2"
data-ouia-component-type="PF4/ModalContent" data-ouia-component-type="PF4/ModalContent"
data-ouia-safe="true" data-ouia-safe="true"
id="pf-modal-part-1" id="pf-modal-part-1"
@ -170,11 +170,11 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
isOpen={true} isOpen={true}
labelId="pf-modal-part-2" labelId="pf-modal-part-2"
onClose={[Function]} onClose={[Function]}
ouiaId="OUIA-Generated-Modal-default-2" ouiaId="OUIA-Generated-Modal-small-2"
ouiaSafe={true} ouiaSafe={true}
showClose={true} showClose={true}
title="Delete app02?" title="Delete app02?"
variant="default" variant="small"
> >
<Backdrop> <Backdrop>
<div <div
@ -198,20 +198,20 @@ exports[`Confirmation dialog renders simple confirm dialog 1`] = `
aria-label="" aria-label=""
aria-labelledby="pf-modal-part-2" aria-labelledby="pf-modal-part-2"
className="" className=""
data-ouia-component-id="OUIA-Generated-Modal-default-2" data-ouia-component-id="OUIA-Generated-Modal-small-2"
data-ouia-component-type="PF4/ModalContent" data-ouia-component-type="PF4/ModalContent"
data-ouia-safe={true} data-ouia-safe={true}
id="pf-modal-part-1" id="pf-modal-part-1"
style={Object {}} style={Object {}}
variant="default" variant="small"
> >
<div <div
aria-describedby="pf-modal-part-3" aria-describedby="pf-modal-part-3"
aria-label={null} aria-label={null}
aria-labelledby="pf-modal-part-2" aria-labelledby="pf-modal-part-2"
aria-modal="true" aria-modal="true"
className="pf-c-modal-box" className="pf-c-modal-box pf-m-sm"
data-ouia-component-id="OUIA-Generated-Modal-default-2" data-ouia-component-id="OUIA-Generated-Modal-small-2"
data-ouia-component-type="PF4/ModalContent" data-ouia-component-type="PF4/ModalContent"
data-ouia-safe={true} data-ouia-safe={true}
id="pf-modal-part-1" id="pf-modal-part-1"

View file

@ -15,7 +15,7 @@ type MultiLine = {
}; };
export function convertToMultiline(fields: string[]): MultiLine[] { export function convertToMultiline(fields: string[]): MultiLine[] {
return fields.map((field) => { return (fields && fields.length > 0 ? fields : [""]).map((field) => {
return { value: field }; return { value: field };
}); });
} }

View file

@ -1,7 +1,7 @@
import React, { createContext, ReactNode, useContext } from "react"; import React, { createContext, ReactNode, useContext } from "react";
import { ServerInfoRepresentation } from "./server-info"; import { ServerInfoRepresentation } from "./server-info";
import { HttpClientContext } from "../http-service/HttpClientContext"; import { HttpClientContext } from "../http-service/HttpClientContext";
import { sortProvider } from "../../util"; import { sortProviders } from "../../util";
import { DataLoader } from "../../components/data-loader/DataLoader"; import { DataLoader } from "../../components/data-loader/DataLoader";
export const ServerInfoContext = createContext<ServerInfoRepresentation>( export const ServerInfoContext = createContext<ServerInfoRepresentation>(
@ -11,10 +11,7 @@ export const ServerInfoContext = createContext<ServerInfoRepresentation>(
export const useServerInfo = () => useContext(ServerInfoContext); export const useServerInfo = () => useContext(ServerInfoContext);
export const useLoginProviders = () => { export const useLoginProviders = () => {
const serverInfo = Object.entries( return sortProviders(useServerInfo().providers["login-protocol"].providers);
useServerInfo().providers["login-protocol"].providers
);
return [...new Map(serverInfo.sort(sortProvider)).keys()];
}; };
export const ServerInfoProvider = ({ children }: { children: ReactNode }) => { export const ServerInfoProvider = ({ children }: { children: ReactNode }) => {

View file

@ -3,7 +3,6 @@ import { AuthenticationSection } from "./authentication/AuthenticationSection";
import { ClientScopeForm } from "./client-scopes/form/ClientScopeForm"; import { ClientScopeForm } from "./client-scopes/form/ClientScopeForm";
import { ClientScopesSection } from "./client-scopes/ClientScopesSection"; import { ClientScopesSection } from "./client-scopes/ClientScopesSection";
import { NewClientForm } from "./clients/add/NewClientForm"; import { NewClientForm } from "./clients/add/NewClientForm";
import { ClientSettings } from "./clients/ClientSettings";
import { ClientsSection } from "./clients/ClientsSection"; import { ClientsSection } from "./clients/ClientsSection";
import { ImportForm } from "./clients/import/ImportForm"; import { ImportForm } from "./clients/import/ImportForm";
import { EventsSection } from "./events/EventsSection"; import { EventsSection } from "./events/EventsSection";
@ -20,6 +19,7 @@ import { UsersSection } from "./user/UsersSection";
import { MappingDetails } from "./client-scopes/details/MappingDetails"; import { MappingDetails } from "./client-scopes/details/MappingDetails";
import { AccessType } from "./context/whoami/who-am-i-model"; import { AccessType } from "./context/whoami/who-am-i-model";
import { ClientDetails } from "./clients/ClientDetails";
export type RouteDef = { export type RouteDef = {
path: string; path: string;
@ -39,7 +39,7 @@ export const routes: RoutesFn = (t: TFunction) => [
}, },
{ {
path: "/clients/:id", path: "/clients/:id",
component: ClientSettings, component: ClientDetails,
breadcrumb: t("clients:clientSettings"), breadcrumb: t("clients:clientSettings"),
access: "view-clients", access: "view-clients",
}, },

View file

@ -3,7 +3,13 @@ import FileSaver from "file-saver";
import { ClientRepresentation } from "./clients/models/client-model"; import { ClientRepresentation } from "./clients/models/client-model";
import { ProviderRepresentation } from "./context/server-info/server-info"; import { ProviderRepresentation } from "./context/server-info/server-info";
export const sortProvider = ( export const sortProviders = (providers: {
[index: string]: ProviderRepresentation;
}) => {
return [...new Map(Object.entries(providers).sort(sortProvider)).keys()];
};
const sortProvider = (
a: [string, ProviderRepresentation], a: [string, ProviderRepresentation],
b: [string, ProviderRepresentation] b: [string, ProviderRepresentation]
) => { ) => {