Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 422d85e97e | |||
| a0695fd11e |
@@ -24,6 +24,8 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
const [resetUrl, setResetUrl] = useState<string | null>(null)
|
const [resetUrl, setResetUrl] = useState<string | null>(null)
|
||||||
const [lotteryReset, setLotteryReset] = useState(false)
|
const [lotteryReset, setLotteryReset] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||||
|
const [deleteError, setDeleteError] = useState('')
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -80,10 +82,28 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (deleteConfirm !== user.username) {
|
||||||
|
setDeleteError('Username does not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setDeleteError('')
|
||||||
|
const res = await fetch(`/api/admin/users/${user.id}`, { method: 'DELETE' })
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (!res.ok) {
|
||||||
|
setDeleteError(data.error ?? 'Delete failed.')
|
||||||
|
} else {
|
||||||
|
setOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setOpen(true); setResetUrl(null); setError('') }}
|
onClick={() => { setOpen(true); setResetUrl(null); setError(''); setDeleteConfirm(''); setDeleteError('') }}
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
@@ -199,6 +219,35 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone — delete user */}
|
||||||
|
<div className="border-t border-red-500/20 pt-4">
|
||||||
|
<p className="text-sm text-red-400 mb-1 font-medium">Danger zone</p>
|
||||||
|
<p className="text-xs text-slate-500 mb-3">
|
||||||
|
Permanently deletes this account, all trades, positions, and history.
|
||||||
|
Fund investments will be forfeited to their respective funds.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deleteConfirm}
|
||||||
|
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||||
|
placeholder={`Type "${user.username}" to confirm`}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full bg-surface border border-red-500/30 focus:border-red-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
{deleteError && (
|
||||||
|
<p className="text-red-400 text-xs">{deleteError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading || deleteConfirm !== user.username}
|
||||||
|
className="text-sm bg-red-700/30 hover:bg-red-700/50 text-red-400 border border-red-500/30 px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Deleting…' : 'Delete account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
|
|||||||
@@ -37,3 +37,46 @@ export async function PATCH(req: NextRequest, { params }: { params: { userId: st
|
|||||||
|
|
||||||
return NextResponse.json(updated)
|
return NextResponse.json(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/users/[userId]
|
||||||
|
* Permanently deletes a user account.
|
||||||
|
* Fund investments are reconciled (sharesOutstanding decremented) before cascade deletion.
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: { userId: string } }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user.isAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: params.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
isFund: true,
|
||||||
|
fundInvestments: { select: { fundId: true, shares: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
|
||||||
|
if (user.isFund) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Use the fund deletion endpoint to remove fund accounts.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile fund sharesOutstanding before cascade delete
|
||||||
|
for (const inv of user.fundInvestments) {
|
||||||
|
await prisma.hedgeFund.update({
|
||||||
|
where: { id: inv.fundId },
|
||||||
|
data: { sharesOutstanding: { decrement: inv.shares } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id: params.userId } })
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,3 +90,36 @@ export async function PATCH(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ ok: true, ...updated })
|
return NextResponse.json({ ok: true, ...updated })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/user/me
|
||||||
|
* Permanently deletes the authenticated user's account.
|
||||||
|
* Fund investments are reconciled (sharesOutstanding decremented) before deletion.
|
||||||
|
*/
|
||||||
|
export async function DELETE() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
isFund: true,
|
||||||
|
fundInvestments: { select: { fundId: true, shares: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
if (user.isFund) return NextResponse.json({ error: 'Fund accounts cannot be self-deleted.' }, { status: 400 })
|
||||||
|
|
||||||
|
// Reconcile fund sharesOutstanding before user is deleted
|
||||||
|
for (const inv of user.fundInvestments) {
|
||||||
|
await prisma.hedgeFund.update({
|
||||||
|
where: { id: inv.fundId },
|
||||||
|
data: { sharesOutstanding: { decrement: inv.shares } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id: session.user.id } })
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
import { Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CloseAccountForm({ username }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleDelete(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (confirm !== username) {
|
||||||
|
setError('Username does not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/me', { method: 'DELETE' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? 'Failed to delete account.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await signOut({ callbackUrl: '/' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-surface-card border border-red-500/20 rounded-xl p-6">
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen((v) => !v); setError(''); setConfirm('') }}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Close account
|
||||||
|
<span className="ml-1 text-slate-500">{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<form onSubmit={handleDelete} className="mt-4 space-y-4 max-w-sm">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
This will <span className="text-red-400 font-medium">permanently delete</span> your account,
|
||||||
|
all trade history, positions, and portfolio data. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Any fund investments you hold will be forfeited to the fund.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
Type <span className="text-white font-mono">{username}</span> to confirm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder={username}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full bg-surface border border-red-500/30 focus:border-red-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || confirm !== username}
|
||||||
|
className="flex items-center gap-2 bg-red-700 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{loading ? 'Deleting…' : 'Permanently delete my account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import Link from 'next/link'
|
|||||||
import { TrendingUp, TrendingDown, Coins, Building2 } from 'lucide-react'
|
import { TrendingUp, TrendingDown, Coins, Building2 } from 'lucide-react'
|
||||||
import ChangePasswordForm from './ChangePasswordForm'
|
import ChangePasswordForm from './ChangePasswordForm'
|
||||||
import AccountSettingsForm from './AccountSettingsForm'
|
import AccountSettingsForm from './AccountSettingsForm'
|
||||||
|
import CloseAccountForm from './CloseAccountForm'
|
||||||
import { PriceChart } from '@/components/PriceChart'
|
import { PriceChart } from '@/components/PriceChart'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -327,6 +328,7 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
currentDisplayUsername={user.displayUsername ?? null}
|
currentDisplayUsername={user.displayUsername ?? null}
|
||||||
/>
|
/>
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm />
|
||||||
|
<CloseAccountForm username={user.username} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -486,7 +486,7 @@ async function setupRepeatableJobs() {
|
|||||||
'fund-nav-snapshot',
|
'fund-nav-snapshot',
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
repeat: { pattern: '0 * * * *' },
|
repeat: { pattern: '*/15 * * * *' },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user