initial version organization table for users
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
parent
4376a3c757
commit
a3ffbb439d
8 changed files with 231 additions and 94 deletions
|
@ -3217,3 +3217,7 @@ idpAccountEmailVerificationHelp=Specifies independent timeout for IdP account em
|
|||
forgotPasswordHelp=Specifies independent timeout for forgot password.
|
||||
executeActionsHelp=Specifies independent timeout for execute actions.
|
||||
validatingX509CertsHelp=The public certificates Keycloak uses to validate the signatures of SAML requests and responses from the external IDP when Use metadata descriptor URL is OFF. Multiple certificates can be entered separated by comma (,). The certificates can be re-imported from the Metadata descriptor URL clicking the Import Keys action in the identity provider page. The action downloads the current certificates in the metadata endpoint and assigns them to the config in this same option. You need to click Save to definitely store the re-imported certificates.
|
||||
emptyUserOrganizations=No organizations
|
||||
emptyUserOrganizationsInstructions=There is no organization yet. Please join an organization or send an invitation to join an organization.
|
||||
joinOrganization=Join organization
|
||||
sendInvitation=Send invitation
|
107
js/apps/admin-ui/src/organizations/OrganizationTable.tsx
Normal file
107
js/apps/admin-ui/src/organizations/OrganizationTable.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
||||
import { Badge, Chip, ChipGroup } from "@patternfly/react-core";
|
||||
import { TableText } from "@patternfly/react-table";
|
||||
import { PropsWithChildren, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
KeycloakDataTable,
|
||||
LoaderFunction,
|
||||
} from "../components/table-toolbar/KeycloakDataTable";
|
||||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
import { toEditOrganization } from "./routes/EditOrganization";
|
||||
|
||||
const OrgDetailLink = (organization: OrganizationRepresentation) => {
|
||||
const { t } = useTranslation();
|
||||
const { realm } = useRealm();
|
||||
return (
|
||||
<TableText wrapModifier="truncate">
|
||||
<Link
|
||||
key={organization.id}
|
||||
to={toEditOrganization({
|
||||
realm,
|
||||
id: organization.id!,
|
||||
tab: "settings",
|
||||
})}
|
||||
>
|
||||
{organization.name}
|
||||
{!organization.enabled && (
|
||||
<Badge
|
||||
key={`${organization.id}-disabled`}
|
||||
isRead
|
||||
className="pf-v5-u-ml-sm"
|
||||
>
|
||||
{t("disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TableText>
|
||||
);
|
||||
};
|
||||
|
||||
const Domains = (org: OrganizationRepresentation) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ChipGroup
|
||||
numChips={2}
|
||||
expandedText={t("hide")}
|
||||
collapsedText={t("showRemaining")}
|
||||
>
|
||||
{org.domains?.map((dn) => (
|
||||
<Chip key={dn.name} isReadOnly>
|
||||
{dn.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
);
|
||||
};
|
||||
|
||||
type OrganizationTableProps = PropsWithChildren & {
|
||||
loader:
|
||||
| LoaderFunction<OrganizationRepresentation>
|
||||
| OrganizationRepresentation[];
|
||||
onDelete?: (org: OrganizationRepresentation) => void;
|
||||
toolbarItem?: ReactNode;
|
||||
};
|
||||
|
||||
export const OrganizationTable = ({
|
||||
loader,
|
||||
toolbarItem,
|
||||
onDelete,
|
||||
children,
|
||||
}: OrganizationTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<KeycloakDataTable
|
||||
loader={loader}
|
||||
isPaginated
|
||||
ariaLabelKey="organizationList"
|
||||
searchPlaceholderKey="searchOrganization"
|
||||
toolbarItem={toolbarItem}
|
||||
actions={[
|
||||
{
|
||||
title: t("delete"),
|
||||
onRowClick: onDelete,
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
name: "name",
|
||||
displayKey: "name",
|
||||
cellRenderer: OrgDetailLink,
|
||||
},
|
||||
{
|
||||
name: "domains",
|
||||
displayKey: "domains",
|
||||
cellRenderer: Domains,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
displayKey: "description",
|
||||
},
|
||||
]}
|
||||
emptyState={children}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,71 +1,21 @@
|
|||
import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation";
|
||||
import { useAlerts } from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonVariant,
|
||||
Chip,
|
||||
ChipGroup,
|
||||
PageSection,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
import { TableText } from "@patternfly/react-table";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAdminClient } from "../admin-client";
|
||||
import { useAlerts } from "@keycloak/keycloak-ui-shared";
|
||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
import { OrganizationTable } from "./OrganizationTable";
|
||||
import { toAddOrganization } from "./routes/AddOrganization";
|
||||
import { toEditOrganization } from "./routes/EditOrganization";
|
||||
|
||||
const OrgDetailLink = (organization: any) => {
|
||||
const { t } = useTranslation();
|
||||
const { realm } = useRealm();
|
||||
return (
|
||||
<TableText wrapModifier="truncate">
|
||||
<Link
|
||||
key={organization.id}
|
||||
to={toEditOrganization({
|
||||
realm,
|
||||
id: organization.id!,
|
||||
tab: "settings",
|
||||
})}
|
||||
>
|
||||
{organization.name}
|
||||
{!organization.enabled && (
|
||||
<Badge
|
||||
key={`${organization.id}-disabled`}
|
||||
isRead
|
||||
className="pf-v5-u-ml-sm"
|
||||
>
|
||||
{t("disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TableText>
|
||||
);
|
||||
};
|
||||
|
||||
const Domains = (org: OrganizationRepresentation) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ChipGroup
|
||||
numChips={2}
|
||||
expandedText={t("hide")}
|
||||
collapsedText={t("showRemaining")}
|
||||
>
|
||||
{org.domains?.map((dn) => (
|
||||
<Chip key={dn.name} isReadOnly>
|
||||
{dn.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OrganizationSection() {
|
||||
const { adminClient } = useAdminClient();
|
||||
|
@ -110,12 +60,9 @@ export default function OrganizationSection() {
|
|||
/>
|
||||
<PageSection variant="light" className="pf-v5-u-p-0">
|
||||
<DeleteConfirm />
|
||||
<KeycloakDataTable
|
||||
<OrganizationTable
|
||||
key={key}
|
||||
loader={loader}
|
||||
isPaginated
|
||||
ariaLabelKey="organizationList"
|
||||
searchPlaceholderKey="searchOrganization"
|
||||
toolbarItem={
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
|
@ -128,40 +75,18 @@ export default function OrganizationSection() {
|
|||
</Button>
|
||||
</ToolbarItem>
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
title: t("delete"),
|
||||
onRowClick: (org) => {
|
||||
onDelete={(org) => {
|
||||
setSelectedOrg(org);
|
||||
toggleDeleteDialog();
|
||||
},
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
name: "name",
|
||||
displayKey: "name",
|
||||
cellRenderer: OrgDetailLink,
|
||||
},
|
||||
{
|
||||
name: "domains",
|
||||
displayKey: "domains",
|
||||
cellRenderer: Domains,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
displayKey: "description",
|
||||
},
|
||||
]}
|
||||
emptyState={
|
||||
}}
|
||||
>
|
||||
<ListEmptyState
|
||||
message={t("emptyOrganizations")}
|
||||
instructions={t("emptyOrganizationsInstructions")}
|
||||
primaryActionText={t("createOrganization")}
|
||||
onPrimaryAction={() => navigate(toAddOrganization({ realm }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</OrganizationTable>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -5,6 +5,7 @@ import type {
|
|||
import {
|
||||
isUserProfileError,
|
||||
setUserProfileServerError,
|
||||
useAlerts,
|
||||
} from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
AlertVariant,
|
||||
|
@ -23,7 +24,6 @@ import { FormProvider, useForm } from "react-hook-form";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAdminClient } from "../admin-client";
|
||||
import { useAlerts } from "@keycloak/keycloak-ui-shared";
|
||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||
import { KeyValueType } from "../components/key-value-form/key-value-convert";
|
||||
import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner";
|
||||
|
@ -36,7 +36,9 @@ import { useAccess } from "../context/access/Access";
|
|||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
import { UserProfileProvider } from "../realm-settings/user-profile/UserProfileContext";
|
||||
import { useFetch } from "../utils/useFetch";
|
||||
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
|
||||
import { useParams } from "../utils/useParams";
|
||||
import { Organizations } from "./Organizations";
|
||||
import { UserAttributes } from "./UserAttributes";
|
||||
import { UserConsents } from "./UserConsents";
|
||||
import { UserCredentials } from "./UserCredentials";
|
||||
|
@ -87,6 +89,11 @@ export default function EditUser() {
|
|||
const lightweightUser = isLightweightUser(user?.id);
|
||||
const [upConfig, setUpConfig] = useState<UserProfileConfig>();
|
||||
|
||||
const [realmHasOrganizations, setRealmHasOrganizations] = useState(false);
|
||||
const isFeatureEnabled = useIsFeatureEnabled();
|
||||
const showOrganizations =
|
||||
isFeatureEnabled(Feature.Organizations) && realm?.organizationsEnabled;
|
||||
|
||||
const toTab = (tab: UserTab) =>
|
||||
toUser({
|
||||
realm: realmName,
|
||||
|
@ -101,6 +108,7 @@ export default function EditUser() {
|
|||
const credentialsTab = useTab("credentials");
|
||||
const roleMappingTab = useTab("role-mapping");
|
||||
const groupsTab = useTab("groups");
|
||||
const organizationsTab = useTab("organizations");
|
||||
const consentsTab = useTab("consents");
|
||||
const identityProviderLinksTab = useTab("identity-provider-links");
|
||||
const sessionsTab = useTab("sessions");
|
||||
|
@ -115,8 +123,15 @@ export default function EditUser() {
|
|||
adminClient.attackDetection.findOne({ id: id! }),
|
||||
adminClient.users.getUnmanagedAttributes({ id: id! }),
|
||||
adminClient.users.getProfile({ realm: realmName }),
|
||||
adminClient.organizations.find({ first: 0, max: 1 }),
|
||||
]),
|
||||
([userData, attackDetection, unmanagedAttributes, upConfig]) => {
|
||||
([
|
||||
userData,
|
||||
attackDetection,
|
||||
unmanagedAttributes,
|
||||
upConfig,
|
||||
organizations,
|
||||
]) => {
|
||||
if (!userData || !realm || !attackDetection) {
|
||||
throw new Error(t("notFound"));
|
||||
}
|
||||
|
@ -140,6 +155,7 @@ export default function EditUser() {
|
|||
const isLocked = isBruteForceProtected && attackDetection.disabled;
|
||||
|
||||
setBruteForced({ isBruteForceProtected, isLocked });
|
||||
setRealmHasOrganizations(organizations.length === 1);
|
||||
|
||||
form.reset(toUserFormFields(user));
|
||||
},
|
||||
|
@ -357,6 +373,15 @@ export default function EditUser() {
|
|||
<UserGroups user={user} />
|
||||
</Tab>
|
||||
)}
|
||||
{showOrganizations && realmHasOrganizations && (
|
||||
<Tab
|
||||
data-testid="user-organizations-tab"
|
||||
title={<TabTitleText>{t("organizations")}</TabTitleText>}
|
||||
{...organizationsTab}
|
||||
>
|
||||
<Organizations />
|
||||
</Tab>
|
||||
)}
|
||||
<Tab
|
||||
data-testid="user-consents-tab"
|
||||
title={<TabTitleText>{t("consents")}</TabTitleText>}
|
||||
|
|
66
js/apps/admin-ui/src/user/Organizations.tsx
Normal file
66
js/apps/admin-ui/src/user/Organizations.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { Button, ToolbarItem } from "@patternfly/react-core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useAdminClient } from "../admin-client";
|
||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
import { OrganizationTable } from "../organizations/OrganizationTable";
|
||||
import { toAddOrganization } from "../organizations/routes/AddOrganization";
|
||||
import { UserParams } from "./routes/User";
|
||||
|
||||
export const Organizations = () => {
|
||||
const { adminClient } = useAdminClient();
|
||||
const { t } = useTranslation();
|
||||
const { realm } = useRealm();
|
||||
const { id } = useParams<UserParams>();
|
||||
|
||||
return (
|
||||
<OrganizationTable
|
||||
loader={() =>
|
||||
adminClient.organizations.memberOrganizations({
|
||||
userId: id!,
|
||||
})
|
||||
}
|
||||
toolbarItem={
|
||||
<>
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
data-testid="joinOrganization"
|
||||
component={(props) => (
|
||||
<Link {...props} to={toAddOrganization({ realm })} />
|
||||
)}
|
||||
>
|
||||
{t("joinOrganization")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
data-testid="removeOrganization"
|
||||
variant="secondary"
|
||||
component={(props) => (
|
||||
<Link {...props} to={toAddOrganization({ realm })} />
|
||||
)}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ListEmptyState
|
||||
message={t("emptyUserOrganizations")}
|
||||
instructions={t("emptyUserOrganizationsInstructions")}
|
||||
secondaryActions={[
|
||||
{
|
||||
text: t("joinOrganization"),
|
||||
onClick: () => alert("join organization"),
|
||||
},
|
||||
{
|
||||
text: t("sendInvitation"),
|
||||
onClick: () => alert("send invitation"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</OrganizationTable>
|
||||
);
|
||||
};
|
|
@ -6,6 +6,7 @@ import type { AppRouteObject } from "../../routes";
|
|||
export type UserTab =
|
||||
| "settings"
|
||||
| "groups"
|
||||
| "organizations"
|
||||
| "consents"
|
||||
| "attributes"
|
||||
| "sessions"
|
||||
|
|
|
@ -56,7 +56,7 @@ async function startServer() {
|
|||
path.join(SERVER_DIR, `bin/kc${SCRIPT_EXTENSION}`),
|
||||
[
|
||||
"start-dev",
|
||||
`--features="login2,account3,admin-fine-grained-authz,transient-users,oid4vc-vci"`,
|
||||
`--features="login2,account3,admin-fine-grained-authz,transient-users,oid4vc-vci,organization"`,
|
||||
...keycloakArgs,
|
||||
],
|
||||
{
|
||||
|
|
|
@ -96,6 +96,15 @@ export class Organizations extends Resource<{ realm?: string }> {
|
|||
urlParamKeys: ["orgId", "userId"],
|
||||
});
|
||||
|
||||
public memberOrganizations = this.makeRequest<
|
||||
{ userId: string },
|
||||
OrganizationRepresentation[]
|
||||
>({
|
||||
method: "GET",
|
||||
path: "/members/{userId}/organizations",
|
||||
urlParamKeys: ["userId"],
|
||||
});
|
||||
|
||||
public invite = this.makeUpdateRequest<{ orgId: string }, FormData>({
|
||||
method: "POST",
|
||||
path: "/{orgId}/members/invite-user",
|
||||
|
|
Loading…
Reference in a new issue