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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
170
frontend/src/pages/ChangePasswordPage.tsx
Normal file
170
frontend/src/pages/ChangePasswordPage.tsx
Normal 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
|
||||
115
frontend/src/pages/ForgotPasswordPage.tsx
Normal file
115
frontend/src/pages/ForgotPasswordPage.tsx
Normal 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
|
||||
@@ -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}
|
||||
|
||||
187
frontend/src/pages/ResetPasswordPage.tsx
Normal file
187
frontend/src/pages/ResetPasswordPage.tsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface Token {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
must_change_password?: boolean
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user