Skip to content

Commit 95dde52

Browse files
committed
feat(settings): add unified Admin tab with user management
Consolidate superuser features into a single Admin settings tab: - Super admin mode toggle (moved from General) - Workflow import (moved from Debug) - User management via better-auth admin (list, set role, ban/unban) Replace Debug tab with Admin tab gated by requiresAdminRole. Add React Query hooks for admin user operations.
1 parent c86954f commit 95dde52

File tree

9 files changed

+449
-124
lines changed

9 files changed

+449
-124
lines changed

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
import dynamic from 'next/dynamic'
44
import { useSearchParams } from 'next/navigation'
55
import { Skeleton } from '@/components/emcn'
6+
import { AdminSkeleton } from '@/app/workspace/[workspaceId]/settings/components/admin/admin-skeleton'
67
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
78
import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
89
import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
910
import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton'
1011
import { CredentialsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
1112
import { CustomToolsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
12-
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
1313
import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton'
1414
import { InboxSkeleton } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton'
1515
import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton'
@@ -130,10 +130,10 @@ const Inbox = dynamic(
130130
import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox),
131131
{ loading: () => <InboxSkeleton /> }
132132
)
133-
const Debug = dynamic(
133+
const Admin = dynamic(
134134
() =>
135-
import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug),
136-
{ loading: () => <DebugSkeleton /> }
135+
import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin),
136+
{ loading: () => <AdminSkeleton /> }
137137
)
138138
const RecentlyDeleted = dynamic(
139139
() =>
@@ -185,7 +185,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
185185
{effectiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
186186
{effectiveSection === 'inbox' && <Inbox />}
187187
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
188-
{effectiveSection === 'debug' && <Debug />}
188+
{effectiveSection === 'admin' && <Admin />}
189189
</div>
190190
)
191191
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Skeleton } from '@/components/emcn'
2+
3+
export function AdminSkeleton() {
4+
return (
5+
<div className='flex h-full flex-col gap-[24px]'>
6+
<div className='flex items-center justify-between'>
7+
<Skeleton className='h-[14px] w-[120px]' />
8+
<Skeleton className='h-[20px] w-[36px] rounded-full' />
9+
</div>
10+
<div className='flex flex-col gap-[8px]'>
11+
<Skeleton className='h-[14px] w-[340px]' />
12+
<div className='flex gap-[8px]'>
13+
<Skeleton className='h-9 flex-1 rounded-[6px]' />
14+
<Skeleton className='h-9 w-[80px] rounded-[6px]' />
15+
</div>
16+
</div>
17+
<div className='flex flex-col gap-[8px]'>
18+
<Skeleton className='h-[14px] w-[120px]' />
19+
<Skeleton className='h-[200px] w-full rounded-[8px]' />
20+
</div>
21+
</div>
22+
)
23+
}
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
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+
}

apps/sim/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton.tsx

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)