Client policies(policies): Create, add, delete and list client profiles in existing policy (#1393)

* add create client policy form; WIP

add client policy tests

checkout realm settings test from master

RealmSettingsPage.ts master

remove comment and add missing translation

fix tests

PR feedback from Jon and Erik

rebase

editClientPolicy

edit client policy

add client policy conditions form

fix bug in create form

remove comment

update help text

fixes

breadcrumbs

add support for adding multiple conditions, deleting conditions, and list conditions in data table

clean up names

add delete functionality to conditions form

PR feedback from Jon

useMemo for conditions

remove comments and logs

remove unused hook

add profiles modal wip

addprofiles wip

profiles wip

help text wip

add help text

remove comments

remove duplicate message

update data test id

PR feedback from Jon 1

Apply suggestions from code review

Co-authored-by: Jon Koops <jonkoops@gmail.com>

remove fragment

create policy detail attribute type

* PR feedback from Jon 1

* PR feedback from Jon 2

* add spinner to prevent loader from being called

* remove duplicate identifier

* fix and rename route

* rename route
This commit is contained in:
Jenny 2021-10-29 10:19:57 -04:00 committed by GitHub
parent 5e63bac12e
commit 9e5f711bea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 379 additions and 35 deletions

View file

@ -0,0 +1,137 @@
import React, { useState } from "react";
import {
Button,
Label,
Modal,
ModalVariant,
Spinner,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useFetch, useAdminClient } from "../context/auth/AdminClient";
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import type ClientProfileRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfileRepresentation";
type ClientProfile = ClientProfileRepresentation & {
global: boolean;
};
export type AddClientProfileModalProps = {
open: boolean;
toggleDialog: () => void;
onConfirm: (newReps: RoleRepresentation[]) => void;
allProfiles: string[];
};
export const AddClientProfileModal = (props: AddClientProfileModalProps) => {
const { t } = useTranslation("roles");
const adminClient = useAdminClient();
const [selectedRows, setSelectedRows] = useState<RoleRepresentation[]>([]);
const [tableProfiles, setTableProfiles] = useState<ClientProfile[]>();
useFetch(
() =>
adminClient.clientPolicies.listProfiles({
includeGlobalProfiles: true,
}),
(allProfiles) => {
const globalProfiles = allProfiles.globalProfiles?.map(
(globalProfiles) => ({
...globalProfiles,
global: true,
})
);
const profiles = allProfiles.profiles?.map((profiles) => ({
...profiles,
global: false,
}));
setTableProfiles([...(globalProfiles ?? []), ...(profiles ?? [])]);
},
[]
);
const loader = async () => tableProfiles ?? [];
if (!tableProfiles) {
return (
<div className="pf-u-text-align-center">
<Spinner />
</div>
);
}
const AliasRenderer = ({ name }: ClientProfile) => (
<>
{name && <Label color="blue">{name}</Label>} {name}
</>
);
return (
<Modal
data-testid="addClientProfile"
title={t("realm-settings:addClientProfile")}
isOpen={props.open}
onClose={props.toggleDialog}
variant={ModalVariant.large}
actions={[
<Button
key="add"
data-testid="add-client-profile-button"
variant="primary"
isDisabled={!selectedRows.length}
onClick={() => {
props.toggleDialog();
props.onConfirm(selectedRows);
}}
>
{t("common:add")}
</Button>,
<Button
key="cancel"
variant="link"
onClick={() => {
props.toggleDialog();
}}
>
{t("common:cancel")}
</Button>,
]}
>
<KeycloakDataTable
loader={loader}
isRowDisabled={(value) =>
props.allProfiles.includes(value.name!) || false
}
ariaLabelKey="realm-settings:profilesList"
searchPlaceholderKey="realm-settings:searchProfile"
canSelectAll
onSelect={(rows) => {
setSelectedRows([...rows]);
}}
columns={[
{
name: "name",
displayKey: "realm-settings:clientProfileName",
cellRenderer: AliasRenderer,
},
{
name: "description",
displayKey: "common:description",
},
]}
emptyState={
<ListEmptyState
hasIcon
message={t("noRoles")}
instructions={t("noRolesInstructions")}
primaryActionText={t("createRole")}
/>
}
/>
</Modal>
);
};

View file

@ -38,6 +38,8 @@ import { toClientPolicies } from "./routes/ClientPolicies";
import { toNewClientPolicyCondition } from "./routes/AddCondition"; import { toNewClientPolicyCondition } from "./routes/AddCondition";
import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import type { EditClientPolicyParams } from "./routes/EditClientPolicy"; import type { EditClientPolicyParams } from "./routes/EditClientPolicy";
import { AddClientProfileModal } from "./AddClientProfileModal";
import type ClientProfileRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientProfileRepresentation";
type NewClientPolicyForm = Required<ClientPolicyRepresentation>; type NewClientPolicyForm = Required<ClientPolicyRepresentation>;
@ -49,6 +51,11 @@ const defaultValues: NewClientPolicyForm = {
profiles: [], profiles: [],
}; };
type PolicyDetailAttributes = {
idx: number;
name: string;
};
export const NewClientPolicyForm = () => { export const NewClientPolicyForm = () => {
const { t } = useTranslation("realm-settings"); const { t } = useTranslation("realm-settings");
const { errors, reset: resetForm } = useForm<NewClientPolicyForm>({ const { errors, reset: resetForm } = useForm<NewClientPolicyForm>({
@ -58,6 +65,10 @@ export const NewClientPolicyForm = () => {
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>([]); const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>([]);
const [clientProfiles, setClientProfiles] = useState<
ClientProfileRepresentation[]
>([]);
const [currentPolicy, setCurrentPolicy] = const [currentPolicy, setCurrentPolicy] =
useState<ClientPolicyRepresentation>(); useState<ClientPolicyRepresentation>();
const [ const [
@ -66,7 +77,12 @@ export const NewClientPolicyForm = () => {
] = useState(false); ] = useState(false);
const [conditionToDelete, setConditionToDelete] = const [conditionToDelete, setConditionToDelete] =
useState<{ idx: number; name: string }>(); useState<PolicyDetailAttributes>();
const [profilesModalOpen, setProfilesModalOpen] = useState(false);
const [profileToDelete, setProfileToDelete] =
useState<PolicyDetailAttributes>();
const { policyName } = useParams<EditClientPolicyParams>(); const { policyName } = useParams<EditClientPolicyParams>();
@ -78,14 +94,30 @@ export const NewClientPolicyForm = () => {
const refresh = () => setKey(new Date().getTime()); const refresh = () => setKey(new Date().getTime());
useFetch( useFetch(
() => adminClient.clientPolicies.listPolicies(), async () => {
(policies) => { const [policies, profiles] = await Promise.all([
adminClient.clientPolicies.listPolicies(),
adminClient.clientPolicies.listProfiles({
includeGlobalProfiles: true,
}),
]);
return { policies, profiles };
},
({ policies, profiles }) => {
const currentPolicy = policies.policies?.find( const currentPolicy = policies.policies?.find(
(item) => item.name === policyName (item) => item.name === policyName
); );
const allClientProfiles = [
...(profiles.globalProfiles ?? []),
...(profiles.profiles ?? []),
];
setPolicies(policies.policies ?? []); setPolicies(policies.policies ?? []);
if (currentPolicy) { if (currentPolicy) {
setupForm(currentPolicy); setupForm(currentPolicy);
setClientProfiles(allClientProfiles);
setCurrentPolicy(currentPolicy); setCurrentPolicy(currentPolicy);
setShowAddConditionsAndProfilesForm(true); setShowAddConditionsAndProfilesForm(true);
} }
@ -102,6 +134,7 @@ export const NewClientPolicyForm = () => {
const policy = policies.filter((policy) => policy.name === policyName); const policy = policies.filter((policy) => policy.name === policyName);
const policyConditions = policy[0]?.conditions || []; const policyConditions = policy[0]?.conditions || [];
const policyProfiles = policy[0]?.profiles || [];
const serverInfo = useServerInfo(); const serverInfo = useServerInfo();
@ -110,8 +143,10 @@ export const NewClientPolicyForm = () => {
"org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider" "org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider"
]; ];
const formValues = form.getValues();
const save = async () => { const save = async () => {
const createdForm = form.getValues(); const createdForm = formValues;
const createdPolicy = { const createdPolicy = {
...createdForm, ...createdForm,
profiles: [], profiles: [],
@ -137,9 +172,7 @@ export const NewClientPolicyForm = () => {
AlertVariant.success AlertVariant.success
); );
history.push( history.push(
`/${realm}/realm-settings/clientPolicies/${ `/${realm}/realm-settings/clientPolicies/${formValues.name}/edit-policy`
form.getValues().name
}/edit-policy`
); );
setShowAddConditionsAndProfilesForm(true); setShowAddConditionsAndProfilesForm(true);
refresh(); refresh();
@ -189,10 +222,9 @@ export const NewClientPolicyForm = () => {
}); });
addAlert(t("deleteConditionSuccess"), AlertVariant.success); addAlert(t("deleteConditionSuccess"), AlertVariant.success);
history.push( history.push(
`/${realm}/realm-settings/clientPolicies/${ `/${realm}/realm-settings/clientPolicies/${formValues.name}/edit-policy`
form.getValues().name
}/edit-policy`
); );
refresh();
} catch (error) { } catch (error) {
addError(t("deleteConditionError"), error); addError(t("deleteConditionError"), error);
} }
@ -214,15 +246,109 @@ export const NewClientPolicyForm = () => {
}, },
}); });
const [toggleDeleteProfileDialog, DeleteProfileConfirm] = useConfirmDialog({
titleKey: t("deleteClientPolicyProfileConfirmTitle"),
messageKey: t("deleteClientPolicyProfileConfirm", {
profileName: profileToDelete?.name,
policyName,
}),
continueButtonLabel: t("delete"),
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
if (profileToDelete?.name) {
currentPolicy?.profiles?.splice(profileToDelete.idx!, 1);
try {
await adminClient.clientPolicies.updatePolicy({
policies: policies,
});
addAlert(t("deleteClientPolicyProfileSuccess"), AlertVariant.success);
history.push(
`/${realm}/realm-settings/clientPolicies/${formValues.name}/edit-policy`
);
} catch (error) {
addError(t("deleteClientPolicyProfileError"), error);
}
} else {
const updatedPolicies = policies.filter(
(policy) => policy.name !== policyName
);
try {
await adminClient.clientPolicies.updatePolicy({
policies: updatedPolicies,
});
addAlert(t("deleteClientSuccess"), AlertVariant.success);
history.push(toClientPolicies({ realm }));
} catch (error) {
addError(t("deleteClientError"), error);
}
}
},
});
const reset = () => { const reset = () => {
form.setValue("name", currentPolicy?.name); form.setValue("name", currentPolicy?.name);
form.setValue("description", currentPolicy?.description); form.setValue("description", currentPolicy?.description);
}; };
const toggleModal = () => {
setProfilesModalOpen(!profilesModalOpen);
};
const addProfiles = async (profiles: string[]) => {
const createdPolicy = {
...currentPolicy,
profiles: (currentPolicy?.profiles ?? []).concat(profiles),
conditions: currentPolicy?.conditions,
};
const index = policies.findIndex(
(policy) => createdPolicy.name === policy.name
);
if (index === -1) {
return;
}
const newPolicies = [
...policies.slice(0, index),
createdPolicy,
...policies.slice(index + 1),
];
try {
await adminClient.clientPolicies.updatePolicy({
policies: newPolicies,
});
setPolicies(newPolicies);
history.push(
`/${realm}/realm-settings/clientPolicies/${formValues.name}/edit-policy`
);
addAlert(
t("realm-settings:addClientProfileSuccess"),
AlertVariant.success
);
refresh();
} catch (error) {
addError("realm-settings:addClientProfileError", error);
}
};
console.log("NY", policyConditions);
return ( return (
<> <>
<DeleteConfirm /> <DeleteConfirm />
<DeleteConditionConfirm /> <DeleteConditionConfirm />
<DeleteProfileConfirm />
<AddClientProfileModal
onConfirm={(profiles: ClientProfileRepresentation[]) => {
addProfiles(profiles.map((item) => item.name!));
}}
allProfiles={policyProfiles}
open={profilesModalOpen}
toggleDialog={toggleModal}
/>
<ViewHeader <ViewHeader
titleKey={ titleKey={
showAddConditionsAndProfilesForm || policyName showAddConditionsAndProfilesForm || policyName
@ -286,6 +412,7 @@ export const NewClientPolicyForm = () => {
variant="primary" variant="primary"
type="submit" type="submit"
data-testid="saveCreatePolicy" data-testid="saveCreatePolicy"
isDisabled={!formValues.name}
> >
{t("common:save")} {t("common:save")}
</Button> </Button>
@ -325,7 +452,7 @@ export const NewClientPolicyForm = () => {
{...props} {...props}
to={toNewClientPolicyCondition({ to={toNewClientPolicyCondition({
realm, realm,
policyName: form.getValues().name!, policyName: formValues.name!,
})} })}
></Link> ></Link>
)} )}
@ -342,9 +469,10 @@ export const NewClientPolicyForm = () => {
<DataList aria-label={t("conditions")} isCompact> <DataList aria-label={t("conditions")} isCompact>
{policyConditions.map((condition, idx) => ( {policyConditions.map((condition, idx) => (
<DataListItem <DataListItem
aria-labelledby={"conditions-list-item"} aria-labelledby="conditions-list-item"
key={`list-item-${idx}`} key={`list-item-${idx}`}
id={condition.condition} id={condition.condition}
data-testid="conditions-list-item"
> >
<DataListItemRow data-testid="conditions-list-row"> <DataListItemRow data-testid="conditions-list-row">
<DataListItemCells <DataListItemCells
@ -435,23 +563,92 @@ export const NewClientPolicyForm = () => {
</FlexItem> </FlexItem>
<FlexItem align={{ default: "alignRight" }}> <FlexItem align={{ default: "alignRight" }}>
<Button <Button
id="addExecutor" id="addClientProfile"
variant="link" variant="link"
className="kc-addClientProfile" className="kc-addClientProfile"
data-testid="cancelCreateProfile" data-testid="cancelCreateProfile"
icon={<PlusCircleIcon />} icon={<PlusCircleIcon />}
onClick={toggleModal}
> >
{t("realm-settings:addClientProfile")} {t("realm-settings:addClientProfile")}
</Button> </Button>
</FlexItem> </FlexItem>
</Flex> </Flex>
<Divider /> {policyProfiles.length > 0 ? (
<Text <DataList aria-label={t("profiles")} isCompact>
className="kc-emptyClientProfiles" {policyProfiles.map((profile, idx) => (
component={TextVariants.h6} <DataListItem
> aria-labelledby={`${profile}-profile-list-item`}
{t("realm-settings:emptyProfiles")} key={profile}
</Text> id={`${profile}-profile-list-item`}
data-testid={"profile-list-item"}
>
<DataListItemRow data-testid="profile-list-row">
<DataListItemCells
dataListCells={[
<DataListCell key="name" data-testid="profile-name">
{profile && (
<Link
key={profile}
data-testid="profile-name-link"
to={""}
className="kc-profile-link"
>
{profile}
</Link>
)}
{policyProfiles
.filter((type) => type === profile)
.map((type) => (
<>
<HelpItem
helpText={
clientProfiles.find(
(profile) => type === profile.name
)?.description
}
forLabel={profile}
forID={t(`common:helpLabel`, {
label: profile,
})}
/>
<Button
variant="link"
isInline
icon={
<TrashIcon
className="kc-conditionType-trash-icon"
data-testid="deleteClientProfileDropdown"
onClick={() => {
toggleDeleteProfileDialog();
setProfileToDelete({
idx: idx,
name: type!,
});
}}
/>
}
></Button>
</>
))}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
))}
</DataList>
) : (
<>
<Divider />
<Text
className="kc-emptyClientProfiles"
component={TextVariants.h6}
>
{t("realm-settings:emptyProfiles")}
</Text>
</>
)}
</> </>
)} )}
</FormAccess> </FormAccess>

View file

@ -26,7 +26,7 @@ import { useAlerts } from "../components/alert/Alerts";
import "./RealmSettingsSection.css"; import "./RealmSettingsSection.css";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
import { toNewClientPolicy } from "./routes/NewClientPolicy"; import { toAddClientPolicy } from "./routes/AddClientPolicy";
import { toEditClientPolicy } from "./routes/EditClientPolicy"; import { toEditClientPolicy } from "./routes/EditClientPolicy";
export const PoliciesTab = () => { export const PoliciesTab = () => {
const { t } = useTranslation("realm-settings"); const { t } = useTranslation("realm-settings");
@ -161,7 +161,7 @@ export const PoliciesTab = () => {
message={t("realm-settings:noClientPolicies")} message={t("realm-settings:noClientPolicies")}
instructions={t("realm-settings:noClientPoliciesInstructions")} instructions={t("realm-settings:noClientPoliciesInstructions")}
primaryActionText={t("realm-settings:createClientPolicy")} primaryActionText={t("realm-settings:createClientPolicy")}
onPrimaryAction={() => history.push(toNewClientPolicy({ realm }))} onPrimaryAction={() => history.push(toAddClientPolicy({ realm }))}
/> />
} }
ariaLabelKey="realm-settings:clientPolicies" ariaLabelKey="realm-settings:clientPolicies"
@ -172,7 +172,7 @@ export const PoliciesTab = () => {
<Button <Button
id="createPolicy" id="createPolicy"
component={(props) => ( component={(props) => (
<Link {...props} to={toNewClientPolicy({ realm })} /> <Link {...props} to={toAddClientPolicy({ realm })} />
)} )}
data-testid="createPolicy" data-testid="createPolicy"
> >

View file

@ -226,7 +226,7 @@ export const ProfilesTab = () => {
columns={[ columns={[
{ {
name: "name", name: "name",
displayKey: t("clientProfileName"), displayKey: t("common:name"),
cellRenderer: cellFormatter, cellRenderer: cellFormatter,
}, },
{ {

View file

@ -231,7 +231,8 @@ export default {
jsonEditor: "JSON editor", jsonEditor: "JSON editor",
}, },
clientProfileSearch: "Search", clientProfileSearch: "Search",
clientProfileName: "Name", searchProfile: "Search profile",
clientProfileName: "Client profile name",
clientProfileDescription: "Description", clientProfileDescription: "Description",
emptyClientProfiles: "No profiles", emptyClientProfiles: "No profiles",
emptyClientProfilesInstructions: emptyClientProfilesInstructions:
@ -241,12 +242,20 @@ export default {
"This action will permanently delete the profile {{profileName}}. This cannot be undone.", "This action will permanently delete the profile {{profileName}}. This cannot be undone.",
deleteClientSuccess: "Client profile deleted", deleteClientSuccess: "Client profile deleted",
deleteClientError: "Could not delete profile: {{error}}", deleteClientError: "Could not delete profile: {{error}}",
deleteClientPolicyProfileConfirmTitle: "Delete profile?",
deleteClientPolicyProfileConfirm:
"This action will permanently delete {{profileName}} from the policy {{policyName}}. This cannot be undone.",
deleteClientPolicyProfileSuccess:
"Profile successfully removed from the policy.",
deleteClientPolicyProfileError:
"Could not delete profile from the policy: {{error}}",
createClientProfile: "Create client profile", createClientProfile: "Create client profile",
deleteClientProfile: "Delete this client profile", deleteClientProfile: "Delete this client profile",
createClientProfileSuccess: "New client profile created", createClientProfileSuccess: "New client profile created",
updateClientProfileSuccess: "Client profile updated successfully", updateClientProfileSuccess: "Client profile updated successfully",
createClientProfileError: "Could not create client profile: '{{error}}'", createClientProfileError: "Could not create client profile: '{{error}}'",
updateClientProfileError: "Could not update client profile: '{{error}}'", addClientProfileSuccess: "New client profile added",
addClientProfileError: "Could not create client profile: '{{error}}'",
createClientProfileNameHelperText: createClientProfileNameHelperText:
"The name must be unique within the realm", "The name must be unique within the realm",
allClientPolicies: "Client policies", allClientPolicies: "Client policies",

View file

@ -10,7 +10,7 @@ import { ClientPoliciesRoute } from "./routes/ClientPolicies";
import { AddClientProfileRoute } from "./routes/AddClientProfile"; import { AddClientProfileRoute } from "./routes/AddClientProfile";
import { ClientProfileRoute } from "./routes/ClientProfile"; import { ClientProfileRoute } from "./routes/ClientProfile";
import { AddExecutorRoute } from "./routes/AddExecutor"; import { AddExecutorRoute } from "./routes/AddExecutor";
import { NewClientPolicyRoute } from "./routes/NewClientPolicy"; import { AddClientPolicyRoute } from "./routes/AddClientPolicy";
import { EditClientPolicyRoute } from "./routes/EditClientPolicy"; import { EditClientPolicyRoute } from "./routes/EditClientPolicy";
import { NewClientPolicyConditionRoute } from "./routes/AddCondition"; import { NewClientPolicyConditionRoute } from "./routes/AddCondition";
@ -26,7 +26,7 @@ const routes: RouteDef[] = [
AddClientProfileRoute, AddClientProfileRoute,
AddExecutorRoute, AddExecutorRoute,
ClientProfileRoute, ClientProfileRoute,
NewClientPolicyRoute, AddClientPolicyRoute,
EditClientPolicyRoute, EditClientPolicyRoute,
NewClientPolicyConditionRoute, NewClientPolicyConditionRoute,
]; ];

View file

@ -4,17 +4,17 @@ import type { RouteDef } from "../../route-config";
import { NewClientPolicyForm } from "../NewClientPolicyForm"; import { NewClientPolicyForm } from "../NewClientPolicyForm";
import { NewPolicyCrumb } from "../RealmSettingsSection"; import { NewPolicyCrumb } from "../RealmSettingsSection";
export type NewClientPolicyParams = { realm: string }; export type AddClientPolicyParams = { realm: string };
export const NewClientPolicyRoute: RouteDef = { export const AddClientPolicyRoute: RouteDef = {
path: "/:realm/realm-settings/clientPolicies/new-client-policy", path: "/:realm/realm-settings/clientPolicies/add-client-policy",
component: NewClientPolicyForm, component: NewClientPolicyForm,
breadcrumb: () => NewPolicyCrumb, breadcrumb: () => NewPolicyCrumb,
access: "manage-clients", access: "manage-clients",
}; };
export const toNewClientPolicy = ( export const toAddClientPolicy = (
params: NewClientPolicyParams params: AddClientPolicyParams
): LocationDescriptorObject => ({ ): LocationDescriptorObject => ({
pathname: generatePath(NewClientPolicyRoute.path, params), pathname: generatePath(AddClientPolicyRoute.path, params),
}); });

View file

@ -9,7 +9,7 @@ export type ClientProfileParams = {
}; };
export const ClientProfileRoute: RouteDef = { export const ClientProfileRoute: RouteDef = {
path: "/:realm/realm-settings/clientPolicies/:profileName", path: "/:realm/realm-settings/clientPolicies/:profileName/edit-profile",
component: ClientProfileForm, component: ClientProfileForm,
breadcrumb: (t) => t("realm-settings:clientProfile"), breadcrumb: (t) => t("realm-settings:clientProfile"),
access: ["view-realm", "view-users"], access: ["view-realm", "view-users"],

View file

@ -1,6 +1,7 @@
import type { LocationDescriptorObject } from "history"; import type { LocationDescriptorObject } from "history";
import { lazy } from "react"; import { lazy } from "react";
import { generatePath } from "react-router-dom"; import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config"; import type { RouteDef } from "../../route-config";
export type AddUserParams = { realm: string }; export type AddUserParams = { realm: string };