-
-
Notifications
You must be signed in to change notification settings - Fork 423
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(console): implement account deletion (#5969)
* feat(console): implement account deletion * refactor: remove unused phrases * chore: add i18n phrases * refactor: add comments and error handling
- Loading branch information
Showing
26 changed files
with
413 additions
and
139 deletions.
There are no files selected for viewing
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
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
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
127 changes: 127 additions & 0 deletions
127
...ages/Profile/containers/DeleteAccountModal/components/DeletionConfirmationModal/index.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,127 @@ | ||
import { type IdTokenClaims, useLogto } from '@logto/react'; | ||
import { TenantRole, getTenantIdFromOrganizationId } from '@logto/schemas'; | ||
import { useContext, useEffect, useState } from 'react'; | ||
import { toast } from 'react-hot-toast'; | ||
import { useTranslation } from 'react-i18next'; | ||
|
||
import AppLoading from '@/components/AppLoading'; | ||
import { TenantsContext } from '@/contexts/TenantsProvider'; | ||
import Button from '@/ds-components/Button'; | ||
import ModalLayout from '@/ds-components/ModalLayout'; | ||
|
||
import * as styles from '../../index.module.scss'; | ||
import FinalConfirmationModal from '../FinalConfirmationModal'; | ||
import TenantsList from '../TenantsList'; | ||
|
||
type RoleMap = { [key in string]?: string[] }; | ||
|
||
/** | ||
* Given a list of organization roles from the user's claims, returns a tenant ID - role names map. | ||
* A user may have multiple roles in the same tenant. | ||
*/ | ||
const getRoleMap = (organizationRoles: string[]) => | ||
organizationRoles.reduce<RoleMap>((accumulator, value) => { | ||
const [organizationId, roleName] = value.split(':'); | ||
|
||
if (!organizationId || !roleName) { | ||
return accumulator; | ||
} | ||
|
||
const tenantId = getTenantIdFromOrganizationId(organizationId); | ||
|
||
if (!tenantId) { | ||
return accumulator; | ||
} | ||
|
||
return { | ||
...accumulator, | ||
[tenantId]: [...(accumulator[tenantId] ?? []), roleName], | ||
}; | ||
}, {}); | ||
|
||
type Props = { | ||
readonly onClose: () => void; | ||
}; | ||
|
||
/** A display component for the account deletion confirmation. */ | ||
export default function DeletionConfirmationModal({ onClose }: Props) { | ||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.profile.delete_account' }); | ||
const [isFinalConfirmationOpen, setIsFinalConfirmationOpen] = useState(false); | ||
const [claims, setClaims] = useState<IdTokenClaims>(); | ||
const { getIdTokenClaims } = useLogto(); | ||
const { tenants } = useContext(TenantsContext); | ||
|
||
useEffect(() => { | ||
const fetchRoleMap = async () => { | ||
setClaims(undefined); | ||
const claims = await getIdTokenClaims(); | ||
|
||
if (!claims) { | ||
toast.error(t('error_occurred')); | ||
onClose(); | ||
return; | ||
} | ||
|
||
setClaims(claims); | ||
}; | ||
|
||
void fetchRoleMap(); | ||
}, [getIdTokenClaims, onClose, t]); | ||
|
||
const roleMap = claims && getRoleMap(claims.organization_roles ?? []); | ||
const tenantsToDelete = tenants.filter(({ id }) => roleMap?.[id]?.includes(TenantRole.Admin)); | ||
const tenantsToQuit = tenants.filter(({ id }) => | ||
tenantsToDelete.every(({ id: tenantId }) => tenantId !== id) | ||
); | ||
|
||
if (!claims) { | ||
return <AppLoading />; | ||
} | ||
|
||
return ( | ||
<ModalLayout | ||
title="profile.delete_account.label" | ||
footer={ | ||
<> | ||
<Button size="large" title="general.cancel" onClick={onClose} /> | ||
<Button | ||
size="large" | ||
type="danger" | ||
title="general.delete" | ||
onClick={() => { | ||
setIsFinalConfirmationOpen(true); | ||
}} | ||
/> | ||
</> | ||
} | ||
> | ||
{isFinalConfirmationOpen && ( | ||
<FinalConfirmationModal | ||
userId={claims.sub} | ||
tenantsToDelete={tenantsToDelete} | ||
tenantsToQuit={tenantsToQuit} | ||
onClose={() => { | ||
setIsFinalConfirmationOpen(false); | ||
}} | ||
/> | ||
)} | ||
<div className={styles.container}> | ||
<p>{t('p.check_information')}</p> | ||
{tenantsToDelete.length > 0 && ( | ||
<TenantsList | ||
description={t('p.has_admin_role', { count: tenantsToDelete.length })} | ||
tenants={tenantsToDelete} | ||
/> | ||
)} | ||
{tenantsToQuit.length > 0 && ( | ||
<TenantsList | ||
description={t('p.quit_tenant', { count: tenantsToQuit.length })} | ||
tenants={tenantsToQuit} | ||
/> | ||
)} | ||
<p>{t('p.remove_all_data')}</p> | ||
<p>{t('p.confirm_information')}</p> | ||
</div> | ||
</ModalLayout> | ||
); | ||
} |
125 changes: 125 additions & 0 deletions
125
...c/pages/Profile/containers/DeleteAccountModal/components/FinalConfirmationModal/index.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,125 @@ | ||
import { useLogto } from '@logto/react'; | ||
import { ResponseError } from '@withtyped/client'; | ||
import { useContext, useState } from 'react'; | ||
import { useTranslation } from 'react-i18next'; | ||
import ReactModal from 'react-modal'; | ||
|
||
import { useCloudApi, useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api'; | ||
import { type TenantResponse } from '@/cloud/types/router'; | ||
import { TenantsContext } from '@/contexts/TenantsProvider'; | ||
import Button from '@/ds-components/Button'; | ||
import ModalLayout from '@/ds-components/ModalLayout'; | ||
import useRedirectUri from '@/hooks/use-redirect-uri'; | ||
import * as modalStyles from '@/scss/modal.module.scss'; | ||
|
||
import * as styles from '../../index.module.scss'; | ||
|
||
type Props = { | ||
readonly userId: string; | ||
readonly tenantsToDelete: readonly TenantResponse[]; | ||
readonly tenantsToQuit: readonly TenantResponse[]; | ||
readonly onClose: () => void; | ||
}; | ||
|
||
/** The final confirmation modal for deletion, and where the deletion process happens. */ | ||
export default function FinalConfirmationModal({ | ||
userId, | ||
tenantsToDelete, | ||
tenantsToQuit, | ||
onClose, | ||
}: Props) { | ||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.profile.delete_account' }); | ||
const { signOut } = useLogto(); | ||
const { removeTenant } = useContext(TenantsContext); | ||
const postSignOutRedirectUri = useRedirectUri('signOut'); | ||
const [isDeleting, setIsDeleting] = useState(false); | ||
const [deletionError, setDeletionError] = useState<Error>(); | ||
const errorRequestId = | ||
deletionError instanceof ResponseError | ||
? deletionError.response.headers.get('logto-cloud-request-id') | ||
: null; | ||
const cloudApi = useCloudApi(); | ||
const authedCloudApi = useAuthedCloudApi(); | ||
const deleteAccount = async () => { | ||
if (isDeleting) { | ||
return; | ||
} | ||
|
||
setIsDeleting(true); | ||
|
||
try { | ||
for (const tenant of tenantsToDelete) { | ||
// eslint-disable-next-line no-await-in-loop | ||
await cloudApi.delete(`/api/tenants/:tenantId`, { | ||
params: { tenantId: tenant.id }, | ||
}); | ||
removeTenant(tenant.id); | ||
} | ||
|
||
for (const tenant of tenantsToQuit) { | ||
// eslint-disable-next-line no-await-in-loop | ||
await authedCloudApi.delete('/api/tenants/:tenantId/members/:userId', { | ||
params: { tenantId: tenant.id, userId }, | ||
}); | ||
removeTenant(tenant.id); | ||
} | ||
|
||
await cloudApi.delete('/api/me'); | ||
await signOut(postSignOutRedirectUri.href); | ||
} catch (error) { | ||
setDeletionError(error instanceof Error ? error : new Error(String(error))); | ||
console.error(error); | ||
} finally { | ||
setIsDeleting(false); | ||
} | ||
}; | ||
|
||
return ( | ||
<ReactModal | ||
shouldCloseOnEsc | ||
isOpen | ||
className={modalStyles.content} | ||
overlayClassName={modalStyles.overlay} | ||
> | ||
{deletionError ? ( | ||
<ModalLayout | ||
title="profile.delete_account.error_occurred" | ||
footer={<Button size="large" title="general.got_it" onClick={onClose} />} | ||
> | ||
<div className={styles.container}> | ||
<p>{t('error_occurred_description')}</p> | ||
<p> | ||
<code>{deletionError.message}</code> | ||
{errorRequestId && ( | ||
<> | ||
<br /> | ||
<code>{t('request_id', { requestId: errorRequestId })}</code> | ||
</> | ||
)} | ||
</p> | ||
<p>{t('try_again_later')}</p> | ||
</div> | ||
</ModalLayout> | ||
) : ( | ||
<ModalLayout | ||
title="profile.delete_account.final_confirmation" | ||
footer={ | ||
<> | ||
<Button size="large" disabled={isDeleting} title="general.cancel" onClick={onClose} /> | ||
<Button | ||
size="large" | ||
disabled={isDeleting} | ||
isLoading={isDeleting} | ||
type="danger" | ||
title="general.delete" | ||
onClick={deleteAccount} | ||
/> | ||
</> | ||
} | ||
> | ||
<div className={styles.container}>{t('about_to_start_deletion')}</div> | ||
</ModalLayout> | ||
)} | ||
</ReactModal> | ||
); | ||
} |
44 changes: 44 additions & 0 deletions
44
...e/src/pages/Profile/containers/DeleteAccountModal/components/TenantsIssuesModal/index.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,44 @@ | ||
import { type LocalePhrase } from '@logto/phrases'; | ||
import { useTranslation } from 'react-i18next'; | ||
|
||
import { type TenantResponse } from '@/cloud/types/router'; | ||
import Button from '@/ds-components/Button'; | ||
import ModalLayout from '@/ds-components/ModalLayout'; | ||
|
||
import * as styles from '../../index.module.scss'; | ||
import TenantsList from '../TenantsList'; | ||
|
||
type Props = { | ||
readonly issues: ReadonlyArray<{ | ||
readonly description: keyof LocalePhrase['translation']['admin_console']['profile']['delete_account']['issues']; | ||
readonly tenants: readonly TenantResponse[]; | ||
}>; | ||
readonly onClose: () => void; | ||
}; | ||
|
||
/** A display component for tenant issues that prevent account deletion. */ | ||
export default function TenantsIssuesModal({ issues, onClose }: Props) { | ||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.profile.delete_account' }); | ||
|
||
return ( | ||
<ModalLayout | ||
title="profile.delete_account.label" | ||
footer={<Button size="large" title="general.got_it" onClick={onClose} />} | ||
> | ||
<div className={styles.container}> | ||
<p>{t('p.has_issue')}</p> | ||
{issues.map( | ||
({ description, tenants }) => | ||
tenants.length > 0 && ( | ||
<TenantsList | ||
key={description} | ||
description={t(`issues.${description}`, { count: tenants.length })} | ||
tenants={tenants} | ||
/> | ||
) | ||
)} | ||
<p>{t('p.after_resolved')}</p> | ||
</div> | ||
</ModalLayout> | ||
); | ||
} |
6 changes: 6 additions & 0 deletions
6
.../src/pages/Profile/containers/DeleteAccountModal/components/TenantsList/index.module.scss
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,6 @@ | ||
.tenantList { | ||
h3 { | ||
font: var(--font-title-3); | ||
color: var(--color-text-secondary); | ||
} | ||
} |
24 changes: 24 additions & 0 deletions
24
.../console/src/pages/Profile/containers/DeleteAccountModal/components/TenantsList/index.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,24 @@ | ||
import { type TenantResponse } from '@/cloud/types/router'; | ||
|
||
import * as styles from './index.module.scss'; | ||
|
||
type Props = { | ||
readonly description: string; | ||
readonly tenants: readonly TenantResponse[]; | ||
}; | ||
|
||
/** A component that displays a list of tenants with their summary information. */ | ||
export default function TenantsList({ description, tenants }: Props) { | ||
return ( | ||
<section className={styles.tenantList}> | ||
<h3>{description}</h3> | ||
<ul> | ||
{tenants.map(({ id, name }) => ( | ||
<li key={id}> | ||
{name} ({id}) | ||
</li> | ||
))} | ||
</ul> | ||
</section> | ||
); | ||
} |
Oops, something went wrong.