Skip to content
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

feat(console): implement account deletion #5969

Merged
merged 4 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading