forked from keycloak/keycloak
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Split UsersSection into components for better maintainability (keyclo…
…ak#19848) Closes keycloak#19847
- Loading branch information
1 parent
8331f57
commit ab8366f
Showing
3 changed files
with
392 additions
and
366 deletions.
There are no files selected for viewing
288 changes: 288 additions & 0 deletions
288
js/apps/admin-ui/src/components/users/UserDataTable.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,288 @@ | ||
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; | ||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; | ||
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; | ||
import { | ||
AlertVariant, | ||
ButtonVariant, | ||
EmptyState, | ||
Label, | ||
Text, | ||
TextContent, | ||
Toolbar, | ||
ToolbarContent, | ||
Tooltip, | ||
} from "@patternfly/react-core"; | ||
import { | ||
ExclamationCircleIcon, | ||
InfoCircleIcon, | ||
WarningTriangleIcon, | ||
} from "@patternfly/react-icons"; | ||
import type { IRowData } from "@patternfly/react-table"; | ||
import { useState } from "react"; | ||
import { useTranslation } from "react-i18next"; | ||
import { Link, useNavigate } from "react-router-dom"; | ||
|
||
import { adminClient } from "../../admin-client"; | ||
import { useAlerts } from "../alert/Alerts"; | ||
import { useConfirmDialog } from "../confirm-dialog/ConfirmDialog"; | ||
import { KeycloakSpinner } from "../keycloak-spinner/KeycloakSpinner"; | ||
import { ListEmptyState } from "../list-empty-state/ListEmptyState"; | ||
import { BruteUser, findUsers } from "../role-mapping/resource"; | ||
import { KeycloakDataTable } from "../table-toolbar/KeycloakDataTable"; | ||
import { useRealm } from "../../context/realm-context/RealmContext"; | ||
import { emptyFormatter } from "../../util"; | ||
import { useFetch } from "../../utils/useFetch"; | ||
import { toAddUser } from "../../user/routes/AddUser"; | ||
import { toUser } from "../../user/routes/User"; | ||
import { UserDataTableToolbarItems } from "./UserDataTableToolbarItems"; | ||
|
||
export function UserDataTable() { | ||
const { t } = useTranslation("users"); | ||
const { addAlert, addError } = useAlerts(); | ||
const { realm: realmName } = useRealm(); | ||
const navigate = useNavigate(); | ||
const [userStorage, setUserStorage] = useState<ComponentRepresentation[]>(); | ||
const [realm, setRealm] = useState<RealmRepresentation | undefined>(); | ||
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]); | ||
|
||
const [key, setKey] = useState(0); | ||
const refresh = () => setKey(key + 1); | ||
|
||
useFetch( | ||
async () => { | ||
const testParams = { | ||
type: "org.keycloak.storage.UserStorageProvider", | ||
}; | ||
|
||
try { | ||
return await Promise.all([ | ||
adminClient.components.find(testParams), | ||
adminClient.realms.findOne({ realm: realmName }), | ||
]); | ||
} catch { | ||
return [[], {}] as [ | ||
ComponentRepresentation[], | ||
RealmRepresentation | undefined | ||
]; | ||
} | ||
}, | ||
([storageProviders, realm]) => { | ||
setUserStorage( | ||
storageProviders.filter((p) => p.config?.enabled[0] === "true") | ||
); | ||
setRealm(realm); | ||
}, | ||
[] | ||
); | ||
|
||
const UserDetailLink = (user: UserRepresentation) => ( | ||
<Link | ||
key={user.username} | ||
to={toUser({ realm: realmName, id: user.id!, tab: "settings" })} | ||
> | ||
{user.username} | ||
</Link> | ||
); | ||
|
||
const loader = async (first?: number, max?: number, search?: string) => { | ||
const params: { [name: string]: string | number } = { | ||
first: first!, | ||
max: max!, | ||
}; | ||
|
||
const searchParam = search || ""; | ||
if (searchParam) { | ||
params.search = searchParam; | ||
} | ||
|
||
if (!listUsers && !searchParam) { | ||
return []; | ||
} | ||
|
||
try { | ||
return await findUsers({ | ||
briefRepresentation: true, | ||
...params, | ||
}); | ||
} catch (error) { | ||
if (userStorage?.length) { | ||
addError("users:noUsersFoundErrorStorage", error); | ||
} else { | ||
addError("users:noUsersFoundError", error); | ||
} | ||
return []; | ||
} | ||
}; | ||
|
||
const [toggleUnlockUsersDialog, UnlockUsersConfirm] = useConfirmDialog({ | ||
titleKey: "users:unlockAllUsers", | ||
messageKey: "users:unlockUsersConfirm", | ||
continueButtonLabel: "users:unlock", | ||
onConfirm: async () => { | ||
try { | ||
await adminClient.attackDetection.delAll(); | ||
refresh(); | ||
addAlert(t("unlockUsersSuccess"), AlertVariant.success); | ||
} catch (error) { | ||
addError("users:unlockUsersError", error); | ||
} | ||
}, | ||
}); | ||
|
||
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ | ||
titleKey: "users:deleteConfirm", | ||
messageKey: t("deleteConfirmDialog", { count: selectedRows.length }), | ||
continueButtonLabel: "delete", | ||
continueButtonVariant: ButtonVariant.danger, | ||
onConfirm: async () => { | ||
try { | ||
for (const user of selectedRows) { | ||
await adminClient.users.del({ id: user.id! }); | ||
} | ||
setSelectedRows([]); | ||
refresh(); | ||
addAlert(t("userDeletedSuccess"), AlertVariant.success); | ||
} catch (error) { | ||
addError("users:userDeletedError", error); | ||
} | ||
}, | ||
}); | ||
|
||
const StatusRow = (user: BruteUser) => { | ||
return ( | ||
<> | ||
{!user.enabled && ( | ||
<Label key={user.id} color="red" icon={<InfoCircleIcon />}> | ||
{t("disabled")} | ||
</Label> | ||
)} | ||
{user.bruteForceStatus?.disabled && ( | ||
<Label key={user.id} color="orange" icon={<WarningTriangleIcon />}> | ||
{t("temporaryLocked")} | ||
</Label> | ||
)} | ||
{user.enabled && !user.bruteForceStatus?.disabled && "—"} | ||
</> | ||
); | ||
}; | ||
|
||
const ValidatedEmail = (user: UserRepresentation) => { | ||
return ( | ||
<> | ||
{!user.emailVerified && ( | ||
<Tooltip | ||
key={`email-verified-${user.id}`} | ||
content={<>{t("notVerified")}</>} | ||
> | ||
<ExclamationCircleIcon className="keycloak__user-section__email-verified" /> | ||
</Tooltip> | ||
)}{" "} | ||
{emptyFormatter()(user.email)} | ||
</> | ||
); | ||
}; | ||
|
||
const goToCreate = () => navigate(toAddUser({ realm: realmName })); | ||
|
||
if (!userStorage || !realm) { | ||
return <KeycloakSpinner />; | ||
} | ||
|
||
//should *only* list users when no user federation is configured | ||
const listUsers = !(userStorage.length > 0); | ||
|
||
return ( | ||
<> | ||
<DeleteConfirm /> | ||
<UnlockUsersConfirm /> | ||
<KeycloakDataTable | ||
key={key} | ||
loader={loader} | ||
isPaginated | ||
ariaLabelKey="users:title" | ||
searchPlaceholderKey="users:searchForUser" | ||
canSelectAll | ||
onSelect={(rows: any[]) => setSelectedRows([...rows])} | ||
emptyState={ | ||
!listUsers ? ( | ||
<> | ||
<Toolbar> | ||
<ToolbarContent> | ||
<UserDataTableToolbarItems | ||
realm={realm} | ||
hasSelectedRows={selectedRows.length === 0} | ||
toggleDeleteDialog={toggleDeleteDialog} | ||
toggleUnlockUsersDialog={toggleUnlockUsersDialog} | ||
goToCreate={goToCreate} | ||
/> | ||
</ToolbarContent> | ||
</Toolbar> | ||
<EmptyState data-testid="empty-state" variant="large"> | ||
<TextContent className="kc-search-users-text"> | ||
<Text>{t("searchForUserDescription")}</Text> | ||
</TextContent> | ||
</EmptyState> | ||
</> | ||
) : ( | ||
<ListEmptyState | ||
message={t("noUsersFound")} | ||
instructions={t("emptyInstructions")} | ||
primaryActionText={t("createNewUser")} | ||
onPrimaryAction={goToCreate} | ||
/> | ||
) | ||
} | ||
toolbarItem={ | ||
<UserDataTableToolbarItems | ||
realm={realm} | ||
hasSelectedRows={selectedRows.length === 0} | ||
toggleDeleteDialog={toggleDeleteDialog} | ||
toggleUnlockUsersDialog={toggleUnlockUsersDialog} | ||
goToCreate={goToCreate} | ||
/> | ||
} | ||
actionResolver={(rowData: IRowData) => { | ||
const user: UserRepresentation = rowData.data; | ||
if (!user.access?.manage) return []; | ||
|
||
return [ | ||
{ | ||
title: t("common:delete"), | ||
onClick: () => { | ||
setSelectedRows([user]); | ||
toggleDeleteDialog(); | ||
}, | ||
}, | ||
]; | ||
}} | ||
columns={[ | ||
{ | ||
name: "username", | ||
displayKey: "users:username", | ||
cellRenderer: UserDetailLink, | ||
}, | ||
{ | ||
name: "email", | ||
displayKey: "users:email", | ||
cellRenderer: ValidatedEmail, | ||
}, | ||
{ | ||
name: "lastName", | ||
displayKey: "users:lastName", | ||
cellFormatters: [emptyFormatter()], | ||
}, | ||
{ | ||
name: "firstName", | ||
displayKey: "users:firstName", | ||
cellFormatters: [emptyFormatter()], | ||
}, | ||
{ | ||
name: "status", | ||
displayKey: "users:status", | ||
cellRenderer: StatusRow, | ||
}, | ||
]} | ||
/> | ||
</> | ||
); | ||
} |
97 changes: 97 additions & 0 deletions
97
js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; | ||
import { | ||
Button, | ||
ButtonVariant, | ||
Dropdown, | ||
DropdownItem, | ||
KebabToggle, | ||
ToolbarItem, | ||
} from "@patternfly/react-core"; | ||
import { useState } from "react"; | ||
import { useTranslation } from "react-i18next"; | ||
import { useAccess } from "../../context/access/Access"; | ||
|
||
type UserDataTableToolbarItemsProps = { | ||
realm: RealmRepresentation; | ||
hasSelectedRows: boolean; | ||
toggleDeleteDialog: () => void; | ||
toggleUnlockUsersDialog: () => void; | ||
goToCreate: () => void; | ||
}; | ||
|
||
export function UserDataTableToolbarItems({ | ||
realm, | ||
hasSelectedRows, | ||
toggleDeleteDialog, | ||
toggleUnlockUsersDialog, | ||
goToCreate, | ||
}: UserDataTableToolbarItemsProps) { | ||
const { t } = useTranslation("users"); | ||
const [kebabOpen, setKebabOpen] = useState(false); | ||
|
||
const { hasAccess } = useAccess(); | ||
|
||
// Only needs query-users access to attempt add/delete of users. | ||
// This is because the user could have fine-grained access to users | ||
// of a group. There is no way to know this without searching the | ||
// permissions of every group. | ||
const isManager = hasAccess("query-users"); | ||
|
||
const bruteForceProtectionToolbarItem = !realm.bruteForceProtected ? ( | ||
<ToolbarItem> | ||
<Button | ||
variant={ButtonVariant.link} | ||
onClick={toggleDeleteDialog} | ||
data-testid="delete-user-btn" | ||
isDisabled={hasSelectedRows} | ||
> | ||
{t("deleteUser")} | ||
</Button> | ||
</ToolbarItem> | ||
) : ( | ||
<ToolbarItem> | ||
<Dropdown | ||
toggle={<KebabToggle onToggle={(open) => setKebabOpen(open)} />} | ||
isOpen={kebabOpen} | ||
isPlain | ||
dropdownItems={[ | ||
<DropdownItem | ||
key="deleteUser" | ||
component="button" | ||
isDisabled={hasSelectedRows} | ||
onClick={() => { | ||
toggleDeleteDialog(); | ||
setKebabOpen(false); | ||
}} | ||
> | ||
{t("deleteUser")} | ||
</DropdownItem>, | ||
|
||
<DropdownItem | ||
key="unlock" | ||
component="button" | ||
onClick={() => { | ||
toggleUnlockUsersDialog(); | ||
setKebabOpen(false); | ||
}} | ||
> | ||
{t("unlockAllUsers")} | ||
</DropdownItem>, | ||
]} | ||
/> | ||
</ToolbarItem> | ||
); | ||
|
||
const actionItems = ( | ||
<> | ||
<ToolbarItem> | ||
<Button data-testid="add-user" onClick={goToCreate}> | ||
{t("addUser")} | ||
</Button> | ||
</ToolbarItem> | ||
{bruteForceProtectionToolbarItem} | ||
</> | ||
); | ||
|
||
return isManager ? actionItems : null; | ||
} |
Oops, something went wrong.