Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: [<DeleteCommunity communityData={communityData} />],
} as const,
]
: ([] as Subtab[])),
].filter((x): x is Subtab => Boolean(x)) satisfies Subtab[];

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DeletionAudit | null>(null);
const [isLoadingAudit, setIsLoadingAudit] = useState(false);
const [confirmationTitle, setConfirmationTitle] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(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 }),
});
Comment thread
isTravis marked this conversation as resolved.
window.location.href = 'https://www.pubpub.org';
} catch (err: any) {
setError(err?.message || 'Failed to delete community');
setIsDeleting(false);
}
};

return (
<div>
<h5>Delete community</h5>
<Callout intent="danger" icon="warning-sign">
<p>
<b>Deleting a community is permanent and cannot be undone.</b>
</p>
{isLoadingAudit && <Spinner size={20} />}
{audit && (
<div style={{ marginBottom: 15 }}>
<p>
This community contains <b>{audit.totalPubs}</b> pub
{audit.totalPubs !== 1 ? 's' : ''}:
</p>
<ul style={{ margin: '8px 0' }}>
{audit.pubsWithDoi > 0 && (
<li>
<Tag intent="warning" minimal>
{audit.pubsWithDoi}
</Tag>{' '}
pub{audit.pubsWithDoi !== 1 ? 's' : ''} with DOIs — these will
be <b>moved to archive.pubpub.org</b> to preserve the scholarly
record. Their discussions, releases, drafts, and attributions
will be preserved.
</li>
)}
{audit.pubsWithoutDoi > 0 && (
<li>
<Tag intent="danger" minimal>
{audit.pubsWithoutDoi}
</Tag>{' '}
pub{audit.pubsWithoutDoi !== 1 ? 's' : ''} without DOIs — these
will be <b>permanently deleted</b> along with all their
discussions, releases, and metadata.
</li>
)}
</ul>
<p>
All pages, collections, members, and community settings will be
permanently deleted.
</p>
</div>
)}
<p>
Please type <b>{communityData.title}</b> below to confirm.
</p>
<InputField
label={<b>Confirm community title</b>}
value={confirmationTitle}
onChange={(evt) => setConfirmationTitle(evt.target.value)}
/>
{error && (
<Callout intent="danger" style={{ marginBottom: 10 }}>
{error}
</Callout>
)}
<Button
intent="danger"
text="Delete community"
loading={isDeleting}
onClick={handleDelete}
disabled={!canDelete}
/>
</Callout>
</div>
);
};

export default DeleteCommunity;
142 changes: 142 additions & 0 deletions client/containers/Legal/DeleteAccount.tsx
Original file line number Diff line number Diff line change
@@ -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<DeletionAudit | null>(null);
const [isLoadingAudit, setIsLoadingAudit] = useState(false);
const [password, setPassword] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Card>
<h5>Delete account</h5>
<Callout intent="danger" icon="warning-sign">
<p>
<b>Deleting your account is permanent and cannot be undone.</b>
</p>
{isLoadingAudit && <Spinner size={20} />}
{audit && (
<div style={{ marginBottom: 15 }}>
<p>Here is what will happen when you delete your account:</p>
<ul style={{ margin: '8px 0' }}>
{audit.pubAttributionCount > 0 && (
<li>
<Tag minimal>{audit.pubAttributionCount}</Tag> pub attribution
{audit.pubAttributionCount !== 1 ? 's' : ''} will be{' '}
<b>preserved with your name</b> but unlinked from your account.
</li>
)}
{audit.collectionAttributionCount > 0 && (
<li>
<Tag minimal>{audit.collectionAttributionCount}</Tag> collection
attribution
{audit.collectionAttributionCount !== 1 ? 's' : ''} will be{' '}
<b>preserved with your name</b> but unlinked from your account.
</li>
)}
{audit.discussionCount > 0 && (
<li>
<Tag minimal>{audit.discussionCount}</Tag> discussion
{audit.discussionCount !== 1 ? 's' : ''} you started will be{' '}
<b>anonymized</b> (content preserved, shown as "Deleted User").
</li>
)}
{audit.threadCommentCount > 0 && (
<li>
<Tag minimal>{audit.threadCommentCount}</Tag> comment
{audit.threadCommentCount !== 1 ? 's' : ''} you wrote will be{' '}
<b>anonymized</b> (content preserved, shown as "Deleted User").
</li>
)}
{audit.releaseCount > 0 && (
<li>
<Tag minimal>{audit.releaseCount}</Tag> release
{audit.releaseCount !== 1 ? 's' : ''} you created will be
preserved.
</li>
)}
{audit.membershipCount > 0 && (
<li>
<Tag minimal>{audit.membershipCount}</Tag> membership
{audit.membershipCount !== 1 ? 's' : ''} will be removed.
</li>
)}
</ul>
</div>
)}
<p>Enter your password to confirm account deletion.</p>
<InputField
label={<b>Password</b>}
type="password"
value={password}
onChange={(evt) => setPassword(evt.target.value)}
/>
{error && (
<Callout intent="danger" style={{ marginBottom: 10 }}>
{error}
</Callout>
)}
<Button
intent="danger"
text="Permanently delete my account"
loading={isDeleting}
onClick={handleDelete}
disabled={!password}
/>
</Callout>
</Card>
);
};

export default DeleteAccount;
28 changes: 3 additions & 25 deletions client/containers/Legal/PrivacySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import AccountSecuritySettings from 'components/AccountSecuritySettings';
import UserNotificationPreferences from 'components/UserNotifications/UserNotificationPreferences';
import { usePageContext } from 'utils/hooks';

import DeleteAccount from './DeleteAccount';

type PrivacySettingsProps = {
integrations: types.Integration[];
userEmail: string;
Expand All @@ -26,15 +28,6 @@ Hello.
I am writing to request an export of any PubPub account data associated with this email address.
`;

const deleteEmailBody = `
Hello.
%0D%0A%0D%0A
I am writing to request that the PubPub account associated with this email address, and all%20
data associated with that account, be deleted.
%0D%0A%0D%0A
I understand that this action may be irreversible.
`;

const possibleIntegrations = [
{
title: 'Zotero',
Expand Down Expand Up @@ -162,22 +155,7 @@ const PrivacySettings = (props: PrivacySettingsProps) => {
</Card>
)}
<AccountSecuritySettings userEmail={props.userEmail} />
<Card>
<h5>Account deletion</h5>
<p>
You can request that we completely delete your PubPub account using the
button below. If you have left comments on notable Pubs, we may reserve
the right to continue to display them based on the academic research
exception to GDPR.
</p>
<AnchorButton
intent="danger"
target="_blank"
href={`mailto:privacy@pubpub.org?subject=Account+deletion+request&body=${deleteEmailBody.trim()}`}
>
Request account deletion
</AnchorButton>
</Card>
<DeleteAccount />
</React.Fragment>
)}
</div>
Expand Down
26 changes: 26 additions & 0 deletions client/containers/Pub/PubDocument/PubArchiveNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';

import { Callout } from '@blueprintjs/core';

import { usePageContext } from 'utils/hooks';

import './pubArchiveNotice.scss';

const PubArchiveNotice = () => {
const { communityData } = usePageContext();

if (!communityData.isArchiveCommunity) {
return null;
}

return (
<Callout icon="archive" intent="primary" className="pub-archive-notice-component">
<p>
This publication's community has been removed. This page is maintained to preserve
the scholarly record.
</p>
</Callout>
);
};

export default PubArchiveNotice;
2 changes: 2 additions & 0 deletions client/containers/Pub/PubDocument/PubDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import ContributorsListCondensed from '../../../components/ContributorsListConde
import { usePubContext } from '../pubHooks';
import { usePermalinkOnMount } from '../usePermalinkOnMount';
import { usePubHrefs } from '../usePubHrefs';
import PubArchiveNotice from './PubArchiveNotice';
import PubBody from './PubBody';
import PubBottom from './PubBottom/PubBottom';
import PubFileImport from './PubFileImport';
Expand Down Expand Up @@ -64,6 +65,7 @@ const PubDocument = () => {
)}
<div className="pub-grid">
<div className="main-content" ref={mainContentRef}>
<PubArchiveNotice />
<PubMaintenanceNotice pubData={pubData} />
{!isReviewingPub && (
<PubHistoricalNotice pubData={pubData} historyData={historyData} />
Expand Down
Loading
Loading