Skip to content

Commit

Permalink
feat(console): display user password information on user details page (
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun authored Sep 6, 2024
1 parent 27d2c91 commit f150a67
Show file tree
Hide file tree
Showing 24 changed files with 198 additions and 99 deletions.
6 changes: 6 additions & 0 deletions .changeset/brave-horses-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@logto/console": minor
"@logto/phrases": minor
---

display user password information on user details page
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
@use '@/scss/underscore' as _;

.password {
display: flex;
align-items: center;
user-select: none;
border: 1px solid var(--color-divider);
border-radius: 12px;
padding: _.unit(3) _.unit(6);
justify-content: space-between;
font: var(--font-body-2);

.label {
flex: 5;
display: flex;
align-items: center;

.icon {
margin-right: _.unit(3);
color: var(--color-text-secondary);
}
}

.text {
flex: 8;
color: var(--color-text-secondary);
}

.actionButton {
flex: 3;
display: flex;
justify-content: center;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { type UserProfileResponse } from '@logto/schemas';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';

import Key from '@/assets/icons/key.svg?react';
import UserAccountInformation from '@/components/UserAccountInformation';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import modalStyles from '@/scss/modal.module.scss';

import ResetPasswordForm from '../../components/ResetPasswordForm';

import styles from './index.module.scss';

type Props = {
readonly user: UserProfileResponse;
readonly onResetPassword: () => void;
};

function UserPassword({ user, onResetPassword }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { hasPassword = false } = user;

const [isResetPasswordFormOpen, setIsResetPasswordFormOpen] = useState(false);
const [newPassword, setNewPassword] = useState<string>();

// Use a ref to store the initial state of hasPassword to determine which title to show when the password has been reset
const initialHasPassword = useRef(hasPassword);

return (
<>
<div className={styles.password}>
<div className={styles.label}>
<Key className={styles.icon} />
<span>
<DynamicT forKey="user_details.field_password" />
</span>
</div>
<div className={styles.text}>
<DynamicT
forKey={`user_details.${hasPassword ? 'password_already_set' : 'no_password_set'}`}
/>
</div>
<div className={styles.actionButton}>
<Button
title={`general.${hasPassword ? 'reset' : 'generate'}`}
type="text"
size="small"
onClick={() => {
setIsResetPasswordFormOpen(true);
}}
/>
</div>
</div>
<ReactModal
shouldCloseOnEsc
isOpen={isResetPasswordFormOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
setIsResetPasswordFormOpen(false);
}}
>
<ResetPasswordForm
userId={user.id}
hasPassword={hasPassword}
onClose={(password) => {
setIsResetPasswordFormOpen(false);

if (password) {
setNewPassword(password);
onResetPassword();
}
}}
/>
</ReactModal>
{newPassword && (
<UserAccountInformation
title={`user_details.reset_password.${
initialHasPassword.current ? 'reset_complete' : 'generate_complete'
}`}
user={user}
password={newPassword}
passwordLabel={t(
`user_details.reset_password.${
initialHasPassword.current ? 'new_password' : 'password'
}`
)}
onClose={() => {
setNewPassword(undefined);
// Update the initial state to true once the user has acknowledged the new password
// eslint-disable-next-line @silverhand/fp/no-mutation
initialHasPassword.current = true;
}}
/>
)}
</>
);
}

export default UserPassword;
9 changes: 9 additions & 0 deletions packages/console/src/pages/UserDetails/UserSettings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { userDetailsParser } from '../utils';

import PersonalAccessTokens from './PersonalAccessTokens';
import UserMfaVerifications from './UserMfaVerifications';
import UserPassword from './UserPassword';
import UserSocialIdentities from './UserSocialIdentities';
import UserSsoIdentities from './UserSsoIdentities';

Expand Down Expand Up @@ -150,6 +151,14 @@ function UserSettings() {
placeholder={t('users.placeholder_username')}
/>
</FormField>
<FormField title="user_details.field_password">
<UserPassword
user={user}
onResetPassword={() => {
onUserUpdated({ ...user, hasPassword: true });
}}
/>
</FormField>
<FormField title="user_details.field_connectors">
<UserSocialIdentities
userId={user.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import { generateRandomPassword } from '@/utils/password';

type Props = {
readonly userId: string;
readonly hasPassword: boolean;
readonly onClose?: (password?: string) => void;
};

function ResetPasswordForm({ onClose, userId }: Props) {
function ResetPasswordForm({ onClose, userId, hasPassword }: Props) {
const { t } = useTranslation(undefined, {
keyPrefix: 'admin_console',
});
Expand All @@ -29,7 +30,7 @@ function ResetPasswordForm({ onClose, userId }: Props) {
return (
<ConfirmModal
isOpen
title="user_details.reset_password.title"
title={`user_details.reset_password.${hasPassword ? 'reset_title' : 'generate_title'}`}
isLoading={isLoading}
onCancel={() => {
onClose?.();
Expand Down
46 changes: 0 additions & 46 deletions packages/console/src/pages/UserDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import useSWR from 'swr';

import Delete from '@/assets/icons/delete.svg?react';
import Forbidden from '@/assets/icons/forbidden.svg?react';
import Reset from '@/assets/icons/reset.svg?react';
import Shield from '@/assets/icons/shield.svg?react';
import DetailsPage from '@/components/DetailsPage';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
Expand All @@ -22,14 +20,11 @@ import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import modalStyles from '@/scss/modal.module.scss';
import { buildUrl } from '@/utils/url';
import { getUserTitle, getUserSubtitle } from '@/utils/user';

import UserAccountInformation from '../../components/UserAccountInformation';
import SuspendedTag from '../Users/components/SuspendedTag';

import ResetPasswordForm from './components/ResetPasswordForm';
import styles from './index.module.scss';
import { type UserDetailsOutletContext } from './types';

Expand All @@ -41,10 +36,8 @@ function UserDetails() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isResetPasswordFormOpen, setIsResetPasswordFormOpen] = useState(false);
const [isToggleSuspendFormOpen, setIsToggleSuspendFormOpen] = useState(false);
const [isUpdatingSuspendState, setIsUpdatingSuspendState] = useState(false);
const [resetResult, setResetResult] = useState<string>();

// Get user info with user's SSO identities in a single API call.
const { data, error, mutate } = useSWR<UserProfileResponse, RequestError>(
Expand All @@ -59,7 +52,6 @@ function UserDetails() {

useEffect(() => {
setIsDeleteFormOpen(false);
setIsResetPasswordFormOpen(false);
setIsToggleSuspendFormOpen(false);
}, [pathname]);

Expand Down Expand Up @@ -119,13 +111,6 @@ function UserDetails() {
primaryTag={isSuspendedUser && <SuspendedTag />}
identifier={{ name: 'User ID', value: data.id }}
actionMenuItems={[
{
title: 'user_details.reset_password.reset_password',
icon: <Reset />,
onClick: () => {
setIsResetPasswordFormOpen(true);
},
},
{
title: isSuspendedUser
? 'user_details.reactivate_user'
Expand All @@ -145,26 +130,6 @@ function UserDetails() {
},
]}
/>
<ReactModal
shouldCloseOnEsc
isOpen={isResetPasswordFormOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
setIsResetPasswordFormOpen(false);
}}
>
<ResetPasswordForm
userId={data.id}
onClose={(password) => {
setIsResetPasswordFormOpen(false);

if (password) {
setResetResult(password);
}
}}
/>
</ReactModal>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
Expand Down Expand Up @@ -217,17 +182,6 @@ function UserDetails() {
} satisfies UserDetailsOutletContext
}
/>
{resetResult && (
<UserAccountInformation
title="user_details.reset_password.congratulations"
user={data}
password={resetResult}
passwordLabel={t('user_details.reset_password.new_password')}
onClose={() => {
setResetResult(undefined);
}}
/>
)}
</>
)}
</DetailsPage>
Expand Down
4 changes: 2 additions & 2 deletions packages/console/src/pages/UserDetails/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { User, UserProfileResponse } from '@logto/schemas';
import type { UserProfileResponse } from '@logto/schemas';

export type UserDetailsForm = {
primaryEmail: string;
Expand All @@ -13,5 +13,5 @@ export type UserDetailsForm = {
export type UserDetailsOutletContext = {
user: UserProfileResponse;
isDeleting: boolean;
onUserUpdated: (user?: User) => void;
onUserUpdated: (user?: UserProfileResponse) => void;
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ const user_details = {
delete_description: 'لا يمكن التراجع عن هذا الإجراء. سيتم حذف المستخدم نهائيًا.',
deleted: 'تم حذف المستخدم بنجاح',
reset_password: {
reset_password: 'إعادة تعيين كلمة المرور',
title: 'هل أنت متأكد أنك تريد إعادة تعيين كلمة المرور؟',
reset_title: 'هل أنت متأكد أنك تريد إعادة تعيين كلمة المرور؟',
content:
'لا يمكن التراجع عن هذا الإجراء. سيتم إعادة تعيين معلومات تسجيل الدخول الخاصة بالمستخدم.',
congratulations: 'تم إعادة تعيين هذا المستخدم',
reset_complete: 'تم إعادة تعيين هذا المستخدم',
new_password: 'كلمة المرور الجديدة:',
},
tab_settings: 'الإعدادات',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ const user_details = {
'Diese Aktion kann nicht rückgängig gemacht werden. Der Benutzer wird permanent gelöscht.',
deleted: 'Der Benutzer wurde erfolgreich gelöscht',
reset_password: {
reset_password: 'Passwort zurücksetzen',
title: 'Willst du das Passwort wirklich zurücksetzen?',
reset_title: 'Willst du das Passwort wirklich zurücksetzen?',
content:
'Diese Aktion kann nicht rückgängig gemacht werden. Das Anmeldeinformationen werden zurückgesetzt.',
congratulations: 'Der Benutzer wurde erfolgreich zurückgesetzt',
reset_complete: 'Der Benutzer wurde erfolgreich zurückgesetzt',
new_password: 'Neues Passwort:',
},
tab_settings: 'Einstellungen',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ const general = {
delete_field: 'Delete {{field}}',
coming_soon: 'Coming soon',
or: 'Or',
reset: 'Reset',
generate: 'Generate',
};

export default Object.freeze(general);
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ const user_details = {
delete_description: 'This action cannot be undone. It will permanently delete the user.',
deleted: 'The user has been successfully deleted',
reset_password: {
reset_password: 'Reset password',
title: 'Are you sure you want to reset the password?',
content: "This action cannot be undone. This will reset the user's log in information.",
congratulations: 'This user has been reset',
reset_title: 'Are you sure you want to reset the password?',
generate_title: 'Are you sure you want to generate a password?',
content: "This action cannot be undone. This will update the user's sign-in information.",
reset_complete: 'The password has been reset',
generate_complete: 'The password has been generated',
new_password: 'New password:',
password: 'Password:',
},
tab_settings: 'Settings',
tab_roles: 'Roles',
Expand All @@ -28,6 +30,7 @@ const user_details = {
field_email: 'Email address',
field_phone: 'Phone number',
field_username: 'Username',
field_password: 'Password',
field_name: 'Name',
field_avatar: 'Avatar image URL',
field_avatar_placeholder: 'https://your.cdn.domain/avatar.png',
Expand All @@ -41,6 +44,8 @@ const user_details = {
field_sso_connectors: 'Enterprise connections',
custom_data_invalid: 'Custom data must be a valid JSON object',
profile_invalid: 'Profile must be a valid JSON object',
password_already_set: 'Password already set',
no_password_set: 'No password set',
connectors: {
connectors: 'Connectors',
user_id: 'User ID',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ const user_details = {
delete_description: 'Esta acción no se puede deshacer. Eliminará permanentemente al usuario.',
deleted: 'Usuario eliminado con éxito',
reset_password: {
reset_password: 'Restablecer contraseña',
title: '¿Está seguro de que desea restablecer la contraseña?',
reset_title: '¿Está seguro de que desea restablecer la contraseña?',
content:
'Esta acción no se puede deshacer. Esto restablecerá la información de inicio de sesión del usuario.',
congratulations: 'Se ha restablecido la información de inicio de sesión del usuario',
reset_complete: 'Se ha restablecido la información de inicio de sesión del usuario',
new_password: 'Nueva contraseña:',
},
tab_settings: 'Configuración',
Expand Down
Loading

0 comments on commit f150a67

Please sign in to comment.