diff --git a/client/containers/DashboardSettings/CommunitySettings/CommunitySettings.tsx b/client/containers/DashboardSettings/CommunitySettings/CommunitySettings.tsx index 0a46c9a85b..ac0d1eaad1 100644 --- a/client/containers/DashboardSettings/CommunitySettings/CommunitySettings.tsx +++ b/client/containers/DashboardSettings/CommunitySettings/CommunitySettings.tsx @@ -16,6 +16,7 @@ import AnalyticsSettings from './AnalyticsSettings'; import BasicSettings from './BasicSettings'; import CommunityAdminSettings from './CommunityAdminSettings'; import CommunityOrCollectionLevelPubSettings from './CommunityOrCollectionLevelPubSettings'; +import DeleteCommunity from './DeleteCommunity'; import FooterSettings from './FooterSettings'; import HeaderSettings from './HeaderSettings'; import HomepageBannerSettings from './HomepageBannerSettings'; @@ -178,6 +179,18 @@ const CommunitySettings = (props: Props) => { } as const, ] : ([] as Subtab[])), + ...(pageContext.scopeData.activePermissions.canAdminCommunity || + pageContext.scopeData.activePermissions.isSuperAdmin + ? [ + { + id: 'danger-zone', + title: 'Danger zone', + icon: 'warning-sign', + hideSaveButton: true, + sections: [], + } as const, + ] + : ([] as Subtab[])), ].filter((x): x is Subtab => Boolean(x)) satisfies Subtab[]; return ( diff --git a/client/containers/DashboardSettings/CommunitySettings/DeleteCommunity.tsx b/client/containers/DashboardSettings/CommunitySettings/DeleteCommunity.tsx new file mode 100644 index 0000000000..944ee0baeb --- /dev/null +++ b/client/containers/DashboardSettings/CommunitySettings/DeleteCommunity.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { Button, Callout, Classes, Spinner, Tag } from '@blueprintjs/core'; + +import { apiFetch } from 'client/utils/apiFetch'; +import { InputField } from 'components'; + +type DeletionAudit = { + communityId: string; + communityTitle: string; + communitySubdomain: string; + totalPubs: number; + pubsWithDoi: number; + pubsWithReleases: number; + pubsWithoutDoi: number; +}; + +type Props = { + communityData: { + id: string; + title: string; + }; +}; + +const DeleteCommunity = (props: Props) => { + const { communityData } = props; + const [audit, setAudit] = useState(null); + const [isLoadingAudit, setIsLoadingAudit] = useState(false); + const [confirmationTitle, setConfirmationTitle] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + const loadAudit = useCallback(async () => { + setIsLoadingAudit(true); + try { + const result = await apiFetch.get(`/api/communities/${communityData.id}/deletionAudit`); + setAudit(result); + } catch (err: any) { + setError(err?.message || 'Failed to load deletion audit'); + } finally { + setIsLoadingAudit(false); + } + }, [communityData.id]); + + useEffect(() => { + loadAudit(); + }, [loadAudit]); + + const normalizedConfirmation = confirmationTitle.toLowerCase().trim().replace(/\s+/g, ' '); + const normalizedTitle = communityData.title.toLowerCase().trim().replace(/\s+/g, ' '); + const canDelete = normalizedConfirmation === normalizedTitle; + + const handleDelete = async () => { + setIsDeleting(true); + setError(null); + try { + await apiFetch('/api/communities/' + communityData.id, { + method: 'DELETE', + body: JSON.stringify({ confirmationTitle: communityData.title }), + }); + window.location.href = 'https://www.pubpub.org'; + } catch (err: any) { + setError(err?.message || 'Failed to delete community'); + setIsDeleting(false); + } + }; + + return ( +
+
Delete community
+ +

+ Deleting a community is permanent and cannot be undone. +

+ {isLoadingAudit && } + {audit && ( +
+

+ This community contains {audit.totalPubs} pub + {audit.totalPubs !== 1 ? 's' : ''}: +

+
    + {audit.pubsWithDoi > 0 && ( +
  • + + {audit.pubsWithDoi} + {' '} + pub{audit.pubsWithDoi !== 1 ? 's' : ''} with DOIs — these will + be moved to archive.pubpub.org to preserve the scholarly + record. Their discussions, releases, drafts, and attributions + will be preserved. +
  • + )} + {audit.pubsWithoutDoi > 0 && ( +
  • + + {audit.pubsWithoutDoi} + {' '} + pub{audit.pubsWithoutDoi !== 1 ? 's' : ''} without DOIs — these + will be permanently deleted along with all their + discussions, releases, and metadata. +
  • + )} +
+

+ All pages, collections, members, and community settings will be + permanently deleted. +

+
+ )} +

+ Please type {communityData.title} below to confirm. +

+ Confirm community title} + value={confirmationTitle} + onChange={(evt) => setConfirmationTitle(evt.target.value)} + /> + {error && ( + + {error} + + )} +
+ ); +}; + +export default DeleteCommunity; diff --git a/client/containers/Legal/DeleteAccount.tsx b/client/containers/Legal/DeleteAccount.tsx new file mode 100644 index 0000000000..0fc28885cb --- /dev/null +++ b/client/containers/Legal/DeleteAccount.tsx @@ -0,0 +1,142 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { Button, Callout, Card, Spinner, Tag } from '@blueprintjs/core'; +import SHA3 from 'crypto-js/sha3'; + +import { apiFetch } from 'client/utils/apiFetch'; +import { InputField } from 'components'; + +type DeletionAudit = { + userId: string; + fullName: string; + email: string; + pubAttributionCount: number; + collectionAttributionCount: number; + discussionCount: number; + threadCommentCount: number; + membershipCount: number; + releaseCount: number; +}; + +const DeleteAccount = () => { + const [audit, setAudit] = useState(null); + const [isLoadingAudit, setIsLoadingAudit] = useState(false); + const [password, setPassword] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + const loadAudit = useCallback(async () => { + setIsLoadingAudit(true); + try { + const result = await apiFetch.get('/api/account/deletionAudit'); + setAudit(result); + } catch (err: any) { + setError(err?.message || 'Failed to load account deletion audit'); + } finally { + setIsLoadingAudit(false); + } + }, []); + + useEffect(() => { + loadAudit(); + }, [loadAudit]); + + const handleDelete = async () => { + if (!password) return; + setIsDeleting(true); + setError(null); + try { + const hashedPassword = SHA3(password).toString(); + await apiFetch('/api/account', { + method: 'DELETE', + body: JSON.stringify({ password: hashedPassword }), + }); + window.location.href = 'https://www.pubpub.org'; + } catch (err: any) { + setError(err?.message || 'Failed to delete account'); + setIsDeleting(false); + } + }; + + return ( + +
Delete account
+ +

+ Deleting your account is permanent and cannot be undone. +

+ {isLoadingAudit && } + {audit && ( +
+

Here is what will happen when you delete your account:

+
    + {audit.pubAttributionCount > 0 && ( +
  • + {audit.pubAttributionCount} pub attribution + {audit.pubAttributionCount !== 1 ? 's' : ''} will be{' '} + preserved with your name but unlinked from your account. +
  • + )} + {audit.collectionAttributionCount > 0 && ( +
  • + {audit.collectionAttributionCount} collection + attribution + {audit.collectionAttributionCount !== 1 ? 's' : ''} will be{' '} + preserved with your name but unlinked from your account. +
  • + )} + {audit.discussionCount > 0 && ( +
  • + {audit.discussionCount} discussion + {audit.discussionCount !== 1 ? 's' : ''} you started will be{' '} + anonymized (content preserved, shown as "Deleted User"). +
  • + )} + {audit.threadCommentCount > 0 && ( +
  • + {audit.threadCommentCount} comment + {audit.threadCommentCount !== 1 ? 's' : ''} you wrote will be{' '} + anonymized (content preserved, shown as "Deleted User"). +
  • + )} + {audit.releaseCount > 0 && ( +
  • + {audit.releaseCount} release + {audit.releaseCount !== 1 ? 's' : ''} you created will be + preserved. +
  • + )} + {audit.membershipCount > 0 && ( +
  • + {audit.membershipCount} membership + {audit.membershipCount !== 1 ? 's' : ''} will be removed. +
  • + )} +
+
+ )} +

Enter your password to confirm account deletion.

+ Password} + type="password" + value={password} + onChange={(evt) => setPassword(evt.target.value)} + /> + {error && ( + + {error} + + )} +