initial version organization table for users

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
Erik Jan de Wit 2024-08-16 08:30:02 +02:00 committed by Pedro Igor
parent 4376a3c757
commit a3ffbb439d
8 changed files with 231 additions and 94 deletions

View file

@ -3216,4 +3216,8 @@ emailVerificationHelp=Specifies independent timeout for email verification.
idpAccountEmailVerificationHelp=Specifies independent timeout for IdP account email verification.
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.
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

View 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}
/>
);
};

View file

@ -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) => {
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 }))}
/>
}
/>
onDelete={(org) => {
setSelectedOrg(org);
toggleDeleteDialog();
}}
>
<ListEmptyState
message={t("emptyOrganizations")}
instructions={t("emptyOrganizationsInstructions")}
primaryActionText={t("createOrganization")}
onPrimaryAction={() => navigate(toAddOrganization({ realm }))}
/>
</OrganizationTable>
</PageSection>
</>
);

View file

@ -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>}

View 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>
);
};

View file

@ -6,6 +6,7 @@ import type { AppRouteObject } from "../../routes";
export type UserTab =
| "settings"
| "groups"
| "organizations"
| "consents"
| "attributes"
| "sessions"

View file

@ -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,
],
{

View file

@ -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",