|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useMemo, useState } from 'react' |
| 4 | +import { useParams } from 'next/navigation' |
| 5 | +import { Badge, Button, Input as EmcnInput, Label, Skeleton, Switch } from '@/components/emcn' |
| 6 | +import { useSession } from '@/lib/auth/auth-client' |
| 7 | +import { cn } from '@/lib/utils' |
| 8 | +import { |
| 9 | + useAdminUsers, |
| 10 | + useBanUser, |
| 11 | + useSetUserRole, |
| 12 | + useUnbanUser, |
| 13 | +} from '@/hooks/queries/admin-users' |
| 14 | +import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings' |
| 15 | +import { useImportWorkflow } from '@/hooks/queries/workflows' |
| 16 | + |
| 17 | +const PAGE_SIZE = 20 as const |
| 18 | + |
| 19 | +export function Admin() { |
| 20 | + const params = useParams() |
| 21 | + const workspaceId = params?.workspaceId as string |
| 22 | + const { data: session } = useSession() |
| 23 | + |
| 24 | + const { data: settings } = useGeneralSettings() |
| 25 | + const updateSetting = useUpdateGeneralSetting() |
| 26 | + const importWorkflow = useImportWorkflow() |
| 27 | + |
| 28 | + const setUserRole = useSetUserRole() |
| 29 | + const banUser = useBanUser() |
| 30 | + const unbanUser = useUnbanUser() |
| 31 | + |
| 32 | + const [workflowId, setWorkflowId] = useState('') |
| 33 | + const [usersOffset, setUsersOffset] = useState(0) |
| 34 | + const [usersEnabled, setUsersEnabled] = useState(false) |
| 35 | + const [banUserId, setBanUserId] = useState<string | null>(null) |
| 36 | + const [banReason, setBanReason] = useState('') |
| 37 | + |
| 38 | + const { |
| 39 | + data: usersData, |
| 40 | + isLoading: usersLoading, |
| 41 | + error: usersError, |
| 42 | + refetch: refetchUsers, |
| 43 | + } = useAdminUsers(usersOffset, PAGE_SIZE, usersEnabled) |
| 44 | + |
| 45 | + const totalPages = useMemo( |
| 46 | + () => Math.ceil((usersData?.total ?? 0) / PAGE_SIZE), |
| 47 | + [usersData?.total] |
| 48 | + ) |
| 49 | + const currentPage = useMemo(() => Math.floor(usersOffset / PAGE_SIZE) + 1, [usersOffset]) |
| 50 | + |
| 51 | + const handleSuperUserModeToggle = async (checked: boolean) => { |
| 52 | + if (checked !== settings?.superUserModeEnabled) { |
| 53 | + await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked }) |
| 54 | + } |
| 55 | + } |
| 56 | + |
| 57 | + const handleImport = () => { |
| 58 | + if (!workflowId.trim()) return |
| 59 | + importWorkflow.mutate( |
| 60 | + { workflowId: workflowId.trim(), targetWorkspaceId: workspaceId }, |
| 61 | + { onSuccess: () => setWorkflowId('') } |
| 62 | + ) |
| 63 | + } |
| 64 | + |
| 65 | + const handleLoadUsers = () => { |
| 66 | + if (usersEnabled) { |
| 67 | + refetchUsers() |
| 68 | + } else { |
| 69 | + setUsersEnabled(true) |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | + const actionPending = setUserRole.isPending || banUser.isPending || unbanUser.isPending |
| 74 | + const pendingUserId = |
| 75 | + (setUserRole.variables as { userId?: string })?.userId ?? |
| 76 | + (banUser.variables as { userId?: string })?.userId ?? |
| 77 | + (unbanUser.variables as { userId?: string })?.userId ?? |
| 78 | + null |
| 79 | + |
| 80 | + return ( |
| 81 | + <div className='flex h-full flex-col gap-[24px]'> |
| 82 | + <div className='flex items-center justify-between'> |
| 83 | + <Label htmlFor='super-user-mode'>Super admin mode</Label> |
| 84 | + <Switch |
| 85 | + id='super-user-mode' |
| 86 | + checked={settings?.superUserModeEnabled ?? true} |
| 87 | + onCheckedChange={handleSuperUserModeToggle} |
| 88 | + /> |
| 89 | + </div> |
| 90 | + |
| 91 | + <div className='h-px bg-[var(--border-secondary)]' /> |
| 92 | + |
| 93 | + <div className='flex flex-col gap-[8px]'> |
| 94 | + <p className='text-[14px] text-[var(--text-secondary)]'> |
| 95 | + Import a workflow by ID along with its associated copilot chats. |
| 96 | + </p> |
| 97 | + <div className='flex gap-[8px]'> |
| 98 | + <EmcnInput |
| 99 | + value={workflowId} |
| 100 | + onChange={(e) => { |
| 101 | + setWorkflowId(e.target.value) |
| 102 | + importWorkflow.reset() |
| 103 | + }} |
| 104 | + placeholder='Enter workflow ID' |
| 105 | + disabled={importWorkflow.isPending} |
| 106 | + /> |
| 107 | + <Button |
| 108 | + variant='primary' |
| 109 | + onClick={handleImport} |
| 110 | + disabled={importWorkflow.isPending || !workflowId.trim()} |
| 111 | + > |
| 112 | + {importWorkflow.isPending ? 'Importing...' : 'Import'} |
| 113 | + </Button> |
| 114 | + </div> |
| 115 | + {importWorkflow.error && ( |
| 116 | + <p className='text-[13px] text-[var(--text-error)]'>{importWorkflow.error.message}</p> |
| 117 | + )} |
| 118 | + {importWorkflow.isSuccess && ( |
| 119 | + <p className='text-[13px] text-[var(--text-secondary)]'> |
| 120 | + Workflow imported successfully (new ID: {importWorkflow.data.newWorkflowId},{' '} |
| 121 | + {importWorkflow.data.copilotChatsImported ?? 0} copilot chats imported) |
| 122 | + </p> |
| 123 | + )} |
| 124 | + </div> |
| 125 | + |
| 126 | + <div className='h-px bg-[var(--border-secondary)]' /> |
| 127 | + |
| 128 | + <div className='flex flex-col gap-[12px]'> |
| 129 | + <div className='flex items-center justify-between'> |
| 130 | + <p className='font-medium text-[14px] text-[var(--text-primary)]'>User Management</p> |
| 131 | + <Button variant='active' onClick={handleLoadUsers} disabled={usersLoading}> |
| 132 | + {usersLoading ? 'Loading...' : usersEnabled ? 'Refresh' : 'Load Users'} |
| 133 | + </Button> |
| 134 | + </div> |
| 135 | + |
| 136 | + {usersError && ( |
| 137 | + <p className='text-[13px] text-[var(--text-error)]'> |
| 138 | + {usersError instanceof Error ? usersError.message : 'Failed to fetch users'} |
| 139 | + </p> |
| 140 | + )} |
| 141 | + |
| 142 | + {usersLoading && !usersData && ( |
| 143 | + <div className='flex flex-col gap-[8px]'> |
| 144 | + {Array.from({ length: 5 }).map((_, i) => ( |
| 145 | + <Skeleton key={i} className='h-[48px] w-full rounded-[6px]' /> |
| 146 | + ))} |
| 147 | + </div> |
| 148 | + )} |
| 149 | + |
| 150 | + {usersData && ( |
| 151 | + <> |
| 152 | + <div className='flex flex-col gap-[2px] rounded-[8px] border border-[var(--border-secondary)]'> |
| 153 | + <div className='flex items-center gap-[12px] border-[var(--border-secondary)] border-b px-[12px] py-[8px] text-[12px] text-[var(--text-tertiary)]'> |
| 154 | + <span className='w-[200px]'>Name</span> |
| 155 | + <span className='flex-1'>Email</span> |
| 156 | + <span className='w-[80px]'>Role</span> |
| 157 | + <span className='w-[80px]'>Status</span> |
| 158 | + <span className='w-[180px] text-right'>Actions</span> |
| 159 | + </div> |
| 160 | + |
| 161 | + {usersData.users.length === 0 && ( |
| 162 | + <div className='px-[12px] py-[16px] text-center text-[13px] text-[var(--text-tertiary)]'> |
| 163 | + No users found. |
| 164 | + </div> |
| 165 | + )} |
| 166 | + |
| 167 | + {usersData.users.map((u) => ( |
| 168 | + <div |
| 169 | + key={u.id} |
| 170 | + className={cn( |
| 171 | + 'flex items-center gap-[12px] px-[12px] py-[8px] text-[13px]', |
| 172 | + 'border-[var(--border-secondary)] border-b last:border-b-0' |
| 173 | + )} |
| 174 | + > |
| 175 | + <span className='w-[200px] truncate text-[var(--text-primary)]'> |
| 176 | + {u.name || '—'} |
| 177 | + </span> |
| 178 | + <span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span> |
| 179 | + <span className='w-[80px]'> |
| 180 | + <Badge variant={u.role === 'admin' ? 'default' : 'secondary'}> |
| 181 | + {u.role || 'user'} |
| 182 | + </Badge> |
| 183 | + </span> |
| 184 | + <span className='w-[80px]'> |
| 185 | + {u.banned ? ( |
| 186 | + <Badge variant='destructive'>Banned</Badge> |
| 187 | + ) : ( |
| 188 | + <Badge variant='secondary'>Active</Badge> |
| 189 | + )} |
| 190 | + </span> |
| 191 | + <span className='flex w-[180px] justify-end gap-[4px]'> |
| 192 | + {u.id !== session?.user?.id && ( |
| 193 | + <> |
| 194 | + <Button |
| 195 | + variant='active' |
| 196 | + className='h-[28px] px-[8px] text-[12px]' |
| 197 | + onClick={() => |
| 198 | + setUserRole.mutate({ |
| 199 | + userId: u.id, |
| 200 | + role: u.role === 'admin' ? 'user' : 'admin', |
| 201 | + }) |
| 202 | + } |
| 203 | + disabled={actionPending && pendingUserId === u.id} |
| 204 | + > |
| 205 | + {u.role === 'admin' ? 'Demote' : 'Promote'} |
| 206 | + </Button> |
| 207 | + {u.banned ? ( |
| 208 | + <Button |
| 209 | + variant='active' |
| 210 | + className='h-[28px] px-[8px] text-[12px]' |
| 211 | + onClick={() => unbanUser.mutate({ userId: u.id })} |
| 212 | + disabled={actionPending && pendingUserId === u.id} |
| 213 | + > |
| 214 | + Unban |
| 215 | + </Button> |
| 216 | + ) : banUserId === u.id ? ( |
| 217 | + <div className='flex gap-[4px]'> |
| 218 | + <EmcnInput |
| 219 | + value={banReason} |
| 220 | + onChange={(e) => setBanReason(e.target.value)} |
| 221 | + placeholder='Reason (optional)' |
| 222 | + className='h-[28px] w-[120px] text-[12px]' |
| 223 | + /> |
| 224 | + <Button |
| 225 | + variant='primary' |
| 226 | + className='h-[28px] px-[8px] text-[12px]' |
| 227 | + onClick={() => { |
| 228 | + banUser.mutate( |
| 229 | + { |
| 230 | + userId: u.id, |
| 231 | + ...(banReason.trim() ? { banReason: banReason.trim() } : {}), |
| 232 | + }, |
| 233 | + { |
| 234 | + onSuccess: () => { |
| 235 | + setBanUserId(null) |
| 236 | + setBanReason('') |
| 237 | + }, |
| 238 | + } |
| 239 | + ) |
| 240 | + }} |
| 241 | + disabled={actionPending && pendingUserId === u.id} |
| 242 | + > |
| 243 | + Confirm |
| 244 | + </Button> |
| 245 | + <Button |
| 246 | + variant='active' |
| 247 | + className='h-[28px] px-[8px] text-[12px]' |
| 248 | + onClick={() => { |
| 249 | + setBanUserId(null) |
| 250 | + setBanReason('') |
| 251 | + }} |
| 252 | + > |
| 253 | + Cancel |
| 254 | + </Button> |
| 255 | + </div> |
| 256 | + ) : ( |
| 257 | + <Button |
| 258 | + variant='active' |
| 259 | + className='h-[28px] px-[8px] text-[12px] text-[var(--text-error)]' |
| 260 | + onClick={() => setBanUserId(u.id)} |
| 261 | + disabled={actionPending && pendingUserId === u.id} |
| 262 | + > |
| 263 | + Ban |
| 264 | + </Button> |
| 265 | + )} |
| 266 | + </> |
| 267 | + )} |
| 268 | + </span> |
| 269 | + </div> |
| 270 | + ))} |
| 271 | + </div> |
| 272 | + |
| 273 | + {totalPages > 1 && ( |
| 274 | + <div className='flex items-center justify-between text-[13px] text-[var(--text-secondary)]'> |
| 275 | + <span> |
| 276 | + Page {currentPage} of {totalPages} ({usersData.total} users) |
| 277 | + </span> |
| 278 | + <div className='flex gap-[4px]'> |
| 279 | + <Button |
| 280 | + variant='active' |
| 281 | + className='h-[28px] px-[8px] text-[12px]' |
| 282 | + onClick={() => setUsersOffset((prev) => prev - PAGE_SIZE)} |
| 283 | + disabled={usersOffset === 0 || usersLoading} |
| 284 | + > |
| 285 | + Previous |
| 286 | + </Button> |
| 287 | + <Button |
| 288 | + variant='active' |
| 289 | + className='h-[28px] px-[8px] text-[12px]' |
| 290 | + onClick={() => setUsersOffset((prev) => prev + PAGE_SIZE)} |
| 291 | + disabled={usersOffset + PAGE_SIZE >= (usersData?.total ?? 0) || usersLoading} |
| 292 | + > |
| 293 | + Next |
| 294 | + </Button> |
| 295 | + </div> |
| 296 | + </div> |
| 297 | + )} |
| 298 | + </> |
| 299 | + )} |
| 300 | + </div> |
| 301 | + </div> |
| 302 | + ) |
| 303 | +} |
0 commit comments