Add client registration page (#4250)

This commit is contained in:
Erik Jan de Wit 2023-02-03 12:56:20 +01:00 committed by GitHub
parent 989d35fe0e
commit e65a1effda
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 712 additions and 2 deletions

View file

@ -0,0 +1,64 @@
import ListingPage from "../support/pages/admin-ui/ListingPage";
import { ClientRegistrationPage } from "../support/pages/admin-ui/manage/clients/ClientRegistrationPage";
import Masthead from "../support/pages/admin-ui/Masthead";
import SidebarPage from "../support/pages/admin-ui/SidebarPage";
import LoginPage from "../support/pages/LoginPage";
import { keycloakBefore } from "../support/util/keycloak_hooks";
describe("Client registration policies subtab", () => {
const loginPage = new LoginPage();
const listingPage = new ListingPage();
const masthead = new Masthead();
const sidebarPage = new SidebarPage();
const clientRegistrationPage = new ClientRegistrationPage();
before(() => {
keycloakBefore();
loginPage.logIn();
sidebarPage.goToClients();
});
beforeEach(() => {
clientRegistrationPage.goToClientRegistrationTab();
sidebarPage.waitForPageLoad();
});
it("add anonymous client registration policy", () => {
clientRegistrationPage
.createPolicy()
.selectRow("max-clients")
.fillPolicyForm({
name: "new policy",
})
.formUtils()
.save();
masthead.checkNotificationMessage("New client policy created successfully");
clientRegistrationPage.formUtils().cancel();
listingPage.itemExist("new policy");
});
it("edit anonymous client registration policy", () => {
listingPage.goToItemDetails("new policy");
clientRegistrationPage
.fillPolicyForm({
name: "policy 2",
})
.formUtils()
.save();
masthead.checkNotificationMessage("Client policy updated successfully");
clientRegistrationPage.formUtils().cancel();
listingPage.itemExist("policy 2");
});
it("delete anonymous client registration policy", () => {
listingPage.clickRowDetails("policy 2").clickDetailMenu("Delete");
clientRegistrationPage.modalUtils().confirmModal();
masthead.checkNotificationMessage(
"Client registration policy deleted successfully"
);
listingPage.itemExist("policy 2", false);
});
});

View file

@ -0,0 +1,23 @@
import CommonPage from "../../../CommonPage";
export class ClientRegistrationPage extends CommonPage {
goToClientRegistrationTab() {
this.tabUtils().clickTab("registration");
return this;
}
createPolicy() {
cy.findAllByTestId("createPolicy").click();
return this;
}
selectRow(name: string) {
cy.findAllByTestId(name).click();
return this;
}
fillPolicyForm(props: { name: string }) {
cy.findAllByTestId("name").clear().type(props.name);
return this;
}
}

View file

@ -46,12 +46,15 @@
"clientSignature": "Will the client sign their saml requests and responses? And should they be validated?",
"downloadType": "this is information about the download type",
"details": "this is information about the details",
"clientPolicyName": "Display name of the policy",
"createToken": "An initial access token can only be used to create clients",
"expiration": "Specifies how long the token should be valid",
"count": "Specifies how many clients can be created using the token",
"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.",
"anonymousAccessPolicies": "Those Policies are used when the Client Registration Service is invoked by unauthenticated request. This means that the request does not contain Initial Access Token nor Bearer Token.",
"authenticatedAccessPolicies": "Those Policies are used when Client Registration Service is invoked by authenticated request. This means that the request contains Initial Access Token or Bearer Token.",
"allowRegexComparison": "If OFF, then the Subject DN from given client certificate must exactly match the given DN from the 'Subject DN' property as described in the RFC8705 specification. The Subject DN can be in the RFC2553 or RFC1779 format. If ON, then the Subject DN from given client certificate should match regex specified by 'Subject DN' property.",
"subject": "A regular expression for validating Subject DN in the Client Certificate. Use \"(.*?)(?:$)\" to match all kind of expressions.",
"evaluateExplain": "This page allows you to see all protocol mappers and role scope mappings",

View file

@ -313,6 +313,20 @@
"copySuccess": "Successfully copied to clipboard!",
"clipboardCopyError": "Error copying to clipboard.",
"copyToClipboard": "Copy to clipboard",
"clientRegistration": "Client registration",
"anonymousAccessPolicies": "Anonymous access polices",
"authenticatedAccessPolicies": "Authenticated access polices",
"provider": "Provider",
"providerId": "Provider ID",
"providerCreateSuccess": "New client policy created successfully",
"providerCreateError": "Could not create client policy due to {{error}}",
"providerUpdatedSuccess": "Client policy updated successfully",
"providerUpdatedError": "Could not update client policy due to {{error}}",
"clientRegisterPolicyDeleteConfirmTitle": "Delete client registration policy?",
"clientRegisterPolicyDeleteConfirm": "Are you sure you want to permanently delete the client registration policy {{name}}",
"clientRegisterPolicyDeleteSuccess": "Client registration policy deleted successfully",
"clientRegisterPolicyDeleteError": "Could not delete client registration policy: '{{error}}'",
"chooseAPolicyProvider": "Choose a policy provider",
"clientAuthentication": "Client authentication",
"authentication": "Authentication",
"authenticationFlow": "Authentication flow",

View file

@ -156,5 +156,33 @@
"client-updater-source-roles": {
"label": "Updating entity role",
"tooltip": "The condition is checked during client registration/update requests and it evaluates to true if the entity (usually user), who is creating/updating client is member of the specified role. For reference the realm role, you can use the realm role name like 'my_realm_role' . For reference client role, you can use the client_id.role_name for example 'my_client.my_client_role' will refer to client role 'my_client_role' of client 'my_client'."
},
"allowed-client-scopes": {
"label": "Allowed Client Scopes",
"tooltip": "Whitelist of the client scopes, which can be used on a newly registered client. Attempt to register client with some client scope, which is not whitelisted, will be rejected. By default, the whitelist is either empty or contains just realm default client scopes (based on 'Allow Default Scopes' configuration property)"
},
"allow-default-scopes": {
"label": "Allow Default Scopes",
"tooltip": "If on, newly registered clients will be allowed to have client scopes mentioned in realm default client scopes or realm optional client scopes"
},
"allowed-protocol-mappers": {
"label": "Allowed Protocol Mappers",
"tooltip": "Whitelist of allowed protocol mapper providers. If there is an attempt to register client, which contains some protocol mappers, which were not whitelisted, registration request will be rejected."
},
"max-clients": {
"label": "Max Clients Per Realm",
"tooltip": "It will not be allowed to register a new client if count of existing clients in realm is same or bigger than the configured limit."
},
"trusted-hosts": {
"label": "Trusted Hosts",
"tooltip": "List of Hosts, which are trusted and are allowed to invoke Client Registration Service and/or be used as values of Client URIs. You can use hostnames or IP addresses. If you use star at the beginning (for example '*.example.com' ) then whole domain example.com will be trusted."
},
"host-sending-registration-request-must-match": {
"label": "Host Sending Client Registration Request Must Match",
"tooltip": "If on, any request to Client Registration Service is allowed just if it was sent from some trusted host or domain."
},
"client-uris-must-match": {
"label": "Client URIs Must Match",
"tooltip": "If on, all Client URIs (Redirect URIs and others) are allowed just if they match some trusted host or domain."
}
}

View file

@ -38,6 +38,7 @@ import {
useRoutableTab,
} from "../components/routable-tabs/RoutableTabs";
import { ClientsTab, toClients } from "./routes/Clients";
import { ClientRegistration } from "./registration/ClientRegistration";
export default function ClientsSection() {
const { t } = useTranslation("clients");
@ -69,6 +70,7 @@ export default function ClientsSection() {
const listTab = useTab("list");
const initialAccessTokenTab = useTab("initial-access-token");
const clientRegistrationTab = useTab("client-registration");
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("clientDelete", { clientId: selectedClient?.clientId }),
@ -243,6 +245,13 @@ export default function ClientsSection() {
>
<InitialAccessTokenList />
</Tab>
<Tab
data-testid="registration"
title={<TabTitleText>{t("clientRegistration")}</TabTitleText>}
{...clientRegistrationTab}
>
<ClientRegistration />
</Tab>
</RoutableTabs>
</PageSection>
</>

View file

@ -47,7 +47,7 @@ export const InitialAccessTokenList = () => {
addAlert(t("tokenDeleteSuccess"), AlertVariant.success);
setToken(undefined);
} catch (error) {
addError("tokenDeleteError", error);
addError("clients:tokenDeleteError", error);
}
},
});

View file

@ -0,0 +1,96 @@
import {
DataList,
DataListCell,
DataListItem,
DataListItemCells,
DataListItemRow,
Modal,
ModalVariant,
} from "@patternfly/react-core";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import useLocaleSort, { mapByKey } from "../../utils/useLocaleSort";
type AddProviderDialogProps = {
onConfirm: (providerId: string) => void;
toggleDialog: () => void;
};
export const AddProviderDialog = ({
onConfirm,
toggleDialog,
}: AddProviderDialogProps) => {
const { t } = useTranslation("clients");
const serverInfo = useServerInfo();
const providers = Object.keys(
serverInfo.providers?.["client-registration-policy"].providers || []
);
const descriptions =
serverInfo.componentTypes?.[
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy"
];
const localeSort = useLocaleSort();
const rows = useMemo(
() =>
localeSort(
descriptions?.filter((d) => providers.includes(d.id)) || [],
mapByKey("id")
),
[providers, descriptions]
);
return (
<Modal
variant={ModalVariant.medium}
title={t("chooseAPolicyProvider")}
isOpen
onClose={toggleDialog}
>
<DataList
onSelectDataListItem={(id) => {
onConfirm(id);
toggleDialog();
}}
aria-label={t("addPredefinedMappers")}
isCompact
>
<DataListItem aria-label={t("headerName")} id="header">
<DataListItemRow>
<DataListItemCells
dataListCells={[t("common:name"), t("common:description")].map(
(name) => (
<DataListCell style={{ fontWeight: 700 }} key={name}>
{name}
</DataListCell>
)
)}
/>
</DataListItemRow>
</DataListItem>
{rows.map((provider) => (
<DataListItem
aria-label={provider.id}
key={provider.id}
data-testid={provider.id}
id={provider.id}
>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell width={2} key={`name-${provider.id}`}>
{provider.id}
</DataListCell>,
<DataListCell width={4} key={`description-${provider.id}`}>
{provider.helpText}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
))}
</DataList>
</Modal>
);
};

View file

@ -0,0 +1,66 @@
import { Tab, TabTitleText } from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import {
RoutableTabs,
useRoutableTab,
} from "../../components/routable-tabs/RoutableTabs";
import { useRealm } from "../../context/realm-context/RealmContext";
import {
ClientRegistrationTab,
toClientRegistration,
} from "../routes/ClientRegistration";
import { ClientRegistrationList } from "./ClientRegistrationList";
export const ClientRegistration = () => {
const { t } = useTranslation("clients");
const { realm } = useRealm();
const useTab = (subTab: ClientRegistrationTab) =>
useRoutableTab(toClientRegistration({ realm, subTab }));
const anonymousTab = useTab("anonymous");
const authenticatedTab = useTab("authenticated");
return (
<RoutableTabs
defaultLocation={toClientRegistration({ realm, subTab: "anonymous" })}
mountOnEnter
>
<Tab
data-testid="anonymous"
title={
<TabTitleText>
{t("anonymousAccessPolicies")}{" "}
<HelpItem
fieldLabelId=""
helpText="clients-help:anonymousAccessPolicies"
noVerticalAlign={false}
unWrap
/>
</TabTitleText>
}
{...anonymousTab}
>
<ClientRegistrationList subType="anonymous" />
</Tab>
<Tab
data-testid="authenticated"
title={
<TabTitleText>
{t("authenticatedAccessPolicies")}{" "}
<HelpItem
fieldLabelId=""
helpText="clients-help:authenticatedAccessPolicies"
noVerticalAlign={false}
unWrap
/>
</TabTitleText>
}
{...authenticatedTab}
>
<ClientRegistrationList subType="authenticated" />
</Tab>
</RoutableTabs>
);
};

View file

@ -0,0 +1,132 @@
import ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import { Button, ButtonVariant, ToolbarItem } from "@patternfly/react-core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useAlerts } from "../../components/alert/Alerts";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import { useRealm } from "../../context/realm-context/RealmContext";
import useToggle from "../../utils/useToggle";
import { toRegistrationProvider } from "../routes/AddRegistrationProvider";
import { ClientRegistrationParams } from "../routes/ClientRegistration";
import { AddProviderDialog } from "./AddProviderDialog";
type ClientRegistrationListProps = {
subType: "anonymous" | "authenticated";
};
export const ClientRegistrationList = ({
subType,
}: ClientRegistrationListProps) => {
const { t } = useTranslation("clients");
const { subTab } = useParams<ClientRegistrationParams>();
const navigate = useNavigate();
const { adminClient } = useAdminClient();
const { addAlert, addError } = useAlerts();
const { realm } = useRealm();
const [policies, setPolicies] = useState<ComponentRepresentation[]>([]);
const [selectedPolicy, setSelectedPolicy] =
useState<ComponentRepresentation>();
const [isAddDialogOpen, toggleAddDialog] = useToggle();
useFetch(
() =>
adminClient.components.find({
type: "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy",
}),
(policies) => setPolicies(policies.filter((p) => p.subType === subType)),
[selectedPolicy]
);
const DetailLink = (comp: ComponentRepresentation) => (
<Link
key={comp.id}
to={toRegistrationProvider({
realm,
subTab: subTab || "anonymous",
providerId: comp.providerId!,
id: comp.id,
})}
>
{comp.name}
</Link>
);
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "clients:clientRegisterPolicyDeleteConfirmTitle",
messageKey: t("clientRegisterPolicyDeleteConfirm", {
name: selectedPolicy?.name,
}),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.components.del({
realm,
id: selectedPolicy?.id!,
});
addAlert(t("clientRegisterPolicyDeleteSuccess"));
setSelectedPolicy(undefined);
} catch (error) {
addError("clients:clientRegisterPolicyDeleteError", error);
}
},
});
return (
<>
{isAddDialogOpen && (
<AddProviderDialog
onConfirm={(providerId) =>
navigate(
toRegistrationProvider({
realm,
subTab: subTab || "anonymous",
providerId,
})
)
}
toggleDialog={toggleAddDialog}
/>
)}
<DeleteConfirm />
<KeycloakDataTable
ariaLabelKey="clients:initialAccessToken"
searchPlaceholderKey="clients:searchInitialAccessToken"
loader={policies}
toolbarItem={
<ToolbarItem>
<Button data-testid="createPolicy" onClick={toggleAddDialog}>
{t("createPolicy")}
</Button>
</ToolbarItem>
}
actions={[
{
title: t("common:delete"),
onRowClick: (policy) => {
setSelectedPolicy(policy);
toggleDeleteDialog();
},
},
]}
columns={[
{
name: "name",
displayKey: "common:name",
cellRenderer: DetailLink,
},
{
name: "providerId",
displayKey: "clients:providerId",
},
]}
/>
</>
);
};

View file

@ -0,0 +1,204 @@
import ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
import ComponentTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentTypeRepresentation";
import {
ActionGroup,
Button,
ButtonVariant,
DropdownItem,
FormGroup,
PageSection,
ValidatedOptions,
} from "@patternfly/react-core";
import { useState } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAlerts } from "../../components/alert/Alerts";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { DynamicComponents } from "../../components/dynamic/DynamicComponents";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
import { KeycloakTextInput } from "../../components/keycloak-text-input/KeycloakTextInput";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useParams } from "../../utils/useParams";
import {
RegistrationProviderParams,
toRegistrationProvider,
} from "../routes/AddRegistrationProvider";
import { toClientRegistration } from "../routes/ClientRegistration";
export default function DetailProvider() {
const { t } = useTranslation("clients");
const { id, providerId, subTab } = useParams<RegistrationProviderParams>();
const navigate = useNavigate();
const form = useForm<ComponentRepresentation>({
defaultValues: { providerId },
});
const {
register,
control,
handleSubmit,
reset,
formState: { errors },
} = form;
const { adminClient } = useAdminClient();
const { realm } = useRealm();
const { addAlert, addError } = useAlerts();
const [provider, setProvider] = useState<ComponentTypeRepresentation>();
const [parentId, setParentId] = useState("");
useFetch(
async () =>
await Promise.all([
adminClient.realms.getClientRegistrationPolicyProviders({ realm }),
adminClient.realms.findOne({ realm }),
id ? adminClient.components.findOne({ id }) : Promise.resolve(),
]),
([providers, realm, data]) => {
setProvider(providers.find((p) => p.id === providerId));
setParentId(realm?.id || "");
reset(data || { providerId });
},
[]
);
const providerName = useWatch({ control, defaultValue: "", name: "name" });
const onSubmit = async (component: ComponentRepresentation) => {
if (component.config)
Object.entries(component.config).forEach(
([key, value]) =>
(component.config![key] = Array.isArray(value) ? value : [value])
);
try {
const updatedComponent = {
...component,
subType: subTab,
parentId,
providerType:
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy",
providerId,
};
if (id) {
await adminClient.components.update({ id }, updatedComponent);
} else {
const { id } = await adminClient.components.create(updatedComponent);
navigate(toRegistrationProvider({ id, realm, subTab, providerId }));
}
addAlert(t(`provider${id ? "Updated" : "Create"}Success`));
} catch (error) {
addError(`clients:provider${id ? "Updated" : "Create"}Error`, error);
}
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "clients:clientRegisterPolicyDeleteConfirmTitle",
messageKey: t("clientRegisterPolicyDeleteConfirm", {
name: providerName,
}),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.components.del({
realm,
id: id!,
});
addAlert(t("clientRegisterPolicyDeleteSuccess"));
navigate(toClientRegistration({ realm, subTab }));
} catch (error) {
addError("clients:clientRegisterPolicyDeleteError", error);
}
},
});
if (!provider) {
return <KeycloakSpinner />;
}
return (
<>
<ViewHeader
titleKey={id ? providerName! : "clients:createPolicy"}
subKey={id}
dropdownItems={
id
? [
<DropdownItem
data-testid="delete"
key="delete"
onClick={toggleDeleteDialog}
>
{t("common:delete")}
</DropdownItem>,
]
: undefined
}
/>
<DeleteConfirm />
<PageSection variant="light">
<FormAccess
role="manage-clients"
isHorizontal
onSubmit={handleSubmit(onSubmit)}
>
<FormGroup label={t("provider")} fieldId="provider">
<KeycloakTextInput
id="providerId"
data-testid="providerId"
{...register("providerId")}
readOnly
/>
</FormGroup>
<FormGroup
label={t("common:name")}
fieldId="kc-name"
helperTextInvalid={t("common:required")}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
labelIcon={
<HelpItem
helpText="clients-help:clientPolicyName"
fieldLabelId="kc-name"
/>
}
isRequired
>
<KeycloakTextInput
id="kc-name"
data-testid="name"
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
{...register("name", { required: true })}
/>
</FormGroup>
<FormProvider {...form}>
<DynamicComponents properties={provider.properties} />
</FormProvider>
<ActionGroup>
<Button data-testid="save" type="submit">
{t("common:save")}
</Button>
<Button
variant="link"
component={(props) => (
<Link
{...props}
to={toClientRegistration({ realm, subTab })}
></Link>
)}
>
{t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
</PageSection>
</>
);
}

View file

@ -1,7 +1,12 @@
import type { RouteDef } from "../route-config";
import { AddClientRoute } from "./routes/AddClient";
import {
AddRegistrationProviderRoute,
EditRegistrationProviderRoute,
} from "./routes/AddRegistrationProvider";
import { AuthorizationRoute } from "./routes/AuthenticationTab";
import { ClientRoute } from "./routes/Client";
import { ClientRegistrationRoute } from "./routes/ClientRegistration";
import { ClientRoleRoute } from "./routes/ClientRole";
import { ClientsRoute, ClientsRouteWithTab } from "./routes/Clients";
import { ClientScopesRoute } from "./routes/ClientScopeTab";
@ -32,6 +37,9 @@ import {
} from "./routes/Scope";
const routes: RouteDef[] = [
ClientRegistrationRoute,
AddRegistrationProviderRoute,
EditRegistrationProviderRoute,
AddClientRoute,
ImportClientRoute,
ClientsRoute,

View file

@ -0,0 +1,36 @@
import { lazy } from "react";
import type { Path } from "react-router-dom";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { ClientRegistrationTab } from "./ClientRegistration";
export type RegistrationProviderParams = {
realm: string;
subTab: ClientRegistrationTab;
id?: string;
providerId: string;
};
export const AddRegistrationProviderRoute: RouteDef = {
path: "/:realm/clients/client-registration/:subTab/:providerId",
component: lazy(() => import("../registration/DetailProvider")),
breadcrumb: (t) => t("clients:clientSettings"),
access: "manage-clients",
};
export const EditRegistrationProviderRoute: RouteDef = {
...AddRegistrationProviderRoute,
path: "/:realm/clients/client-registration/:subTab/:providerId/:id",
};
export const toRegistrationProvider = (
params: RegistrationProviderParams
): Partial<Path> => {
const path = params.id
? EditRegistrationProviderRoute.path
: AddRegistrationProviderRoute.path;
return {
pathname: generatePath(path, params),
};
};

View file

@ -0,0 +1,24 @@
import { lazy } from "react";
import type { Path } from "react-router-dom";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
export type ClientRegistrationTab = "anonymous" | "authenticated";
export type ClientRegistrationParams = {
realm: string;
subTab: ClientRegistrationTab;
};
export const ClientRegistrationRoute: RouteDef = {
path: "/:realm/clients/client-registration/:subTab",
component: lazy(() => import("../ClientsSection")),
breadcrumb: (t) => t("clients:clientRegistration"),
access: "view-clients",
};
export const toClientRegistration = (
params: ClientRegistrationParams
): Partial<Path> => ({
pathname: generatePath(ClientRegistrationRoute.path, params),
});

View file

@ -3,7 +3,10 @@ import type { Path } from "react-router-dom";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
export type ClientsTab = "list" | "initial-access-token";
export type ClientsTab =
| "list"
| "initial-access-token"
| "client-registration";
export type ClientsParams = {
realm: string;