feat(auth): migrate to better-auth admin plugin with unified Admin tab (#3612)

* feat(auth): migrate to better-auth admin plugin

* 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.

* fix(db): backfill existing super users to admin role in migration

Add UPDATE statement to promote is_super_user=true rows to role='admin'
before dropping the is_super_user column, preventing silent demotion.

* fix(admin): resolve type errors in admin tab

- Fix cn import path to @/lib/core/utils/cn
- Use valid Badge variants (blue/gray/red/green instead of secondary/destructive)
- Type setRole param as 'user' | 'admin' union

* improvement(auth): remove /api/user/super-user route, use session role

Include user.role in customSession so it's available client-side.
Replace all useSuperUserStatus() calls with session.user.role === 'admin'.
Delete the now-redundant /api/user/super-user endpoint.

* chore(auth): remove redundant role override in customSession

The admin plugin already includes role on the user object.
No need to manually spread it in customSession.

* improvement(queries): clean up admin-users hooks per React Query best practices

- Remove unsafe unknown/Record casting, use better-auth typed response
- Add placeholderData: keepPreviousData for paginated variable-key query
- Remove nullable types where defaults are always applied

* fix(admin): address review feedback on admin tab

- Fix superUserModeEnabled default to false (matches sidebar behavior)
- Reset banReason when switching ban target to prevent state bleed
- Guard admin section render with session role check for direct URL access

* fix(settings): align superUserModeEnabled default to false everywhere

Three places defaulted to true while admin tab and sidebar used false.
Align all to false so new admins see consistent behavior.

* fix(admin): fix stale pendingUserId, add isPending guard and error feedback

- Only read mutation.variables when mutation isPending (prevents stale ID)
- Add isPending guard to super user mode toggle (prevents concurrent mutations)
- Show inline error message when setRole/ban/unban mutations fail

* fix(admin): concurrent pending users Set, session loading guard, domain blocking

- Replace pendingUserId scalar with pendingUserIds Set (useMemo) so concurrent
  mutations across different users each disable their own row correctly
- Add sessionLoading guard to admin section redirect to prevent flash on direct
  /settings/admin navigation before session resolves
- Add BLOCKED_SIGNUP_DOMAINS env var and before-hook for email domain denylist,
  parsed once at module init as a Set for O(1) per-request lookups
- Add trailing newline to migration file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(admin): close OAuth domain bypass, fix stale errors, deduplicate icon

- Add databaseHooks.user.create.before to enforce BLOCKED_SIGNUP_DOMAINS at
  the model level, covering all signup vectors (email, OAuth, social) not just
  /sign-up paths
- Call .reset() on each mutation before firing to clear stale error state from
  previous operations
- Change Admin nav icon from ShieldCheck to Lock to avoid duplicate with
  Access Control tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-03-17 15:04:54 -07:00
committed by GitHub
parent 35c42ba227
commit 25a03f1f3c
24 changed files with 14103 additions and 230 deletions

View File

@@ -13,6 +13,7 @@ export type AppSession = {
emailVerified?: boolean
name?: string | null
image?: string | null
role?: string
createdAt?: Date
updatedAt?: Date
} | null

View File

@@ -1,42 +0,0 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('SuperUserAPI')
export const revalidate = 0
// GET /api/user/super-user - Check if current user is a super user (database status)
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized super user status check attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
if (currentUser.length === 0) {
logger.warn(`[${requestId}] User not found: ${session.user.id}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({
isSuperUser: currentUser[0].isSuperUser,
})
} catch (error) {
logger.error(`[${requestId}] Error checking super user status`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -72,7 +72,7 @@ export async function GET() {
emailPreferences: userSettings.emailPreferences ?? {},
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
superUserModeEnabled: userSettings.superUserModeEnabled ?? false,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
showActionBar: userSettings.showActionBar ?? true,

View File

@@ -148,7 +148,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
Array<{ organizationId: string; role: string }>
>([])
const [isSuperUser, setIsSuperUser] = useState(false)
const isSuperUser = session?.user?.role === 'admin'
const [isUsing, setIsUsing] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [isApproving, setIsApproving] = useState(false)
@@ -186,21 +186,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}
const fetchSuperUserStatus = async () => {
if (!currentUserId) return
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser || false)
}
} catch (error) {
logger.error('Error fetching super user status:', error)
}
}
fetchSuperUserStatus()
fetchUserOrganizations()
}, [currentUserId])

View File

@@ -3,13 +3,14 @@
import dynamic from 'next/dynamic'
import { useSearchParams } from 'next/navigation'
import { Skeleton } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { AdminSkeleton } from '@/app/workspace/[workspaceId]/settings/components/admin/admin-skeleton'
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton'
import { CredentialsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
import { CustomToolsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton'
import { InboxSkeleton } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton'
import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton'
@@ -130,10 +131,10 @@ const Inbox = dynamic(
import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox),
{ loading: () => <InboxSkeleton /> }
)
const Debug = dynamic(
const Admin = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug),
{ loading: () => <DebugSkeleton /> }
import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin),
{ loading: () => <AdminSkeleton /> }
)
const RecentlyDeleted = dynamic(
() =>
@@ -157,9 +158,15 @@ interface SettingsPageProps {
export function SettingsPage({ section }: SettingsPageProps) {
const searchParams = useSearchParams()
const mcpServerId = searchParams.get('mcpServerId')
const { data: session, isPending: sessionLoading } = useSession()
const isAdminRole = session?.user?.role === 'admin'
const effectiveSection =
!isBillingEnabled && (section === 'subscription' || section === 'team') ? 'general' : section
!isBillingEnabled && (section === 'subscription' || section === 'team')
? 'general'
: section === 'admin' && !sessionLoading && !isAdminRole
? 'general'
: section
const label =
allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection
@@ -185,7 +192,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
{effectiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{effectiveSection === 'inbox' && <Inbox />}
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
{effectiveSection === 'debug' && <Debug />}
{effectiveSection === 'admin' && <Admin />}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { Skeleton } from '@/components/emcn'
export function AdminSkeleton() {
return (
<div className='flex h-full flex-col gap-[24px]'>
<div className='flex items-center justify-between'>
<Skeleton className='h-[14px] w-[120px]' />
<Skeleton className='h-[20px] w-[36px] rounded-full' />
</div>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[340px]' />
<div className='flex gap-[8px]'>
<Skeleton className='h-9 flex-1 rounded-[6px]' />
<Skeleton className='h-9 w-[80px] rounded-[6px]' />
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[120px]' />
<Skeleton className='h-[200px] w-full rounded-[8px]' />
</div>
</div>
)
}

View File

@@ -0,0 +1,326 @@
'use client'
import { useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Badge, Button, Input as EmcnInput, Label, Skeleton, Switch } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import {
useAdminUsers,
useBanUser,
useSetUserRole,
useUnbanUser,
} from '@/hooks/queries/admin-users'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useImportWorkflow } from '@/hooks/queries/workflows'
const PAGE_SIZE = 20 as const
export function Admin() {
const params = useParams()
const workspaceId = params?.workspaceId as string
const { data: session } = useSession()
const { data: settings } = useGeneralSettings()
const updateSetting = useUpdateGeneralSetting()
const importWorkflow = useImportWorkflow()
const setUserRole = useSetUserRole()
const banUser = useBanUser()
const unbanUser = useUnbanUser()
const [workflowId, setWorkflowId] = useState('')
const [usersOffset, setUsersOffset] = useState(0)
const [usersEnabled, setUsersEnabled] = useState(false)
const [banUserId, setBanUserId] = useState<string | null>(null)
const [banReason, setBanReason] = useState('')
const {
data: usersData,
isLoading: usersLoading,
error: usersError,
refetch: refetchUsers,
} = useAdminUsers(usersOffset, PAGE_SIZE, usersEnabled)
const totalPages = useMemo(
() => Math.ceil((usersData?.total ?? 0) / PAGE_SIZE),
[usersData?.total]
)
const currentPage = useMemo(() => Math.floor(usersOffset / PAGE_SIZE) + 1, [usersOffset])
const handleSuperUserModeToggle = async (checked: boolean) => {
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
}
}
const handleImport = () => {
if (!workflowId.trim()) return
importWorkflow.mutate(
{ workflowId: workflowId.trim(), targetWorkspaceId: workspaceId },
{ onSuccess: () => setWorkflowId('') }
)
}
const handleLoadUsers = () => {
if (usersEnabled) {
refetchUsers()
} else {
setUsersEnabled(true)
}
}
const pendingUserIds = useMemo(() => {
const ids = new Set<string>()
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
ids.add((setUserRole.variables as { userId: string }).userId)
if (banUser.isPending && (banUser.variables as { userId?: string })?.userId)
ids.add((banUser.variables as { userId: string }).userId)
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
ids.add((unbanUser.variables as { userId: string }).userId)
return ids
}, [
setUserRole.isPending,
setUserRole.variables,
banUser.isPending,
banUser.variables,
unbanUser.isPending,
unbanUser.variables,
])
return (
<div className='flex h-full flex-col gap-[24px]'>
<div className='flex items-center justify-between'>
<Label htmlFor='super-user-mode'>Super admin mode</Label>
<Switch
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? false}
onCheckedChange={handleSuperUserModeToggle}
/>
</div>
<div className='h-px bg-[var(--border-secondary)]' />
<div className='flex flex-col gap-[8px]'>
<p className='text-[14px] text-[var(--text-secondary)]'>
Import a workflow by ID along with its associated copilot chats.
</p>
<div className='flex gap-[8px]'>
<EmcnInput
value={workflowId}
onChange={(e) => {
setWorkflowId(e.target.value)
importWorkflow.reset()
}}
placeholder='Enter workflow ID'
disabled={importWorkflow.isPending}
/>
<Button
variant='primary'
onClick={handleImport}
disabled={importWorkflow.isPending || !workflowId.trim()}
>
{importWorkflow.isPending ? 'Importing...' : 'Import'}
</Button>
</div>
{importWorkflow.error && (
<p className='text-[13px] text-[var(--text-error)]'>{importWorkflow.error.message}</p>
)}
{importWorkflow.isSuccess && (
<p className='text-[13px] text-[var(--text-secondary)]'>
Workflow imported successfully (new ID: {importWorkflow.data.newWorkflowId},{' '}
{importWorkflow.data.copilotChatsImported ?? 0} copilot chats imported)
</p>
)}
</div>
<div className='h-px bg-[var(--border-secondary)]' />
<div className='flex flex-col gap-[12px]'>
<div className='flex items-center justify-between'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>User Management</p>
<Button variant='active' onClick={handleLoadUsers} disabled={usersLoading}>
{usersLoading ? 'Loading...' : usersEnabled ? 'Refresh' : 'Load Users'}
</Button>
</div>
{usersError && (
<p className='text-[13px] text-[var(--text-error)]'>
{usersError instanceof Error ? usersError.message : 'Failed to fetch users'}
</p>
)}
{(setUserRole.error || banUser.error || unbanUser.error) && (
<p className='text-[13px] text-[var(--text-error)]'>
{(setUserRole.error || banUser.error || unbanUser.error)?.message ??
'Action failed. Please try again.'}
</p>
)}
{usersLoading && !usersData && (
<div className='flex flex-col gap-[8px]'>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className='h-[48px] w-full rounded-[6px]' />
))}
</div>
)}
{usersData && (
<>
<div className='flex flex-col gap-[2px] rounded-[8px] border border-[var(--border-secondary)]'>
<div className='flex items-center gap-[12px] border-[var(--border-secondary)] border-b px-[12px] py-[8px] text-[12px] text-[var(--text-tertiary)]'>
<span className='w-[200px]'>Name</span>
<span className='flex-1'>Email</span>
<span className='w-[80px]'>Role</span>
<span className='w-[80px]'>Status</span>
<span className='w-[180px] text-right'>Actions</span>
</div>
{usersData.users.length === 0 && (
<div className='px-[12px] py-[16px] text-center text-[13px] text-[var(--text-tertiary)]'>
No users found.
</div>
)}
{usersData.users.map((u) => (
<div
key={u.id}
className={cn(
'flex items-center gap-[12px] px-[12px] py-[8px] text-[13px]',
'border-[var(--border-secondary)] border-b last:border-b-0'
)}
>
<span className='w-[200px] truncate text-[var(--text-primary)]'>
{u.name || '—'}
</span>
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
<span className='w-[80px]'>
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
</span>
<span className='w-[80px]'>
{u.banned ? (
<Badge variant='red'>Banned</Badge>
) : (
<Badge variant='green'>Active</Badge>
)}
</span>
<span className='flex w-[180px] justify-end gap-[4px]'>
{u.id !== session?.user?.id && (
<>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => {
setUserRole.reset()
setUserRole.mutate({
userId: u.id,
role: u.role === 'admin' ? 'user' : 'admin',
})
}}
disabled={pendingUserIds.has(u.id)}
>
{u.role === 'admin' ? 'Demote' : 'Promote'}
</Button>
{u.banned ? (
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => {
unbanUser.reset()
unbanUser.mutate({ userId: u.id })
}}
disabled={pendingUserIds.has(u.id)}
>
Unban
</Button>
) : banUserId === u.id ? (
<div className='flex gap-[4px]'>
<EmcnInput
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder='Reason (optional)'
className='h-[28px] w-[120px] text-[12px]'
/>
<Button
variant='primary'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => {
banUser.reset()
banUser.mutate(
{
userId: u.id,
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
},
{
onSuccess: () => {
setBanUserId(null)
setBanReason('')
},
}
)
}}
disabled={pendingUserIds.has(u.id)}
>
Confirm
</Button>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => {
setBanUserId(null)
setBanReason('')
}}
>
Cancel
</Button>
</div>
) : (
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px] text-[var(--text-error)]'
onClick={() => {
setBanUserId(u.id)
setBanReason('')
}}
disabled={pendingUserIds.has(u.id)}
>
Ban
</Button>
)}
</>
)}
</span>
</div>
))}
</div>
{totalPages > 1 && (
<div className='flex items-center justify-between text-[13px] text-[var(--text-secondary)]'>
<span>
Page {currentPage} of {totalPages} ({usersData.total} users)
</span>
<div className='flex gap-[4px]'>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => setUsersOffset((prev) => prev - PAGE_SIZE)}
disabled={usersOffset === 0 || usersLoading}
>
Previous
</Button>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => setUsersOffset((prev) => prev + PAGE_SIZE)}
disabled={usersOffset + PAGE_SIZE >= (usersData?.total ?? 0) || usersLoading}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -1,17 +0,0 @@
import { Skeleton } from '@/components/emcn'
/**
* Skeleton for the Debug section shown during dynamic import loading.
* Matches the layout: description text + input/button row.
*/
export function DebugSkeleton() {
return (
<div className='flex h-full flex-col gap-[18px]'>
<Skeleton className='h-[14px] w-[340px]' />
<div className='flex gap-[8px]'>
<Skeleton className='h-9 flex-1 rounded-[6px]' />
<Skeleton className='h-9 w-[80px] rounded-[6px]' />
</div>
</div>
)
}

View File

@@ -1,75 +0,0 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import { Button, Input as EmcnInput } from '@/components/emcn'
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
import { useImportWorkflow } from '@/hooks/queries/workflows'
/**
* Debug settings component for superusers.
* Allows importing workflows by ID for debugging purposes.
*/
export function Debug() {
const params = useParams()
const workspaceId = params?.workspaceId as string
const [workflowId, setWorkflowId] = useState('')
const importWorkflow = useImportWorkflow()
const handleImport = () => {
if (!workflowId.trim()) return
importWorkflow.mutate(
{
workflowId: workflowId.trim(),
targetWorkspaceId: workspaceId,
},
{
onSuccess: () => {
setWorkflowId('')
},
}
)
}
return (
<div className='flex h-full flex-col gap-[18px]'>
<p className='text-[14px] text-[var(--text-secondary)]'>
Import a workflow by ID along with its associated copilot chats.
</p>
<div className='flex gap-[8px]'>
<EmcnInput
value={workflowId}
onChange={(e) => {
setWorkflowId(e.target.value)
importWorkflow.reset()
}}
placeholder='Enter workflow ID'
disabled={importWorkflow.isPending}
/>
<Button
variant='primary'
onClick={handleImport}
disabled={importWorkflow.isPending || !workflowId.trim()}
>
{importWorkflow.isPending ? 'Importing...' : 'Import'}
</Button>
</div>
{importWorkflow.isPending && <DebugSkeleton />}
{importWorkflow.error && (
<p className='text-[13px] text-[var(--text-error)]'>{importWorkflow.error.message}</p>
)}
{importWorkflow.isSuccess && (
<p className='text-[13px] text-[var(--text-secondary)]'>
Workflow imported successfully (new ID: {importWorkflow.data.newWorkflowId},{' '}
{importWorkflow.data.copilotChatsImported ?? 0} copilot chats imported)
</p>
)}
</div>
)
}

View File

@@ -28,7 +28,6 @@ import { useBrandConfig } from '@/ee/whitelabeling'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import {
useResetPassword,
useSuperUserStatus,
useUpdateUserProfile,
useUserProfile,
} from '@/hooks/queries/user-profile'
@@ -66,9 +65,6 @@ export function General() {
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
const isAuthDisabled = session?.user?.id === ANONYMOUS_USER_ID
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
const isSuperUser = superUserData?.isSuperUser ?? false
const [name, setName] = useState(profile?.name || '')
const [isEditingName, setIsEditingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -227,12 +223,6 @@ export function General() {
}
}
const handleSuperUserModeToggle = async (checked: boolean) => {
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
}
}
const handleTelemetryToggle = async (checked: boolean) => {
if (checked !== settings?.telemetryEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'telemetryEnabled', value: checked })
@@ -458,17 +448,6 @@ export function General() {
</div>
)}
{isSuperUser && (
<div className='flex items-center justify-between'>
<Label htmlFor='super-user-mode'>Super admin mode</Label>
<Switch
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? true}
onCheckedChange={handleSuperUserModeToggle}
/>
</div>
)}
<div className='mt-auto flex items-center gap-[8px]'>
{!isAuthDisabled && (
<>

View File

@@ -1,11 +1,11 @@
import {
BookOpen,
Bug,
Card,
Connections,
HexSimple,
Key,
KeySquare,
Lock,
LogIn,
Mail,
Send,
@@ -40,7 +40,7 @@ export type SettingsSection =
| 'workflow-mcp-servers'
| 'inbox'
| 'docs'
| 'debug'
| 'admin'
| 'recently-deleted'
export type NavigationSection =
@@ -62,6 +62,7 @@ export interface NavigationItem {
requiresHosted?: boolean
selfHostedOverride?: boolean
requiresSuperUser?: boolean
requiresAdminRole?: boolean
externalUrl?: string
}
@@ -165,10 +166,10 @@ export const allNavigationItems: NavigationItem[] = [
externalUrl: 'https://docs.sim.ai',
},
{
id: 'debug',
label: 'Debug',
icon: Bug,
id: 'admin',
label: 'Admin',
icon: Lock,
section: 'superuser',
requiresSuperUser: true,
requiresAdminRole: true,
},
]

View File

@@ -44,9 +44,9 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
redirect(`/workspace/${workspaceId}`)
}
// Determine effective super user (DB flag AND UI mode enabled)
// Determine effective super user (admin role AND UI mode enabled)
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.select({ role: user.role })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
@@ -56,8 +56,8 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
.where(eq(settings.userId, session.user.id))
.limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
const isSuperUser = currentUser[0]?.role === 'admin'
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? false
const effectiveSuperUser = isSuperUser && superUserModeEnabled
// Load templates from database

View File

@@ -20,7 +20,6 @@ import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials'
import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings'
import { useOrganizations } from '@/hooks/queries/organization'
import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription'
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -49,7 +48,6 @@ export function SettingsSidebar({
staleTime: 5 * 60 * 1000,
})
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig()
@@ -65,7 +63,7 @@ export function SettingsSidebar({
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const isSuperUser = superUserData?.isSuperUser ?? false
const isSuperUser = session?.user?.role === 'admin'
const isSSOProviderOwner = useMemo(() => {
if (isHosted) return null
@@ -123,6 +121,10 @@ export function SettingsSidebar({
return false
}
if (item.requiresAdminRole && !isSuperUser) {
return false
}
return true
})
}, [

View File

@@ -0,0 +1,106 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client } from '@/lib/auth/auth-client'
const logger = createLogger('AdminUsersQuery')
export const adminUserKeys = {
all: ['adminUsers'] as const,
lists: () => [...adminUserKeys.all, 'list'] as const,
list: (offset: number, limit: number) => [...adminUserKeys.lists(), offset, limit] as const,
}
interface AdminUser {
id: string
name: string
email: string
role: string
banned: boolean
banReason: string | null
}
interface AdminUsersResponse {
users: AdminUser[]
total: number
}
async function fetchAdminUsers(offset: number, limit: number): Promise<AdminUsersResponse> {
const { data, error } = await client.admin.listUsers({
query: { limit, offset },
})
if (error) {
throw new Error(error.message ?? 'Failed to fetch users')
}
return {
users: (data?.users ?? []).map((u) => ({
id: u.id,
name: u.name || '',
email: u.email,
role: u.role ?? 'user',
banned: u.banned ?? false,
banReason: u.banReason ?? null,
})),
total: data?.total ?? 0,
}
}
export function useAdminUsers(offset: number, limit: number, enabled: boolean) {
return useQuery({
queryKey: adminUserKeys.list(offset, limit),
queryFn: () => fetchAdminUsers(offset, limit),
enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
export function useSetUserRole() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, role }: { userId: string; role: 'user' | 'admin' }) => {
const result = await client.admin.setRole({ userId, role })
return result
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() })
},
onError: (err) => {
logger.error('Failed to set user role', err)
},
})
}
export function useBanUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, banReason }: { userId: string; banReason?: string }) => {
const result = await client.admin.banUser({
userId,
...(banReason ? { banReason } : {}),
})
return result
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() })
},
onError: (err) => {
logger.error('Failed to ban user', err)
},
})
}
export function useUnbanUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId }: { userId: string }) => {
const result = await client.admin.unbanUser({ userId })
return result
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() })
},
onError: (err) => {
logger.error('Failed to unban user', err)
},
})
}

View File

@@ -36,7 +36,7 @@ export function mapGeneralSettingsResponse(data: Record<string, unknown>): Gener
return {
autoConnect: (data.autoConnect as boolean) ?? true,
showTrainingControls: (data.showTrainingControls as boolean) ?? false,
superUserModeEnabled: (data.superUserModeEnabled as boolean) ?? true,
superUserModeEnabled: (data.superUserModeEnabled as boolean) ?? false,
theme: (data.theme as GeneralSettings['theme']) || 'system',
telemetryEnabled: (data.telemetryEnabled as boolean) ?? true,
billingUsageNotificationsEnabled: (data.billingUsageNotificationsEnabled as boolean) ?? true,

View File

@@ -9,7 +9,6 @@ const logger = createLogger('UserProfileQuery')
export const userProfileKeys = {
all: ['userProfile'] as const,
profile: () => [...userProfileKeys.all, 'profile'] as const,
superUser: (userId?: string) => [...userProfileKeys.all, 'superUser', userId ?? ''] as const,
}
/**
@@ -117,40 +116,6 @@ export function useUpdateUserProfile() {
})
}
/**
* Superuser status response type
*/
interface SuperUserStatus {
isSuperUser: boolean
}
/**
* Fetch superuser status from API
*/
async function fetchSuperUserStatus(signal?: AbortSignal): Promise<SuperUserStatus> {
const response = await fetch('/api/user/super-user', { signal })
if (!response.ok) {
return { isSuperUser: false }
}
const data = await response.json()
return { isSuperUser: data.isSuperUser ?? false }
}
/**
* Hook to fetch superuser status
* @param userId - User ID for cache isolation (required for proper per-user caching)
*/
export function useSuperUserStatus(userId?: string) {
return useQuery({
queryKey: userProfileKeys.superUser(userId),
queryFn: ({ signal }) => fetchSuperUserStatus(signal),
enabled: Boolean(userId),
staleTime: 5 * 60 * 1000, // 5 minutes - superuser status rarely changes
})
}
/**
* Reset password mutation
*/

View File

@@ -2,6 +2,7 @@ import { useContext } from 'react'
import { ssoClient } from '@better-auth/sso/client'
import { stripeClient } from '@better-auth/stripe/client'
import {
adminClient,
customSessionClient,
emailOTPClient,
genericOAuthClient,
@@ -17,6 +18,7 @@ import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/s
export const client = createAuthClient({
baseURL: getBaseUrl(),
plugins: [
adminClient(),
emailOTPClient(),
genericOAuthClient(),
customSessionClient<typeof auth>(),

View File

@@ -7,6 +7,7 @@ import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import {
admin,
createAuthMiddleware,
customSession,
emailOTP,
@@ -78,6 +79,10 @@ const logger = createLogger('Auth')
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS
? new Set(env.BLOCKED_SIGNUP_DOMAINS.split(',').map((d) => d.trim().toLowerCase()))
: null
const validStripeKey = env.STRIPE_SECRET_KEY
let stripeClient = null
@@ -111,6 +116,15 @@ export const auth = betterAuth({
databaseHooks: {
user: {
create: {
before: async (user) => {
if (blockedSignupDomains) {
const emailDomain = user.email?.split('@')[1]?.toLowerCase()
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
throw new Error('Sign-ups from this email domain are not allowed.')
}
}
return { data: user }
},
after: async (user) => {
logger.info('[databaseHooks.user.create.after] User created, initializing stats', {
userId: user.id,
@@ -598,6 +612,16 @@ export const auth = betterAuth({
}
}
if (ctx.path.startsWith('/sign-up') && blockedSignupDomains) {
const requestEmail = ctx.body?.email?.toLowerCase()
if (requestEmail) {
const emailDomain = requestEmail.split('@')[1]
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
throw new Error('Sign-ups from this email domain are not allowed.')
}
}
}
if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') {
const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined
if (clientId && isMetadataUrl(clientId)) {
@@ -625,6 +649,7 @@ export const auth = betterAuth({
},
plugins: [
nextCookies(),
admin(),
jwt({
jwks: {
keyPairConfig: { alg: 'RS256' },

View File

@@ -24,6 +24,7 @@ export const env = createEnv({
DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session)
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS)
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication

View File

@@ -17,7 +17,7 @@ export async function verifyEffectiveSuperUser(userId: string): Promise<{
superUserModeEnabled: boolean
}> {
const [currentUser] = await db
.select({ isSuperUser: user.isSuperUser })
.select({ role: user.role })
.from(user)
.where(eq(user.id, userId))
.limit(1)
@@ -28,7 +28,7 @@ export async function verifyEffectiveSuperUser(userId: string): Promise<{
.where(eq(settings.userId, userId))
.limit(1)
const isSuperUser = currentUser?.isSuperUser || false
const isSuperUser = currentUser?.role === 'admin'
const superUserModeEnabled = userSettings?.superUserModeEnabled ?? false
return {

View File

@@ -0,0 +1,7 @@
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "role" text DEFAULT 'user';--> statement-breakpoint
UPDATE "user" SET "role" = 'admin' WHERE "is_super_user" = true;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "ban_reason" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp;--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "is_super_user";

File diff suppressed because it is too large Load Diff

View File

@@ -1233,6 +1233,13 @@
"when": 1773574710090,
"tag": "0176_clear_luckman",
"breakpoints": true
},
{
"idx": 177,
"version": "7",
"when": 1773698715278,
"tag": "0177_wise_puma",
"breakpoints": true
}
]
}

View File

@@ -38,7 +38,10 @@ export const user = pgTable('user', {
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
stripeCustomerId: text('stripe_customer_id'),
isSuperUser: boolean('is_super_user').notNull().default(false),
role: text('role').default('user'),
banned: boolean('banned').default(false),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'),
})
export const session = pgTable(
@@ -57,6 +60,7 @@ export const session = pgTable(
activeOrganizationId: text('active_organization_id').references(() => organization.id, {
onDelete: 'set null',
}),
impersonatedBy: text('impersonated_by'),
},
(table) => ({
userIdIdx: index('session_user_id_idx').on(table.userId),