User credentials (#1597)

* user credentials - wip

* user credentials - wip

* user credentials - wip

* user credentials - wip

* user credentials - wip

* user credentials - wip

* user credentials - wip

* user credentials - wip

* user credentials - wip

* added deleting credentials

* replaced DataList with Table

* added reset password - wip

* added reset password

* added show data dialog - wip

* added show data dialog - wip

* added password data dialog

* added few translations

* added sorting to password data

* tidied up

* clean up rows code

* feedback fixes

Co-authored-by: Agnieszka Gancarczyk <agancarc@redhat.com>
Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
agagancarczyk 2021-11-24 15:37:30 +00:00 committed by GitHub
parent 999b502d44
commit 25030a790f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 597 additions and 0 deletions

View file

@ -119,6 +119,7 @@ export default {
}, },
attributes: "Attributes", attributes: "Attributes",
credentials: "Credentials",
clientId: "Client ID", clientId: "Client ID",
id: "ID", id: "ID",
@ -153,5 +154,11 @@ export default {
onDragFinish: "Dragging finished {{list}}", onDragFinish: "Dragging finished {{list}}",
notFound: "Could not find the resource that you are looking for", notFound: "Could not find the resource that you are looking for",
password: "Password",
passwordConfirmation: "Password confirmation",
temporaryPassword: "Temporary",
temporaryPasswordHelpText:
"If enabled, the user must change the password on next login",
}, },
}; };

View file

@ -0,0 +1,505 @@
import React, { FunctionComponent, useMemo, useState } from "react";
import {
AlertVariant,
Button,
ButtonVariant,
Dropdown,
DropdownItem,
DropdownPosition,
Form,
FormGroup,
KebabToggle,
Modal,
ModalVariant,
Switch,
Text,
TextVariants,
ValidatedOptions,
} from "@patternfly/react-core";
import {
Table,
TableBody,
TableComposable,
TableHeader,
TableVariant,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@patternfly/react-table";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { useTranslation } from "react-i18next";
import { useAlerts } from "../components/alert/Alerts";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { useWhoAmI } from "../context/whoami/WhoAmI";
import { Controller, useForm, useWatch } from "react-hook-form";
import { PasswordInput } from "../components/password-input/PasswordInput";
import { HelpItem } from "../components/help-enabler/HelpItem";
import "./user-section.css";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation";
type UserCredentialsProps = {
user: UserRepresentation;
};
type CredentialsForm = {
password: string;
passwordConfirmation: string;
temporaryPassword: boolean;
};
const defaultValues: CredentialsForm = {
password: "",
passwordConfirmation: "",
temporaryPassword: true,
};
type DisplayDialogProps = {
titleKey: string;
onClose: () => void;
};
const DisplayDialog: FunctionComponent<DisplayDialogProps> = ({
titleKey,
onClose,
children,
}) => {
const { t } = useTranslation("users");
return (
<Modal
variant={ModalVariant.medium}
title={t(titleKey)}
isOpen={true}
onClose={onClose}
>
{children}
</Modal>
);
};
export const UserCredentials = ({ user }: UserCredentialsProps) => {
const { t } = useTranslation("users");
const { whoAmI } = useWhoAmI();
const { addAlert, addError } = useAlerts();
const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1);
const [open, setOpen] = useState(false);
const [openSaveConfirm, setOpenSaveConfirm] = useState(false);
const [kebabOpen, setKebabOpen] = useState(false);
const adminClient = useAdminClient();
const form = useForm<CredentialsForm>({ defaultValues });
const { control, errors, handleSubmit, register } = form;
const [credentials, setCredentials] = useState<CredentialsForm>();
const [userCredentials, setUserCredentials] = useState<
CredentialRepresentation[]
>([]);
const [selectedCredential, setSelectedCredential] =
useState<CredentialRepresentation>({});
const [isResetPassword, setIsResetPassword] = useState(false);
const [showData, setShowData] = useState(false);
useFetch(
() => adminClient.users.getCredentials({ id: user.id! }),
(credentials) => {
setUserCredentials(credentials);
},
[key]
);
const passwordWatcher = useWatch<CredentialsForm["password"]>({
control,
name: "password",
});
const passwordConfirmationWatcher = useWatch<
CredentialsForm["passwordConfirmation"]
>({
control,
name: "passwordConfirmation",
});
const isNotDisabled =
passwordWatcher !== "" && passwordConfirmationWatcher !== "";
const toggleModal = () => {
setOpen(!open);
};
const toggleConfirmSaveModal = () => {
setOpenSaveConfirm(!openSaveConfirm);
};
const saveUserPassword = async () => {
if (!credentials) {
return;
}
const passwordsMatch =
credentials.password === credentials.passwordConfirmation;
if (!passwordsMatch) {
addAlert(
isResetPassword
? t("resetPasswordNotMatchError")
: t("savePasswordNotMatchError"),
AlertVariant.danger
);
} else {
try {
await adminClient.users.resetPassword({
id: user.id!,
credential: {
temporary: credentials.temporaryPassword,
type: "password",
value: credentials.password,
},
});
refresh();
addAlert(
isResetPassword
? t("resetCredentialsSuccess")
: t("savePasswordSuccess"),
AlertVariant.success
);
setIsResetPassword(false);
setOpenSaveConfirm(false);
} catch (error) {
addError(
isResetPassword ? t("resetPasswordError") : t("savePasswordError"),
error
);
}
}
};
const resetPassword = () => {
setIsResetPassword(true);
setOpen(true);
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("deleteCredentialsConfirmTitle"),
messageKey: t("deleteCredentialsConfirm"),
continueButtonLabel: t("common:delete"),
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.users.deleteCredential({
id: user.id!,
credentialId: selectedCredential.id!,
});
addAlert(t("deleteCredentialsSuccess"), AlertVariant.success);
setKey((key) => key + 1);
} catch (error) {
addError(t("deleteCredentialsError"), error);
}
},
});
const rows = useMemo(() => {
if (!selectedCredential.credentialData) {
return [];
}
const credentialData = JSON.parse(selectedCredential.credentialData);
const locale = whoAmI.getLocale();
return Object.entries(credentialData)
.sort(([a], [b]) => a.localeCompare(b, locale))
.map<[string, string]>(([key, value]) => {
if (typeof value === "string") {
return [key, value];
}
return [key, JSON.stringify(value)];
});
}, [selectedCredential.credentialData]);
return (
<>
{open && (
<Modal
variant={ModalVariant.small}
width={600}
title={
isResetPassword
? `${t("resetPasswordFor")} ${user.username}`
: `${t("setPasswordFor")} ${user.username}`
}
isOpen
onClose={() => {
setIsResetPassword(false);
setOpen(false);
}}
actions={[
<Button
data-testid="okBtn"
key={`confirmBtn-${user.id}`}
variant="primary"
form="userCredentials-form"
onClick={() => {
setOpen(false);
setCredentials(form.getValues());
toggleConfirmSaveModal();
}}
isDisabled={!isNotDisabled}
>
{t("save")}
</Button>,
<Button
data-testid="cancelBtn"
key={`cancelBtn-${user.id}`}
variant="link"
form="userCredentials-form"
onClick={() => {
setIsResetPassword(false);
setOpen(false);
}}
>
{t("cancel")}
</Button>,
]}
>
<Form id="userCredentials-form" isHorizontal>
<FormGroup
name="password"
label={t("password")}
fieldId="password"
helperTextInvalid={t("common:required")}
validated={
errors.password
? ValidatedOptions.error
: ValidatedOptions.default
}
isRequired
>
<div className="kc-password">
<PasswordInput
name="password"
aria-label="password"
ref={register({ required: true })}
/>
</div>
</FormGroup>
<FormGroup
name="passwordConfirmation"
label={
isResetPassword
? t("resetPasswordConfirmation")
: t("passwordConfirmation")
}
fieldId="passwordConfirmation"
helperTextInvalid={t("common:required")}
validated={
errors.passwordConfirmation
? ValidatedOptions.error
: ValidatedOptions.default
}
isRequired
>
<div className="kc-passwordConfirmation">
<PasswordInput
name="passwordConfirmation"
aria-label="passwordConfirm"
ref={register({ required: true })}
/>
</div>
</FormGroup>
<FormGroup
label={t("common:temporaryPassword")}
labelIcon={
<HelpItem
helpText={t("common:temporaryPasswordHelpText")}
forLabel={t("common:temporaryPassword")}
forID="kc-temporaryPasswordSwitch"
/>
}
fieldId="kc-temporaryPassword"
>
{" "}
<Controller
name="temporaryPassword"
defaultValue={true}
control={control}
render={({ onChange, value }) => (
<Switch
className={"kc-temporaryPassword"}
onChange={(value) => onChange(value)}
isChecked={value}
label={t("common:on")}
labelOff={t("common:off")}
/>
)}
></Controller>
</FormGroup>
</Form>
</Modal>
)}
{openSaveConfirm && (
<Modal
variant={ModalVariant.small}
width={600}
title={
isResetPassword
? t("resetPasswordConfirm")
: t("setPasswordConfirm")
}
isOpen
onClose={() => setOpenSaveConfirm(false)}
actions={[
<Button
data-testid="setPasswordBtn"
key={`confirmSaveBtn-${user.id}`}
variant="danger"
form="userCredentials-form"
onClick={() => {
handleSubmit(saveUserPassword)();
}}
>
{isResetPassword ? t("resetPassword") : t("savePassword")}
</Button>,
<Button
data-testid="cancelSetPasswordBtn"
key={`cancelConfirmBtn-${user.id}`}
variant="link"
form="userCredentials-form"
onClick={() => {
setOpenSaveConfirm(false);
}}
>
{t("cancel")}
</Button>,
]}
>
<Text component={TextVariants.h3}>
{isResetPassword
? `${t("resetPasswordConfirmText")} ${user.username} ${t(
"questionMark"
)}`
: `${t("setPasswordConfirmText")} ${user.username} ${t(
"questionMark"
)}`}
</Text>
</Modal>
)}
<DeleteConfirm />
{showData && Object.keys(selectedCredential).length !== 0 && (
<DisplayDialog
titleKey={t("passwordDataTitle")}
onClose={() => {
setShowData(false);
setSelectedCredential({});
}}
>
<Table
aria-label="password-data"
data-testid="password-data-dialog"
variant={TableVariant.compact}
cells={[t("showPasswordDataName"), t("showPasswordDataValue")]}
rows={rows}
>
<TableHeader />
<TableBody />
</Table>
</DisplayDialog>
)}
{userCredentials.length !== 0 ? (
<TableComposable aria-label="password-data-table" variant={"compact"}>
<Thead>
<Tr>
<Th>
<HelpItem
helpText={t("userCredentialsHelpText")}
forLabel={t("userCredentialsHelpTextLabel")}
forID={t(`common:helpLabel`, {
label: t("userCredentialsHelpTextLabel"),
})}
/>
</Th>
<Th>{t("type")}</Th>
<Th>{t("userLabel")}</Th>
<Th>{t("data")}</Th>
<Th />
<Th />
</Tr>
</Thead>
<Tbody>
<Tr key={"key"}>
{userCredentials.map((credential) => (
<>
<Td
draggableRow={{
id: `draggable-row-${credential.id}`,
}}
/>
<Td key={`${credential}`} dataLabel={`columns-${credential}`}>
{credential.type?.charAt(0).toUpperCase()! +
credential.type?.slice(1)}
</Td>
<Td>My Password</Td>
<Td>
<Button
className="kc-showData-btn"
variant="link"
data-testid="showDataBtn"
onClick={() => {
setShowData(true);
setSelectedCredential(credential);
}}
>
{t("showDataBtn")}
</Button>
</Td>
<Td>
<Button
variant="secondary"
data-testid="resetPasswordBtn"
onClick={resetPassword}
>
{t("resetPasswordBtn")}
</Button>
</Td>
<Td>
<Dropdown
isPlain
position={DropdownPosition.right}
toggle={
<KebabToggle onToggle={(open) => setKebabOpen(open)} />
}
isOpen={kebabOpen}
onSelect={() => setSelectedCredential(credential)}
dropdownItems={[
<DropdownItem
key={`delete-dropdown-item-${credential.id}`}
data-testid="deleteDropdownItem"
component="button"
onClick={() => {
toggleDeleteDialog();
setKebabOpen(false);
}}
>
{t("deleteBtn")}
</DropdownItem>,
]}
/>
</Td>
</>
))}
</Tr>
</Tbody>
</TableComposable>
) : (
<ListEmptyState
hasIcon={true}
message={t("noCredentials")}
instructions={t("noCredentialsText")}
primaryActionText={t("setPassword")}
onPrimaryAction={toggleModal}
/>
)}
</>
);
};

View file

@ -27,6 +27,7 @@ import { toUser } from "./routes/User";
import { toUsers } from "./routes/Users"; import { toUsers } from "./routes/Users";
import { UserRoleMapping } from "./UserRoleMapping"; import { UserRoleMapping } from "./UserRoleMapping";
import { UserAttributes } from "./UserAttributes"; import { UserAttributes } from "./UserAttributes";
import { UserCredentials } from "./UserCredentials";
import { useAccess } from "../context/access/Access"; import { useAccess } from "../context/access/Access";
const UsersTabs = () => { const UsersTabs = () => {
@ -185,6 +186,13 @@ const UsersTabs = () => {
> >
<UserAttributes user={user} /> <UserAttributes user={user} />
</Tab> </Tab>
<Tab
eventKey="credentials"
data-testid="credentials"
title={<TabTitleText>{t("common:credentials")}</TabTitleText>}
>
<UserCredentials user={user} />
</Tab>
<Tab <Tab
eventKey="groups" eventKey="groups"
data-testid="user-groups-tab" data-testid="user-groups-tab"

View file

@ -116,5 +116,55 @@ export default {
unlock: "Unlock", unlock: "Unlock",
unlockUsersSuccess: "Any temporarily locked users are now unlocked", unlockUsersSuccess: "Any temporarily locked users are now unlocked",
unlockUsersError: "Could not unlock all users {{error}}", unlockUsersError: "Could not unlock all users {{error}}",
noCredentials: "No credentials",
noCredentialsText:
"This user does not have any credentials. You can set password for this user.",
setPassword: "Set password",
setPasswordFor: "Set password for ",
save: "Save",
cancel: "Cancel",
savePasswordSuccess: "The password has been set successfully.",
savePasswordError: "Error saving password: {{error}}",
savePasswordNotMatchError:
"Error saving password: Password and confirmation does not match.",
credentialType: "Type",
credentialUserLabel: "User Label",
credentialData: "Data",
credentialsList: "Credentials List",
setPasswordConfirm: "Set password?",
setPasswordConfirmText:
"Are you sure you want to set the password for the user",
password: "Password",
passwordConfirmation: "Password confirmation",
resetPasswordConfirmation: "New password confirmation",
questionMark: "?",
savePassword: "Save password",
deleteCredentialsConfirmTitle: "Delete credentials?",
deleteCredentialsConfirm:
"Are you sure you want to delete these users credentials?",
deleteCredentialsSuccess: "The credentials has been deleted successfully.",
deleteCredentialsError: "Error deleting users credentials: {{error}}",
deleteBtn: "Delete",
resetPasswordFor: "Reset password for ",
resetPasswordConfirm: "Reset password?",
resetPasswordConfirmText:
"Are you sure you want to reset the password for the user",
resetPassword: "Reset password",
resetCredentialsSuccess: "The password has been reset successfully.",
resetCredentialsError: "Error resetting users credentials: {{error}}",
resetPasswordNotMatchError:
"Error resetting password: Password and confirmation does not match.",
resetPasswordError: "Error resetting password: {{error}}",
resetPasswordBtn: "Reset password",
showPasswordDataName: "Name",
showPasswordDataValue: "Value",
showDataBtn: "Show data",
userCredentialsHelpText:
"The top level handlers allow you to shift the priority of the credential for the user, the topmost credential having the highest priority. The handlers within one expandable panel allow you to change the visual order of the credentials, the topmost credential will show at the most left.",
userCredentialsHelpTextLabel: "User Credentials Help Text",
type: "Type",
userLabel: "User label",
data: "Data",
passwordDataTitle: "Password data",
}, },
}; };

View file

@ -113,3 +113,30 @@ article.pf-c-card.pf-m-flat.kc-available-idps > div > div > h1 {
.kc-no-providers-text { .kc-no-providers-text {
text-align: center; text-align: center;
} }
.kc-temporaryPassword {
margin: 6px 0 10px 35px;
}
.kc-password, .kc-passwordConfirmation {
width: 355px;
float: right;
}
.pf-c-form__group-label {
width: max-content;
}
.pf-m-error {
display: inline-block;
margin-left: var(--pf-global--spacer--xl);
}
.kc-edit-icon {
color: var(--pf-global--Color--200);
margin-left: 5px;
}
.kc-showData-btn {
padding-left: 0;
}