Skip to content

Commit

Permalink
feat(console): implement account deletion (#5969)
Browse files Browse the repository at this point in the history
* feat(console): implement account deletion

* refactor: remove unused phrases

* chore: add i18n phrases

* refactor: add comments and error handling
  • Loading branch information
gao-sun authored Jun 3, 2024
1 parent 914555e commit 6fbba38
Show file tree
Hide file tree
Showing 26 changed files with 413 additions and 139 deletions.
3 changes: 3 additions & 0 deletions packages/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import AppConfirmModalProvider from './contexts/AppConfirmModalProvider';
import AppDataProvider, { AppDataContext } from './contexts/AppDataProvider';
import { AppThemeProvider } from './contexts/AppThemeProvider';
import TenantsProvider, { TenantsContext } from './contexts/TenantsProvider';
import Toast from './ds-components/Toast';
import useCurrentUser from './hooks/use-current-user';
import initI18n from './i18n/init';

Expand Down Expand Up @@ -86,6 +87,7 @@ function Providers() {
UserScope.Identities,
UserScope.CustomData,
UserScope.Organizations,
UserScope.OrganizationRoles,
PredefinedScope.All,
...conditionalArray(
isCloud && [
Expand All @@ -111,6 +113,7 @@ function Providers() {
>
<AppThemeProvider>
<Helmet titleTemplate={`%s - ${mainTitle}`} defaultTitle={mainTitle} />
<Toast />
<ErrorBoundary>
<LogtoErrorBoundary>
{/**
Expand Down
2 changes: 0 additions & 2 deletions packages/console/src/containers/ConsoleRoutes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import ConsoleContent from '@/containers/ConsoleContent';
import ProtectedRoutes from '@/containers/ProtectedRoutes';
import TenantAccess from '@/containers/TenantAccess';
import { GlobalRoute } from '@/contexts/TenantsProvider';
import Toast from '@/ds-components/Toast';
import useSwrOptions from '@/hooks/use-swr-options';
import Callback from '@/pages/Callback';
import CheckoutSuccessCallback from '@/pages/CheckoutSuccessCallback';
Expand All @@ -23,7 +22,6 @@ function Layout() {
return (
<SWRConfig value={swrOptions}>
<AppBoundary>
<Toast />
<Outlet />
</AppBoundary>
</SWRConfig>
Expand Down
2 changes: 0 additions & 2 deletions packages/console/src/onboarding/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Navigate, type RouteObject, useMatch, useRoutes } from 'react-router-do
import AppLoading from '@/components/AppLoading';
import AppBoundary from '@/containers/AppBoundary';
import { AppThemeContext } from '@/contexts/AppThemeProvider';
import Toast from '@/ds-components/Toast';
import { usePlausiblePageview } from '@/hooks/use-plausible-pageview';

import Topbar from './components/Topbar';
Expand Down Expand Up @@ -74,7 +73,6 @@ export function OnboardingApp() {
return (
<div className={styles.app}>
<AppBoundary>
<Toast />
<Topbar />
<div className={styles.content}>{routes}</div>
</AppBoundary>
Expand Down
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>
);
}
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>
);
}
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>
);
}
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);
}
}
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>
);
}
Loading

0 comments on commit 6fbba38

Please sign in to comment.