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

@@ -16,6 +16,8 @@ import type {
InviteCodeResponse,
InviteCodeCreateRequest,
UserDetailResponse,
AdminUserCreate,
AdminUserCreateResponse,
} from '@/types/admin'
export const adminApi = {
@@ -25,7 +27,9 @@ export const adminApi = {
getDashboardActivity: () =>
api.get<ActivityEntry[]>('/admin/dashboard/activity').then(r => r.data),
// Users (existing endpoints)
// Users
createUser: (data: AdminUserCreate) =>
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
listUsers: (params?: Record<string, unknown>) =>
api.get('/admin/users', { params }).then(r => r.data),
getUser: (id: string) =>
@@ -41,6 +45,24 @@ export const adminApi = {
moveUserAccount: (id: string, display_code: string) =>
api.put(`/admin/users/${id}/move-account`, { display_code }).then(r => r.data),
// Users - archive & delete
archiveUser: (id: string) =>
api.put(`/admin/users/${id}/archive`).then(r => r.data),
restoreUser: (id: string) =>
api.put(`/admin/users/${id}/restore`).then(r => r.data),
hardDeleteCheck: (id: string) =>
api.get<{ can_delete: boolean; blockers: Record<string, number> }>(`/admin/users/${id}/hard-delete-check`).then(r => r.data),
hardDeleteUser: (id: string) =>
api.delete(`/admin/users/${id}/hard-delete`),
// Users - quick invite
createInvite: (data: { email: string; account_display_code: string; role: string }) =>
api.post<{ id: string; email: string; code: string; role: string; account_display_code: string; email_sent: boolean }>('/admin/invites', data).then(r => r.data),
// Users - password reset
adminResetPassword: (id: string, mode: 'email_link' | 'temp_password') =>
api.post<{ message: string; temporary_password?: string; email_sent: boolean }>(`/admin/users/${id}/password-reset`, { mode }).then(r => r.data),
// Users - detail + subscription
getUserDetail: (id: string) =>
api.get<UserDetailResponse>(`/admin/users/${id}`).then(r => r.data),

View File

@@ -30,6 +30,29 @@ export const authApi = {
async logout(): Promise<void> {
await apiClient.post('/auth/logout')
},
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await apiClient.post('/auth/password/change', {
current_password: currentPassword,
new_password: newPassword,
})
},
async forgotPassword(email: string): Promise<void> {
await apiClient.post('/auth/password/forgot', { email })
},
async verifyResetToken(token: string): Promise<{ valid: boolean; email: string | null }> {
const response = await apiClient.post<{ valid: boolean; email: string | null }>('/auth/password/verify-reset-token', { token })
return response.data
},
async resetPassword(token: string, newPassword: string): Promise<void> {
await apiClient.post('/auth/password/reset', {
token,
new_password: newPassword,
})
},
}
export default authApi

View File

@@ -8,7 +8,7 @@ interface ProtectedRouteProps {
}
export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuthStore()
const { isAuthenticated, isLoading, user } = useAuthStore()
const location = useLocation()
const { effectiveRole } = usePermissions()
@@ -24,6 +24,11 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
return <Navigate to="/login" state={{ from: location }} replace />
}
// Enforce must_change_password — redirect unless already on /change-password
if (user?.must_change_password && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />
}
if (requiredRole) {
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
super_admin: 4,

View File

@@ -0,0 +1,170 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { authApi } from '@/api/auth'
import { toast } from '@/lib/toast'
import { BrandLogo } from '@/components/common/BrandLogo'
import { cn } from '@/lib/utils'
export function ChangePasswordPage() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const isForced = user?.must_change_password ?? false
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!currentPassword || !newPassword || !confirmPassword) {
setError('Please fill in all fields')
return
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (newPassword.length < 10) {
setError('Password must be at least 10 characters')
return
}
setIsLoading(true)
try {
await authApi.changePassword(currentPassword, newPassword)
toast.success('Password changed successfully. Please sign in again.')
await logout()
navigate('/login', { replace: true })
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setError(axiosErr.response?.data?.detail || 'Failed to change password')
} else {
setError('Failed to change password')
}
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="relative w-full max-w-md space-y-8">
<div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6">
<div className="w-16 h-16 rounded-2xl bg-white flex items-center justify-center sm:w-20 sm:h-20">
<BrandLogo size="lg" className="h-10 w-10 invert sm:h-12 sm:w-12" />
</div>
</div>
<h1 className="text-3xl font-bold text-white tracking-tight">
Change Password
</h1>
{isForced && (
<div className="mt-4 rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
You must change your password before continuing.
</div>
)}
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="glass-card rounded-2xl p-6 space-y-4">
{error && (
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
{error}
</div>
)}
<div>
<label htmlFor="current-password" className="mb-1 block text-sm font-medium text-white">
Current Password
</label>
<input
id="current-password"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
/>
</div>
<div>
<label htmlFor="new-password" className="mb-1 block text-sm font-medium text-white">
New Password
</label>
<input
id="new-password"
type="password"
autoComplete="new-password"
required
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
placeholder="At least 10 characters"
/>
<p className="mt-1 text-xs text-white/40">
Must include uppercase, lowercase, and a digit.
</p>
</div>
<div>
<label htmlFor="confirm-password" className="mb-1 block text-sm font-medium text-white">
Confirm New Password
</label>
<input
id="confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-white text-black hover:bg-white/90',
'focus:outline-none focus:ring-2 focus:ring-white/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all'
)}
>
{isLoading ? 'Changing password...' : 'Change Password'}
</button>
</div>
</form>
</div>
</div>
)
}
export default ChangePasswordPage

View File

@@ -0,0 +1,115 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { authApi } from '@/api/auth'
import { BrandLogo } from '@/components/common/BrandLogo'
import { cn } from '@/lib/utils'
export function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!email) return
setIsLoading(true)
try {
await authApi.forgotPassword(email)
} catch {
// Always show success (anti-enumeration)
} finally {
setIsLoading(false)
setSubmitted(true)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="relative w-full max-w-md space-y-8">
<div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6">
<div className="w-16 h-16 rounded-2xl bg-white flex items-center justify-center sm:w-20 sm:h-20">
<BrandLogo size="lg" className="h-10 w-10 invert sm:h-12 sm:w-12" />
</div>
</div>
<h1 className="text-3xl font-bold text-white tracking-tight">
Reset Password
</h1>
<p className="mt-2 text-sm text-white/40">
Enter your email and we'll send you a link to reset your password.
</p>
</div>
{submitted ? (
<div className="glass-card rounded-2xl p-6 space-y-4">
<div className="rounded-xl border border-green-400/20 bg-green-400/10 p-4 text-sm text-green-400">
If an account with that email exists, we've sent a password reset link.
Check your inbox and follow the instructions.
</div>
<div className="text-center">
<Link
to="/login"
className="text-sm text-white/60 hover:text-white transition-colors"
>
Back to sign in
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="glass-card rounded-2xl p-6 space-y-4">
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium text-white">
Email Address
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
placeholder="you@example.com"
/>
</div>
<button
type="submit"
disabled={isLoading || !email}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-white text-black hover:bg-white/90',
'focus:outline-none focus:ring-2 focus:ring-white/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all'
)}
>
{isLoading ? 'Sending...' : 'Send Reset Link'}
</button>
<div className="text-center">
<Link
to="/login"
className="text-sm text-white/60 hover:text-white transition-colors"
>
Back to sign in
</Link>
</div>
</div>
</form>
)}
</div>
</div>
)
}
export default ForgotPasswordPage

View File

@@ -27,7 +27,12 @@ export function LoginPage() {
try {
await login({ email, password })
navigate(from, { replace: true })
const user = useAuthStore.getState().user
if (user?.must_change_password) {
navigate('/change-password', { replace: true })
} else {
navigate(from, { replace: true })
}
} catch {
// Error is set in the store
}
@@ -108,6 +113,12 @@ export function LoginPage() {
/>
</div>
<div className="text-right">
<Link to="/forgot-password" className="text-xs text-white/40 hover:text-white/60 transition-colors">
Forgot password?
</Link>
</div>
<button
type="submit"
disabled={isLoading}

View File

@@ -0,0 +1,187 @@
import { useState, useEffect } from 'react'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { authApi } from '@/api/auth'
import { toast } from '@/lib/toast'
import { BrandLogo } from '@/components/common/BrandLogo'
import { cn } from '@/lib/utils'
export function ResetPasswordPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const token = searchParams.get('token') || ''
const [verifying, setVerifying] = useState(true)
const [valid, setValid] = useState(false)
const [email, setEmail] = useState<string | null>(null)
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!token) {
setVerifying(false)
return
}
authApi.verifyResetToken(token).then((res) => {
setValid(res.valid)
setEmail(res.email || null)
}).catch(() => {
setValid(false)
}).finally(() => {
setVerifying(false)
})
}, [token])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!newPassword || !confirmPassword) {
setError('Please fill in all fields')
return
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match')
return
}
if (newPassword.length < 10) {
setError('Password must be at least 10 characters')
return
}
setIsLoading(true)
try {
await authApi.resetPassword(token, newPassword)
toast.success('Password reset successfully. Please sign in.')
navigate('/login', { replace: true })
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setError(axiosErr.response?.data?.detail || 'Failed to reset password')
} else {
setError('Failed to reset password')
}
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="relative w-full max-w-md space-y-8">
<div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6">
<div className="w-16 h-16 rounded-2xl bg-white flex items-center justify-center sm:w-20 sm:h-20">
<BrandLogo size="lg" className="h-10 w-10 invert sm:h-12 sm:w-12" />
</div>
</div>
<h1 className="text-3xl font-bold text-white tracking-tight">
Reset Password
</h1>
</div>
{verifying ? (
<div className="glass-card rounded-2xl p-6 text-center">
<p className="text-white/60">Verifying reset link...</p>
</div>
) : !token || !valid ? (
<div className="glass-card rounded-2xl p-6 space-y-4">
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-4 text-sm text-red-400">
This reset link is invalid or has expired. Please request a new one.
</div>
<div className="text-center">
<Link
to="/forgot-password"
className="text-sm text-white/60 hover:text-white transition-colors"
>
Request new reset link
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="glass-card rounded-2xl p-6 space-y-4">
{email && (
<p className="text-sm text-white/60">
Resetting password for <span className="font-medium text-white">{email}</span>
</p>
)}
{error && (
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
{error}
</div>
)}
<div>
<label htmlFor="new-password" className="mb-1 block text-sm font-medium text-white">
New Password
</label>
<input
id="new-password"
type="password"
autoComplete="new-password"
required
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
placeholder="At least 10 characters"
/>
<p className="mt-1 text-xs text-white/40">
Must include uppercase, lowercase, and a digit.
</p>
</div>
<div>
<label htmlFor="confirm-password" className="mb-1 block text-sm font-medium text-white">
Confirm New Password
</label>
<input
id="confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-white text-black hover:bg-white/90',
'focus:outline-none focus:ring-2 focus:ring-white/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all'
)}
>
{isLoading ? 'Resetting...' : 'Reset Password'}
</button>
</div>
</form>
)}
</div>
</div>
)
}
export default ResetPasswordPage

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

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink } from 'lucide-react'
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink, UserPlus, Copy, Check, Mail } from 'lucide-react'
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
@@ -19,6 +19,7 @@ interface AdminUser {
account_role: string | null
created_at: string
last_login: string | null
deleted_at: string | null
}
export function UsersPage() {
@@ -29,6 +30,7 @@ export function UsersPage() {
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 20
const [showArchived, setShowArchived] = useState(false)
// Role change modal
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
@@ -38,10 +40,31 @@ export function UsersPage() {
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
const [displayCode, setDisplayCode] = useState('')
// Create user modal
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({
email: '',
name: '',
account_mode: 'personal' as 'existing' | 'personal',
account_display_code: '',
account_role: 'engineer' as 'engineer' | 'viewer',
send_email: true,
})
const [createLoading, setCreateLoading] = useState(false)
// Temp password display modal
const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// Invite user modal
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteForm, setInviteForm] = useState({ email: '', account_display_code: '', role: 'engineer' as 'engineer' | 'viewer' })
const [inviteLoading, setInviteLoading] = useState(false)
const fetchUsers = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined })
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined, include_archived: showArchived || undefined })
setUsers(data.items || data)
setTotal(data.total || (data.items ? data.items.length : data.length))
} catch {
@@ -49,7 +72,7 @@ export function UsersPage() {
} finally {
setLoading(false)
}
}, [page, search])
}, [page, search, showArchived])
useEffect(() => { fetchUsers() }, [fetchUsers])
@@ -93,6 +116,71 @@ export function UsersPage() {
}
}
const handleCreateUser = async () => {
if (!createForm.email || !createForm.name) return
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
toast.error('Account display code is required')
return
}
setCreateLoading(true)
try {
const result = await adminApi.createUser({
email: createForm.email,
name: createForm.name,
account_mode: createForm.account_mode,
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
send_email: createForm.send_email,
})
setShowCreateModal(false)
setTempPassword(result.temporary_password)
setCopied(false)
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
setCreateForm({ email: '', name: '', account_mode: 'personal', account_display_code: '', account_role: 'engineer', send_email: true })
fetchUsers()
} 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 create user')
} else {
toast.error('Failed to create user')
}
} finally {
setCreateLoading(false)
}
}
const handleCopyPassword = async () => {
if (!tempPassword) return
await navigator.clipboard.writeText(tempPassword)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleInviteUser = async () => {
if (!inviteForm.email || !inviteForm.account_display_code) return
setInviteLoading(true)
try {
const result = await adminApi.createInvite({
email: inviteForm.email,
account_display_code: inviteForm.account_display_code,
role: inviteForm.role,
})
setShowInviteModal(false)
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
} 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 send invite')
} else {
toast.error('Failed to send invite')
}
} finally {
setInviteLoading(false)
}
}
const columns: Column<AdminUser>[] = [
{
key: 'name',
@@ -121,9 +209,14 @@ export function UsersPage() {
key: 'status',
header: 'Status',
render: (u) => (
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
{u.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
<div className="flex items-center gap-1">
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
{u.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
{u.deleted_at && (
<StatusBadge variant="warning">Archived</StatusBadge>
)}
</div>
),
},
{
@@ -170,14 +263,43 @@ export function UsersPage() {
return (
<div className="space-y-6">
<PageHeader title="Users" description="Manage platform users and roles" />
<div className="flex items-center justify-between">
<PageHeader title="Users" description="Manage platform users and roles" />
<div className="flex items-center gap-3">
<button
onClick={() => setShowInviteModal(true)}
className="flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
>
<Mail className="h-4 w-4" />
Invite User
</button>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 transition-colors"
>
<UserPlus className="h-4 w-4" />
Create User
</button>
</div>
</div>
<SearchInput
value={search}
onSearch={(v) => { setSearch(v); setPage(1) }}
placeholder="Search by name or email..."
className="max-w-sm"
/>
<div className="flex items-center gap-4">
<SearchInput
value={search}
onSearch={(v) => { setSearch(v); setPage(1) }}
placeholder="Search by name or email..."
className="max-w-sm"
/>
<label className="flex items-center gap-2 text-sm text-white/60">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => { setShowArchived(e.target.checked); setPage(1) }}
className="rounded border-white/20 bg-black/50"
/>
Show archived
</label>
</div>
<DataTable
columns={columns}
@@ -278,6 +400,227 @@ export function UsersPage() {
</div>
</div>
</Modal>
{/* Create User Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="Create User"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
>
Cancel
</button>
<button
onClick={handleCreateUser}
disabled={createLoading || !createForm.email || !createForm.name}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
>
{createLoading ? 'Creating...' : 'Create User'}
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-white">Name</label>
<input
type="text"
value={createForm.name}
onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}
placeholder="Full name"
className={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'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Email</label>
<input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm(f => ({ ...f, email: e.target.value }))}
placeholder="user@example.com"
className={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'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Account Mode</label>
<select
value={createForm.account_mode}
onChange={(e) => setCreateForm(f => ({ ...f, account_mode: e.target.value as 'existing' | 'personal' }))}
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
>
<option value="personal">Personal (new account)</option>
<option value="existing">Join existing account</option>
</select>
</div>
{createForm.account_mode === 'existing' && (
<>
<div>
<label className="mb-1 block text-sm font-medium text-white">Account Display Code</label>
<input
type="text"
value={createForm.account_display_code}
onChange={(e) => setCreateForm(f => ({ ...f, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
className={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'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Account Role</label>
<select
value={createForm.account_role}
onChange={(e) => setCreateForm(f => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="send-email"
checked={createForm.send_email}
onChange={(e) => setCreateForm(f => ({ ...f, send_email: e.target.checked }))}
className="rounded border-white/20 bg-black/50"
/>
<label htmlFor="send-email" className="text-sm text-white/70">
Send welcome email with temporary password
</label>
</div>
</div>
</Modal>
{/* Temporary Password Modal */}
<Modal
isOpen={!!tempPassword}
onClose={() => setTempPassword(null)}
title="User Created"
size="sm"
footer={
<div className="flex justify-end">
<button
onClick={() => setTempPassword(null)}
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>
<label className="mb-1 block text-sm font-medium text-white">Temporary Password</label>
<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">
{tempPassword}
</code>
<button
onClick={handleCopyPassword}
className="rounded-md border border-white/10 p-2 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
title="Copy password"
>
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
<p className="text-xs text-white/40">
The user will be required to change this password on first login.
</p>
</div>
</Modal>
{/* Invite User Modal */}
<Modal
isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)}
title="Invite User"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setShowInviteModal(false)}
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
>
Cancel
</button>
<button
onClick={handleInviteUser}
disabled={inviteLoading || !inviteForm.email || !inviteForm.account_display_code}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
>
{inviteLoading ? 'Sending...' : 'Send Invite'}
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-white">Email</label>
<input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm(f => ({ ...f, email: e.target.value }))}
placeholder="user@example.com"
className={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'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Account Display Code</label>
<input
type="text"
value={inviteForm.account_display_code}
onChange={(e) => setInviteForm(f => ({ ...f, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
className={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'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Role</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm(f => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</div>
</Modal>
</div>
)
}

View File

@@ -8,6 +8,11 @@ import {
RegisterPage,
} from '@/pages'
// Standalone auth pages
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'))
// Lazy load heavy pages for code splitting
const QuickStartPage = lazy(() => import('@/pages/QuickStartPage'))
const TreeLibraryPage = lazy(() => import('@/pages/TreeLibraryPage'))
@@ -44,6 +49,35 @@ export const router = createBrowserRouter([
element: <RegisterPage />,
errorElement: <RouteError />,
},
{
path: '/forgot-password',
element: (
<Suspense fallback={<PageLoader />}>
<ForgotPasswordPage />
</Suspense>
),
errorElement: <RouteError />,
},
{
path: '/reset-password',
element: (
<Suspense fallback={<PageLoader />}>
<ResetPasswordPage />
</Suspense>
),
errorElement: <RouteError />,
},
{
path: '/change-password',
element: (
<ProtectedRoute>
<Suspense fallback={<PageLoader />}>
<ChangePasswordPage />
</Suspense>
</ProtectedRoute>
),
errorElement: <RouteError />,
},
{
path: '/',
element: (

View File

@@ -158,6 +158,22 @@ export interface InviteCodeCreateRequest {
trial_duration_days?: number | null
}
// Admin user creation types
export interface AdminUserCreate {
email: string
name: string
account_mode: 'existing' | 'personal'
account_display_code?: string
account_role?: 'engineer' | 'viewer'
send_email: boolean
}
export interface AdminUserCreateResponse {
user: Record<string, unknown>
temporary_password: string
email_sent: boolean
}
// User detail types
export interface AccountSummary {
id: string
@@ -206,6 +222,7 @@ export interface UserDetailResponse {
is_super_admin: boolean
is_team_admin: boolean
created_at: string
deleted_at: string | null
account: AccountSummary | null
subscription: SubscriptionSummary | null
invite_code_used: InviteCodeUsedSummary | null

View File

@@ -2,6 +2,7 @@ export interface Token {
access_token: string
refresh_token: string
token_type: string
must_change_password?: boolean
}
export interface AuthState {

View File

@@ -6,6 +6,8 @@ export interface User {
name: string
role: UserRole
is_super_admin: boolean
is_active: boolean
must_change_password: boolean
account_id: string
account_role: 'owner' | 'engineer' | 'viewer'
created_at: string