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:
chihlasm
2026-02-13 01:42:51 -05:00
parent b8f25f19eb
commit ad59446332
32 changed files with 3064 additions and 38 deletions

View File

@@ -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>
)
}