Skip to content

Facilitate 'Ask Password' Email Resend and Alter 'Reset Password' Button Logic #7992

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
1 change: 1 addition & 0 deletions features/admin.core.v1/store/reducers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const commonConfigReducerInitialState: CommonConfigReducerStateInterface<
publicCertificates: "",
remoteLogging: "",
requestPathAuthenticators: "",
resendCode: "",
resourceTypes: "",
roles: "",
rolesV2: "",
Expand Down
58 changes: 58 additions & 0 deletions features/admin.users.v1/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,61 @@ export const terminateAllUserSessions = (userId: string): Promise<AxiosResponse>
error.config);
});
};

/**
* Interface for the resend code request payload.
*/
export interface ResendCodeRequest {
user: {
username: string;
realm: string;
};
properties: Array<{
key: string;
value: string;
}>;
}

/**
* Resends the verification code for the for recovery-related scenarios.
*
* @param data - The request payload containing user information and properties.
* @returns A promise containing the response.
* @throws `IdentityAppsApiException` if the request fails or if the response status is not as expected.
*/
export const resendCode = (data: ResendCodeRequest): Promise<any> => {

const requestConfig: RequestConfigInterface = {
data,
headers: {
"Access-Control-Allow-Origin": store.getState().config.deployment.clientHost,
"Content-Type": "application/json"
},
method: HttpMethods.POST,
url: store.getState().config.endpoints.resendCode
};

return httpClient(requestConfig)
.then((response: AxiosResponse) => {
if (response.status !== 201) {
throw new IdentityAppsApiException(
UserManagementConstants.RESEND_CODE_REQUEST_ERROR,
null,
response.status,
response.request,
response,
response.config);
}

return Promise.resolve(response.data);
})
.catch((error: AxiosError) => {
throw new IdentityAppsApiException(
UserManagementConstants.RESEND_CODE_REQUEST_ERROR,
error.stack,
error.code,
error.request,
error.response,
error.config);
});
};
68 changes: 51 additions & 17 deletions features/admin.users.v1/components/user-change-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ interface ChangePasswordPropsInterface extends TestableComponentInterface {
* Handles force password reset trigger.
*/
handleForcePasswordResetTrigger?: () => void;
/**
* Flag to identify if this is a password reset operation.
* When false, it indicates newly setting password (usage : In ask password flow).
*/
isResetPassword?: boolean;
}

/**
Expand All @@ -103,6 +108,7 @@ export const ChangePasswordComponent: FunctionComponent<ChangePasswordPropsInter
handleCloseChangePasswordModal,
connectorProperties,
handleForcePasswordResetTrigger,
isResetPassword = true,
[ "data-testid" ]: testId
} = props;

Expand Down Expand Up @@ -585,11 +591,15 @@ export const ChangePasswordComponent: FunctionComponent<ChangePasswordPropsInter
updateUserInfo(user.id, data).then(() => {
onAlertFired({
description: t(
"user:profile.notifications.changeUserPassword.success.description"
isResetPassword
? "user:profile.notifications.changeUserPassword.success.description"
: "user:profile.notifications.setUserPassword.success.description"
),
level: AlertLevels.SUCCESS,
message: t(
"user:profile.notifications.changeUserPassword.success.message"
isResetPassword
? "user:profile.notifications.changeUserPassword.success.message"
: "user:profile.notifications.setUserPassword.success.message"
)
});
handleCloseChangePasswordModal();
Expand All @@ -599,22 +609,35 @@ export const ChangePasswordComponent: FunctionComponent<ChangePasswordPropsInter
.catch((error: any) => {
if (error.response && error.response.data && error.response.data.detail) {
onAlertFired({
description: t("user:profile.notifications.changeUserPassword.error.description",
{ description: error.response.data.detail }),
description: t(
isResetPassword
? "user:profile.notifications.changeUserPassword.error.description"
: "user:profile.notifications.setUserPassword.error.description",
{ description: error.response.data.detail }
),
level: AlertLevels.ERROR,
message: t("user:profile.notifications.changeUserPassword.error." +
"message")
message: t(
isResetPassword
? "user:profile.notifications.changeUserPassword.error.message"
: "user:profile.notifications.setUserPassword.error.message"
)
});

return;
}

onAlertFired({
description: t("user:profile.notifications.changeUserPassword" +
".genericError.description"),
description: t(
isResetPassword
? "user:profile.notifications.changeUserPassword.genericError.description"
: "user:profile.notifications.setUserPassword.genericError.description"
),
level: AlertLevels.ERROR,
message: t("user:profile.notifications.changeUserPassword.genericError." +
"message")
message: t(
isResetPassword
? "user:profile.notifications.changeUserPassword.genericError.message"
: "user:profile.notifications.setUserPassword.genericError.message"
)
});
handleCloseChangePasswordModal();
handleModalClose();
Expand All @@ -629,7 +652,7 @@ export const ChangePasswordComponent: FunctionComponent<ChangePasswordPropsInter
* configured in the server.
*/
const resolveModalContent = () => {
if (governanceConnectorProperties?.length > 1) {
if (isResetPassword && governanceConnectorProperties?.length > 1) {
return (
<>
<Grid.Row>
Expand Down Expand Up @@ -669,10 +692,17 @@ export const ChangePasswordComponent: FunctionComponent<ChangePasswordPropsInter
{ passwordFormFields() }
<Grid.Row>
<Grid.Column mobile={ 16 } tablet={ 16 } computer={ 14 }>
<Message
type="warning"
content={ t("user:modals.changePasswordModal.message") }
/>
{ isResetPassword ? (
<Message
type="warning"
content={ t("user:modals.changePasswordModal.message") }
/>
) : (
<Message
type="warning"
content={ t("user:modals.setPasswordModal.message") }
/>
) }
</Grid.Column>
</Grid.Row>
</>
Expand All @@ -687,7 +717,9 @@ export const ChangePasswordComponent: FunctionComponent<ChangePasswordPropsInter
size="tiny"
>
<Modal.Header>
{ t("user:modals.changePasswordModal.header") }
{ isResetPassword
? t("user:modals.changePasswordModal.header")
: t("user:modals.setPasswordModal.header") }
</Modal.Header>
<Modal.Content>
<Forms
Expand Down Expand Up @@ -717,7 +749,9 @@ export const ChangePasswordComponent: FunctionComponent<ChangePasswordPropsInter
disabled={ isSubmitting }
onClick={ () => setTriggerSubmit() }
>
{ t("user:modals.changePasswordModal.button") }
{ isResetPassword
? t("user:modals.changePasswordModal.button")
: t("user:modals.setPasswordModal.button") }
</PrimaryButton>
<LinkButton
data-testid={ `${ testId }-cancel-button` }
Expand Down
141 changes: 125 additions & 16 deletions features/admin.users.v1/components/user-profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@ import { useDispatch, useSelector } from "react-redux";
import { Dispatch } from "redux";
import { Button, CheckboxProps, Divider, DropdownItemProps, Form, Grid, Icon, Input } from "semantic-ui-react";
import { ChangePasswordComponent } from "./user-change-password";
import { updateUserInfo } from "../api";
import { ResendCodeRequest, resendCode, updateUserInfo } from "../api";
import {
ACCOUNT_LOCK_REASON_MAP,
ACCOUNT_LOCK_REASON_TO_RECOVERY_SCENARIO_MAP,
AccountLockedReason,
AdminAccountTypes,
CONNECTOR_PROPERTY_TO_CONFIG_STATUS_MAP,
LocaleJoiningSymbol,
Expand Down Expand Up @@ -1546,21 +1548,42 @@ export const UserProfile: FunctionComponent<UserProfilePropsInterface> = (
user.userName !== adminUsername
) ? (
<Show when={ featureConfig?.users?.scopes?.update }>
<DangerZone
data-testid={ `${ testId }-change-password` }
actionTitle={ t("user:editUser." +
"dangerZoneGroup.passwordResetZone.actionTitle") }
header={ t("user:editUser." +
"dangerZoneGroup.passwordResetZone.header") }
subheader={ t("user:editUser." +
"dangerZoneGroup.passwordResetZone.subheader") }
onActionClick={ (): void => {
setOpenChangePasswordModal(true);
} }
isButtonDisabled={ accountLocked }
buttonDisableHint={ t("user:editUser." +
"dangerZoneGroup.passwordResetZone.buttonHint") }
/>
{ isResetPassword() ? (
<DangerZone
data-testid={ `${ testId }-change-password` }
actionTitle={ t("user:editUser." +
"dangerZoneGroup.passwordResetZone.actionTitle") }
header={ t("user:editUser." +
"dangerZoneGroup.passwordResetZone.header") }
subheader={ t("user:editUser." +
"dangerZoneGroup.passwordResetZone.subheader") }
onActionClick={ (): void => {
setOpenChangePasswordModal(true);
} }
isButtonDisabled={
accountLocked &&
// eslint-disable-next-line max-len
accountLockedReason !== AccountLockedReason.PENDING_ADMIN_FORCED_USER_PASSWORD_RESET &&
// eslint-disable-next-line max-len
accountLockedReason !== AccountLockedReason.MAX_ATTEMPTS_EXCEEDED
}
buttonDisableHint={ t("user:editUser." +
"dangerZoneGroup.passwordResetZone.buttonHint") }
/>
) : (
<DangerZone
data-testid={ `${ testId }-set-password` }
actionTitle={ t("user:editUser." +
"dangerZoneGroup.passwordSetZone.actionTitle") }
header={ t("user:editUser." +
"dangerZoneGroup.passwordSetZone.header") }
subheader={ t("user:editUser." +
"dangerZoneGroup.passwordSetZone.subheader") }
onActionClick={ (): void => {
setOpenChangePasswordModal(true);
} }
/>
) }
</Show>
) : null
}
Expand Down Expand Up @@ -2636,13 +2659,98 @@ export const UserProfile: FunctionComponent<UserProfilePropsInterface> = (
return "";
};

/**
* Determines which password changing option to display.
*
* Returns true if the "Force Password Reset" option should be displayed.
* This indicates that the account is not in the "PENDING_ASK_PASSWORD" state,
* meaning the user already has an existing password and the admin can force a password reset
* if needed.
*
* Returns false if the "Set Password" option should be displayed.
* This occurs when the account is in the "PENDING_ASK_PASSWORD" state, indicating that
* the user has not yet set a password and the admin can set a new password if needed.
*
* @returns True to display "Force Password Reset"; false to display "Set Password".
*/
const isResetPassword = (): boolean => accountLockedReason !== AccountLockedReason.PENDING_ASK_PASSWORD;


/**
* Determines whether the "Resend" link should be displayed.
*
* The resend option is shown when:
* - The account is in the "PENDING_ASK_PASSWORD" state,
* indicating an initial password setup link has been sent.
* - The account is in the "PENDING_ADMIN_FORCED_USER_PASSWORD_RESET" state,
* indicating that an admin-forced password reset has been sent.
*
* @returns True if the resend link should be shown; false otherwise.
*/
const showResendLink: boolean = accountLockedReason === AccountLockedReason.PENDING_ASK_PASSWORD ||
accountLockedReason === AccountLockedReason.PENDING_ADMIN_FORCED_USER_PASSWORD_RESET;


const handleResendCode = async (accountLockedReason: string) => {
setIsSubmitting(true);

try {
// Map lock reason to recovery scenario
const recoveryScenario: string = ACCOUNT_LOCK_REASON_TO_RECOVERY_SCENARIO_MAP[accountLockedReason];

if (!recoveryScenario) {
throw new Error("Invalid recovery scenario.");
}

const requestData: ResendCodeRequest = {
properties: [
{
key: "RecoveryScenario",
value: recoveryScenario
}
],
user: {
realm: user[ SCIMConfigs.scim.systemSchema ]?.userSource,
username: user?.userName
}
};

await resendCode(requestData);
onAlertFired({
description: t("user:profile.notifications.resendCode.success.description"),
level: AlertLevels.SUCCESS,
message: t("user:profile.notifications.resendCode.success.message")
});
} catch (error: any) {
onAlertFired({
description: error.response?.data?.description ||
t("user:profile.notifications.resendCode.genericError.description"),
level: AlertLevels.ERROR,
message: t("user:profile.notifications.resendCode.genericError.message")
});
} finally {
setIsSubmitting(false);
}
};

return (
!isReadOnlyUserStoresLoading && !isEmpty(profileInfo)
? (<>
{
(accountLocked || accountDisabled) && (
<Alert severity="warning">
{ t(resolveUserAccountLockedReason()) }
{ showResendLink && (
<>
&nbsp;
<a
className="link pointing"
onClick={ () => handleResendCode(accountLockedReason) }
>
{ t("user:resendCode.resend") }
</a>
</>
) }
</Alert>
)
}
Expand Down Expand Up @@ -2928,6 +3036,7 @@ export const UserProfile: FunctionComponent<UserProfilePropsInterface> = (
onAlertFired={ onAlertFired }
user={ user }
handleUserUpdate={ handleUserUpdate }
isResetPassword={ isResetPassword() }
/>
</>)
: <ContentLoader dimmer/>
Expand Down
1 change: 1 addition & 0 deletions features/admin.users.v1/configs/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const getUsersResourceEndpoints = (serverHost: string): UsersResourceEndp
guests: `${ serverHost }/api/server/v1/guests/invite`,
guestsList: `${ serverHost }/api/server/v1/guests/invitations`,
me: `${serverHost}/scim2/Me`,
resendCode: `${serverHost}/api/identity/user/v1.0/resend-code`,
schemas: `${ serverHost }/scim2/Schemas`,
userSessions: `${ serverHost }/api/users/v1/{0}/sessions`,
userStores: `${ serverHost }/api/server/v1/userstores`,
Expand Down
Loading
Loading