Skip to content

Commit abf9561

Browse files
committed
feat: user deletion functionality
1 parent 590d73e commit abf9561

File tree

11 files changed

+262
-15
lines changed

11 files changed

+262
-15
lines changed

api/resolvers/user.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'crypto'
12
import { readFile } from 'fs/promises'
23
import { join, resolve } from 'path'
34
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
@@ -656,6 +657,86 @@ export default {
656657
},
657658

658659
Mutation: {
660+
deleteAccount: async (parent, { deleteContent, confirmation }, { me, models }) => {
661+
if (!me) {
662+
throw new GqlAuthenticationError()
663+
}
664+
665+
if (confirmation !== 'DELETE MY ACCOUNT') {
666+
throw new GqlInputError('incorrect confirmation text')
667+
}
668+
669+
return await models.$transaction(async (tx) => {
670+
const user = await tx.user.findUnique({
671+
where: { id: me.id },
672+
select: {
673+
msats: true,
674+
mcredits: true,
675+
name: true
676+
}
677+
})
678+
679+
if ((user.msats + user.mcredits) > 0) {
680+
throw new GqlInputError('please withdraw your balance before deleting your account')
681+
}
682+
683+
// If deleteContent is true, replace content with hash
684+
if (deleteContent) {
685+
// Get all items for this user
686+
const items = await tx.item.findMany({
687+
where: { userId: me.id },
688+
select: { id: true, title: true, text: true, url: true }
689+
})
690+
691+
// Update each item with hashed content
692+
for (const item of items) {
693+
const originalContent = JSON.stringify({
694+
title: item.title,
695+
text: item.text,
696+
url: item.url
697+
})
698+
699+
const hash = createHash('sha256').update(originalContent).digest('hex')
700+
const deletedContent = `[deleted] ${hash}`
701+
702+
await tx.item.update({
703+
where: { id: item.id },
704+
data: {
705+
title: item.title ? deletedContent : null,
706+
text: item.text ? deletedContent : null,
707+
url: item.url ? deletedContent : null
708+
}
709+
})
710+
}
711+
}
712+
713+
// Create deletion timestamp and hash the old username with it
714+
const deletionTimestamp = new Date()
715+
const usernameHashInput = `${user.name}${deletionTimestamp.toISOString()}`
716+
const hashedUsername = createHash('sha256').update(usernameHashInput).digest('hex')
717+
718+
// Mark user as deleted and anonymize data
719+
await tx.user.update({
720+
where: { id: me.id },
721+
data: {
722+
deletedAt: deletionTimestamp,
723+
name: hashedUsername,
724+
email: null,
725+
emailVerified: null,
726+
emailHash: null,
727+
image: null,
728+
pubkey: null,
729+
apiKeyHash: null,
730+
nostrPubkey: null,
731+
nostrAuthPubkey: null,
732+
twitterId: null,
733+
githubId: null
734+
}
735+
})
736+
737+
return true
738+
})
739+
},
659740
disableFreebies: async (parent, args, { me, models }) => {
660741
if (!me) {
661742
throw new GqlAuthenticationError()

api/ssrApollo.js

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth'
1919

2020
export default async function getSSRApolloClient ({ req, res, me = null }) {
2121
const session = req && await getServerSession(req, res, getAuthOptions(req))
22+
23+
// If there's a session, check if the user is deleted
24+
let meUser = me
25+
if (session?.user) {
26+
const user = await models.user.findUnique({
27+
where: { id: parseInt(session.user.id) }, // Convert string to int
28+
select: { deletedAt: true }
29+
})
30+
31+
// If the user is deleted, don't pass the session to the context
32+
if (user?.deletedAt) {
33+
meUser = null
34+
} else {
35+
meUser = session.user
36+
}
37+
}
38+
2239
const client = new ApolloClient({
2340
ssrMode: true,
2441
link: new SchemaLink({
@@ -28,9 +45,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
2845
}),
2946
context: {
3047
models,
31-
me: session
32-
? session.user
33-
: me,
48+
me: meUser,
3449
lnd,
3550
search
3651
}
@@ -160,6 +175,18 @@ export function getGetServerSideProps (
160175
me = null
161176
}
162177

178+
// Check if the user account is deleted
179+
if (me) {
180+
const user = await models.user.findUnique({
181+
where: { id: parseInt(me.id) }, // Convert string to int
182+
select: { deletedAt: true }
183+
})
184+
185+
if (user?.deletedAt) {
186+
me = null
187+
}
188+
}
189+
163190
if (authRequired && !me) {
164191
let callback = process.env.NEXT_PUBLIC_URL + req.url
165192
// On client-side routing, the callback is a NextJS URL

api/typeDefs/user.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export default gql`
5656
generateApiKey(id: ID!): String
5757
deleteApiKey(id: ID!): User
5858
disableFreebies: Boolean
59+
deleteAccount(deleteContent: Boolean!, confirmation: String!): Boolean
5960
}
6061
6162
type User {
@@ -70,6 +71,7 @@ export default gql`
7071
bioId: Int
7172
photoId: Int
7273
since: Int
74+
deletedAt: Date
7375
7476
"""
7577
this is only returned when we sort stackers by value

components/item-info.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,14 @@ export default function ItemInfo ({
132132
<span> \ </span>
133133
<span>
134134
{showUser &&
135-
<Link href={`/${item.user.name}`}>
136-
<UserPopover name={item.user.name}>@{item.user.name}</UserPopover>
137-
<Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} />
138-
{embellishUser}
139-
</Link>}
135+
(item.user.deletedAt
136+
? <span className='text-muted'>[deleted]</span>
137+
: <Link href={`/${item.user.name}`}>
138+
<UserPopover name={item.user.name}>@{item.user.name}</UserPopover>
139+
<Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} />
140+
{embellishUser}
141+
</Link>
142+
)}
140143
<span> </span>
141144
<Link href={`/items/${item.id}`} title={item.invoicePaidAt || item.createdAt} className='text-reset' suppressHydrationWarning>
142145
{timeSince(new Date(item.invoicePaidAt || item.createdAt))}

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ services:
179179
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD}
180180
- plugins.security.disabled=true
181181
- discovery.type=single-node
182-
- "_JAVA_OPTIONS=-Xms2g -Xmx2g -XX:UseSVE=0"
182+
- "_JAVA_OPTIONS=-Xms2g -Xmx2g"
183183
ports:
184184
- 9200:9200 # REST API
185185
- 9600:9600 # Performance Analyzer

fragments/items.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const ITEM_FIELDS = gql`
2626
id
2727
name
2828
meMute
29+
deletedAt
2930
...StreakFields
3031
}
3132
sub {
@@ -100,6 +101,7 @@ export const ITEM_FULL_FIELDS = gql`
100101
user {
101102
id
102103
name
104+
deletedAt
103105
...StreakFields
104106
}
105107
sub {

lib/auth.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ export const cookieOptions = (args) => ({
3535
...args
3636
})
3737

38+
// Add a function to handle checking for deleted accounts
39+
export const checkDeletedAccount = async (user, prisma) => {
40+
if (!user?.id) return false
41+
42+
const dbUser = await prisma.user.findUnique({
43+
where: { id: user.id },
44+
select: { deletedAt: true }
45+
})
46+
47+
return !!dbUser?.deletedAt
48+
}
49+
3850
export function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
3951
const httpOnlyOptions = cookieOptions()
4052
const jsOptions = { ...httpOnlyOptions, httpOnly: false }

pages/api/auth/[...nextauth].js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ function getCallbacks (req, res) {
9393
* @return {object} JSON Web Token that will be saved
9494
*/
9595
async jwt ({ token, user, account, profile, isNewUser }) {
96+
// Check if the user account is deleted
97+
if (user?.id) {
98+
const dbUser = await prisma.user.findUnique({
99+
where: { id: user.id },
100+
select: { deletedAt: true }
101+
})
102+
103+
if (dbUser?.deletedAt) {
104+
// Return false to prevent sign in for deleted accounts
105+
return false
106+
}
107+
}
108+
96109
if (user) {
97110
// reset signup cookie if any
98111
res.appendHeader('Set-Cookie', cookie.serialize('signin', '', { path: '/', expires: 0, maxAge: 0 }))

pages/settings/index.js

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput } from '@/components/form'
2-
import Alert from 'react-bootstrap/Alert'
3-
import Button from 'react-bootstrap/Button'
4-
import InputGroup from 'react-bootstrap/InputGroup'
5-
import Nav from 'react-bootstrap/Nav'
2+
import { Alert, Button, InputGroup, Nav, Form as BootstrapForm, OverlayTrigger, Tooltip } from 'react-bootstrap'
63
import Layout from '@/components/layout'
74
import { useState, useMemo } from 'react'
85
import { gql, useMutation, useQuery } from '@apollo/client'
96
import { getGetServerSideProps } from '@/api/ssrApollo'
107
import LoginButton from '@/components/login-button'
11-
import { signIn } from 'next-auth/react'
8+
import { signIn, signOut } from 'next-auth/react'
129
import { LightningAuth } from '@/components/lightning-auth'
1310
import { SETTINGS, SET_SETTINGS } from '@/fragments/users'
1411
import { useRouter } from 'next/router'
@@ -27,7 +24,6 @@ import { useToast } from '@/components/toast'
2724
import { useServiceWorkerLogger } from '@/components/logger'
2825
import { useMe } from '@/components/me'
2926
import { INVOICE_RETENTION_DAYS, ZAP_UNDO_DELAY_MS } from '@/lib/constants'
30-
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
3127
import { useField } from 'formik'
3228
import styles from './settings.module.css'
3329
import { AuthBanner } from '@/components/banners'
@@ -87,6 +83,112 @@ export function SettingsHeader () {
8783
)
8884
}
8985

86+
const DELETE_ACCOUNT = gql`
87+
mutation deleteAccount($deleteContent: Boolean!, $confirmation: String!) {
88+
deleteAccount(deleteContent: $deleteContent, confirmation: $confirmation)
89+
}
90+
`
91+
92+
function DeleteAccount () {
93+
const [showConfirmation, setShowConfirmation] = useState(false)
94+
const [deleteContent, setDeleteContent] = useState(false)
95+
const [confirmation, setConfirmation] = useState('')
96+
const [deleteAccount] = useMutation(DELETE_ACCOUNT)
97+
const toaster = useToast()
98+
99+
const handleDelete = async () => {
100+
try {
101+
await deleteAccount({
102+
variables: {
103+
deleteContent,
104+
confirmation
105+
}
106+
})
107+
108+
// Sign out the user after successful deletion
109+
signOut({ callbackUrl: '/' })
110+
111+
// Show success message
112+
toaster.success('Your account has been deleted')
113+
} catch (error) {
114+
console.error(error)
115+
toaster.danger(error.message || 'Failed to delete account')
116+
}
117+
}
118+
119+
return (
120+
<>
121+
<div className='form-label mt-4 text-danger'>danger zone</div>
122+
<div className='card border-danger mb-3'>
123+
<div className='card-body'>
124+
<h5 className='card-title'>Delete Account</h5>
125+
<p className='card-text'>
126+
This will permanently delete your account. This action cannot be undone.
127+
</p>
128+
129+
{!showConfirmation
130+
? (
131+
<Button
132+
variant='danger'
133+
onClick={() => setShowConfirmation(true)}
134+
>
135+
Delete my account
136+
</Button>
137+
)
138+
: (
139+
<>
140+
<Alert variant='danger'>
141+
<p><strong>Warning:</strong> Account deletion is permanent and cannot be reversed.</p>
142+
<p>Before proceeding, please ensure:</p>
143+
<ul>
144+
<li>You have withdrawn all your sats (you cannot delete an account with a balance)</li>
145+
<li>You understand that you will lose access to your account name</li>
146+
<li>You have considered that this action affects your entire account history</li>
147+
</ul>
148+
</Alert>
149+
150+
<BootstrapForm.Check
151+
type='checkbox'
152+
id='delete-content'
153+
label='Also anonymize all my posts and comments (they will show as "[deleted]")'
154+
checked={deleteContent}
155+
onChange={(e) => setDeleteContent(e.target.checked)}
156+
className='mb-3'
157+
/>
158+
159+
<BootstrapForm.Group className='mb-3'>
160+
<BootstrapForm.Label>Type "DELETE MY ACCOUNT" to confirm:</BootstrapForm.Label>
161+
<BootstrapForm.Control
162+
type='text'
163+
value={confirmation}
164+
onChange={(e) => setConfirmation(e.target.value)}
165+
placeholder='DELETE MY ACCOUNT'
166+
/>
167+
</BootstrapForm.Group>
168+
169+
<div className='d-flex justify-content-between'>
170+
<Button
171+
variant='secondary'
172+
onClick={() => setShowConfirmation(false)}
173+
>
174+
Cancel
175+
</Button>
176+
<Button
177+
variant='danger'
178+
disabled={confirmation !== 'DELETE MY ACCOUNT'}
179+
onClick={handleDelete}
180+
>
181+
Permanently delete my account
182+
</Button>
183+
</div>
184+
</>
185+
)}
186+
</div>
187+
</div>
188+
</>
189+
)
190+
}
191+
90192
export default function Settings ({ ssrData }) {
91193
const toaster = useToast()
92194
const { me } = useMe()
@@ -661,6 +763,9 @@ export default function Settings ({ ssrData }) {
661763
<div className='form-label'>saturday newsletter</div>
662764
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
663765
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
766+
767+
{/* Add the delete account section */}
768+
<DeleteAccount />
664769
</div>
665770
</div>
666771
</Layout>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "users" ADD COLUMN "deletedAt" TIMESTAMP(3);

0 commit comments

Comments
 (0)