feat: user management — admin create, password reset, archive/delete, quick invite
Phase 1: must_change_password enforcement + change password endpoint/page Phase 2: Admin user creation (M365-style) with temp password Phase 3: Password reset (self-service forgot + admin-triggered) Phase 4: User archive (soft delete) + hard delete with precheck Phase 5: Quick invite from admin Users page Also fixes: - Auto-create subscription for accounts missing one - Hard delete precheck ignores sole-member personal accounts - Seed script patches tree nodes for validation compliance Migrations: 031 (must_change_password), 032 (password_reset_tokens), 033 (user soft delete) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Shield, Crown, UserCheck, UserX, Clock, Ticket } from 'lucide-react'
|
||||
import { ArrowLeft, Shield, Crown, UserCheck, UserX, Clock, Ticket, KeyRound, Copy, Check, Archive, ArchiveRestore, Trash2 } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { adminApi } from '@/api/admin'
|
||||
@@ -23,6 +23,18 @@ export function UserDetailPage() {
|
||||
const [trialDays, setTrialDays] = useState('14')
|
||||
const [activeTab, setActiveTab] = useState<'sessions' | 'audit'>('sessions')
|
||||
|
||||
// Password reset modal
|
||||
const [resetModalOpen, setResetModalOpen] = useState(false)
|
||||
const [resetMode, setResetMode] = useState<'email_link' | 'temp_password'>('email_link')
|
||||
const [resetLoading, setResetLoading] = useState(false)
|
||||
const [resetTempPassword, setResetTempPassword] = useState<string | null>(null)
|
||||
const [resetCopied, setResetCopied] = useState(false)
|
||||
|
||||
// Hard delete
|
||||
const [hardDeleteModalOpen, setHardDeleteModalOpen] = useState(false)
|
||||
const [hardDeleteChecking, setHardDeleteChecking] = useState(false)
|
||||
const [hardDeleteBlockers, setHardDeleteBlockers] = useState<Record<string, number> | null>(null)
|
||||
|
||||
const fetchUser = useCallback(async () => {
|
||||
if (!userId) return
|
||||
setLoading(true)
|
||||
@@ -78,6 +90,85 @@ export function UserDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!userId) return
|
||||
setResetLoading(true)
|
||||
try {
|
||||
const result = await adminApi.adminResetPassword(userId, resetMode)
|
||||
if (resetMode === 'temp_password' && result.temporary_password) {
|
||||
setResetTempPassword(result.temporary_password)
|
||||
setResetCopied(false)
|
||||
} else {
|
||||
toast.success(result.email_sent ? 'Password reset email sent' : result.message)
|
||||
setResetModalOpen(false)
|
||||
}
|
||||
fetchUser()
|
||||
} catch {
|
||||
toast.error('Failed to reset password')
|
||||
} finally {
|
||||
setResetLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyResetPassword = async () => {
|
||||
if (!resetTempPassword) return
|
||||
await navigator.clipboard.writeText(resetTempPassword)
|
||||
setResetCopied(true)
|
||||
setTimeout(() => setResetCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!userId) return
|
||||
try {
|
||||
await adminApi.archiveUser(userId)
|
||||
toast.success('User archived')
|
||||
fetchUser()
|
||||
} catch {
|
||||
toast.error('Failed to archive user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!userId) return
|
||||
try {
|
||||
await adminApi.restoreUser(userId)
|
||||
toast.success('User restored')
|
||||
fetchUser()
|
||||
} catch {
|
||||
toast.error('Failed to restore user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHardDeleteCheck = async () => {
|
||||
if (!userId) return
|
||||
setHardDeleteChecking(true)
|
||||
try {
|
||||
const result = await adminApi.hardDeleteCheck(userId)
|
||||
setHardDeleteBlockers(result.blockers)
|
||||
setHardDeleteModalOpen(true)
|
||||
} catch {
|
||||
toast.error('Failed to check delete eligibility')
|
||||
} finally {
|
||||
setHardDeleteChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleHardDelete = async () => {
|
||||
if (!userId) return
|
||||
try {
|
||||
await adminApi.hardDeleteUser(userId)
|
||||
toast.success('User permanently deleted')
|
||||
navigate('/admin/users')
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail || 'Failed to delete user')
|
||||
} else {
|
||||
toast.error('Failed to delete user')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
|
||||
@@ -126,6 +217,9 @@ export function UserDetailPage() {
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</StatusBadge>
|
||||
<StatusBadge variant="default">{user.role}</StatusBadge>
|
||||
{user.deleted_at && (
|
||||
<StatusBadge variant="warning">Archived</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,22 +283,37 @@ export function UserDetailPage() {
|
||||
Admin Actions
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{user.account && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPlan(user.subscription?.plan || 'free')
|
||||
setPlanModalOpen(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<Shield className="h-4 w-4 text-white/40" />
|
||||
Change Plan
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTrialModalOpen(true)}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<Clock className="h-4 w-4 text-white/40" />
|
||||
{user.subscription?.status === 'trialing' ? 'Extend Trial' : 'Start Trial'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPlan(user.subscription?.plan || 'free')
|
||||
setPlanModalOpen(true)
|
||||
setResetMode('email_link')
|
||||
setResetTempPassword(null)
|
||||
setResetModalOpen(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<Shield className="h-4 w-4 text-white/40" />
|
||||
Change Plan
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTrialModalOpen(true)}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<Clock className="h-4 w-4 text-white/40" />
|
||||
{user.subscription?.status === 'trialing' ? 'Extend Trial' : 'Start Trial'}
|
||||
<KeyRound className="h-4 w-4 text-white/40" />
|
||||
Reset Password
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleActive}
|
||||
@@ -221,6 +330,33 @@ export function UserDetailPage() {
|
||||
<><UserCheck className="h-4 w-4" /> Activate User</>
|
||||
)}
|
||||
</button>
|
||||
{/* Archive / Restore */}
|
||||
{user.deleted_at ? (
|
||||
<button
|
||||
onClick={handleRestore}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-emerald-500/20 px-4 py-3 text-left text-sm text-emerald-400 hover:bg-emerald-500/5"
|
||||
>
|
||||
<ArchiveRestore className="h-4 w-4" /> Restore User
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleArchive}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-yellow-500/20 px-4 py-3 text-left text-sm text-yellow-400 hover:bg-yellow-500/5"
|
||||
>
|
||||
<Archive className="h-4 w-4" /> Archive User
|
||||
</button>
|
||||
)}
|
||||
{/* Hard Delete (only if archived) */}
|
||||
{user.deleted_at && (
|
||||
<button
|
||||
onClick={handleHardDeleteCheck}
|
||||
disabled={hardDeleteChecking}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-red-500/20 px-4 py-3 text-left text-sm text-red-400 hover:bg-red-500/5 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{hardDeleteChecking ? 'Checking...' : 'Permanently Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,6 +515,106 @@ export function UserDetailPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
<Modal
|
||||
isOpen={resetModalOpen && !resetTempPassword}
|
||||
onClose={() => setResetModalOpen(false)}
|
||||
title="Reset User Password"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setResetModalOpen(false)}
|
||||
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetPassword}
|
||||
disabled={resetLoading}
|
||||
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
|
||||
>
|
||||
{resetLoading ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-white/70">
|
||||
Choose how to reset the password for <span className="font-medium text-white">{user.full_name || user.email}</span>.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-3 rounded-lg border border-white/10 p-3 cursor-pointer hover:bg-white/5">
|
||||
<input
|
||||
type="radio"
|
||||
name="reset-mode"
|
||||
value="email_link"
|
||||
checked={resetMode === 'email_link'}
|
||||
onChange={() => setResetMode('email_link')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">Send Reset Email</div>
|
||||
<div className="text-xs text-white/40">User receives an email with a reset link (30 min expiry)</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-lg border border-white/10 p-3 cursor-pointer hover:bg-white/5">
|
||||
<input
|
||||
type="radio"
|
||||
name="reset-mode"
|
||||
value="temp_password"
|
||||
checked={resetMode === 'temp_password'}
|
||||
onChange={() => setResetMode('temp_password')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">Generate Temp Password</div>
|
||||
<div className="text-xs text-white/40">A temporary password is generated. You share it manually.</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Temp Password Result Modal */}
|
||||
<Modal
|
||||
isOpen={!!resetTempPassword}
|
||||
onClose={() => { setResetTempPassword(null); setResetModalOpen(false) }}
|
||||
title="Temporary Password"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => { setResetTempPassword(null); setResetModalOpen(false) }}
|
||||
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
|
||||
This password will not be shown again. Copy it now.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white font-mono">
|
||||
{resetTempPassword}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyResetPassword}
|
||||
className="rounded-md border border-white/10 p-2 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
|
||||
title="Copy password"
|
||||
>
|
||||
{resetCopied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-white/40">
|
||||
The user will be required to change this password on next login.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Extend Trial Modal */}
|
||||
<Modal
|
||||
isOpen={trialModalOpen}
|
||||
@@ -415,6 +651,55 @@ export function UserDetailPage() {
|
||||
<p className="mt-1 text-xs text-white/40">1-90 days. Will convert to trialing status if not already.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Hard Delete Modal */}
|
||||
<Modal
|
||||
isOpen={hardDeleteModalOpen}
|
||||
onClose={() => setHardDeleteModalOpen(false)}
|
||||
title="Permanently Delete User"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setHardDeleteModalOpen(false)}
|
||||
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{hardDeleteBlockers && Object.keys(hardDeleteBlockers).length === 0 && (
|
||||
<button
|
||||
onClick={handleHardDelete}
|
||||
className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Delete Permanently
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{hardDeleteBlockers && Object.keys(hardDeleteBlockers).length > 0 ? (
|
||||
<>
|
||||
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
|
||||
This user cannot be deleted because they have dependencies:
|
||||
</div>
|
||||
<ul className="space-y-1 text-sm text-white/70">
|
||||
{Object.entries(hardDeleteBlockers).map(([key, count]) => (
|
||||
<li key={key} className="flex justify-between">
|
||||
<span>{key.replace(/_/g, ' ')}</span>
|
||||
<span className="font-mono text-white/40">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-4 text-sm text-red-400">
|
||||
<p className="font-medium">This action is irreversible.</p>
|
||||
<p className="mt-1">The user <strong>{user?.full_name || user?.email}</strong> and all their technical data (tokens, reset tokens) will be permanently removed.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user