mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -13,6 +13,7 @@ export type AppSession = {
|
||||
emailVerified?: boolean
|
||||
name?: string | null
|
||||
image?: string | null
|
||||
role?: string
|
||||
createdAt?: Date
|
||||
updatedAt?: Date
|
||||
} | null
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}, [
|
||||
|
||||
106
apps/sim/hooks/queries/admin-users.ts
Normal file
106
apps/sim/hooks/queries/admin-users.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
7
packages/db/migrations/0177_wise_puma.sql
Normal file
7
packages/db/migrations/0177_wise_puma.sql
Normal 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";
|
||||
13566
packages/db/migrations/meta/0177_snapshot.json
Normal file
13566
packages/db/migrations/meta/0177_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1233,6 +1233,13 @@
|
||||
"when": 1773574710090,
|
||||
"tag": "0176_clear_luckman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 177,
|
||||
"version": "7",
|
||||
"when": 1773698715278,
|
||||
"tag": "0177_wise_puma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user